scripts: la superficie medida — 0 guiones POSIX roto bajo ash, y los 28 bash lo son por ARRAYS
Pregunta distinta a la de las cards, y el doc lo dice: scripts/ corre en el hub bajo el bash del anfitrión, así que su superficie sólo obliga el día del auto-alojamiento. Lo que importa hoy es si algún guion se declara POSIX y no lo es, porque ése se rompe en la imagen, donde /bin/sh es el ash de busybox. - 81 POSIX (10.167 líneas), 76 bash (10.269), 21 EMPOTRADOS por heredoc (los /init que viajan a la imagen, y que hoy nadie parsea), 6 que se hacen source - los 81 POSIX y los 21 empotrados los parsea el ash: CERO roto - 48 de los 76 bash parsean tal cual bajo ash; de los 28 que no, 26 lo son por arrays y nada más. Ése es el presupuesto entero de un sh propio para correr la herramienta del proyecto - 27 builtins, 257 órdenes por nombre desnudo y 35 por ruta absoluta; 0 applets retirados, o sea que este inventario y busybox-vigia.py ahora coinciden El escáner se extrajo a scripts/lib/sh_analisis.py para no tener dos copias que divergan (ADR 0019 un piso más abajo). Comprobado que la extracción no cambió la salida de sh-superficie-cards.py, byte a byte, y que el orden de los empates es determinista para que sea diffable. Van anotados los SIETE bugs del instrumento, que es la parte que enseña: los comentarios sin quitar (89 backticks de prosa en cosecha-cron.sh), shlex desincronizándose sobre el fichero entero, los cuerpos de heredoc (uno era Python y aportaba la orden 'p' — sacarlos bajó las externas de 680 a 257), los patrones de case dando 'init', los cuerpos de $(( )) dando 'i+1', los descriptores dando '2', y la ruta absoluta confundida con applet. Ninguno se veía en la salida: los siete daban números plausibles.
This commit is contained in:
+137
-6
@@ -665,18 +665,23 @@ test -f /etc/x/x.conf || { echo 'x: falta … — es config del SITIO' >&2; exit
|
||||
**Órdenes externas que la imagen tiene que traer: 14.** `mkdir`×14 `grep`×11 `chown`×4 `id`×4
|
||||
`netup`×2 `ssh-keygen`×2 `chmod`×2 `mount`×2 `nft` `hostname` `basename` `blkid` `df` `awk`.
|
||||
|
||||
## El guardián que faltaba, ahora corre
|
||||
|
||||
Cruzar esas 14 con `docs/state/busybox-applets.tsv` es exactamente el guardián que este documento
|
||||
pedía y nadie tenía:
|
||||
## El cruce con el recorte — y una CORRECCIÓN
|
||||
|
||||
```
|
||||
siguen en busybox: 11 · no son applets (otro paquete): 3 (netup, nft, ssh-keygen) · RETIRADAS: 0
|
||||
✓ ninguna card invoca un applet retirado por el recorte 401 → 277.
|
||||
```
|
||||
|
||||
El recorte no rompió ninguna card. **Y ahora es un número que se vuelve a sacar en un segundo**, en
|
||||
vez de un fallo que aparecería en el arranque de otra máquina semanas después.
|
||||
⚠ **Acá se escribió que esto «es el guardián que el plan pedía y nadie tenía». Es falso: el
|
||||
guardián EXISTE** — `scripts/busybox-vigia.py`, escrito con el paso 1, que lee los retirados de
|
||||
`recipes/busybox.toml`, los cruza con la columna `simbolo_kconfig` del TSV y **falla si alguna
|
||||
receta, script o card invoca uno**. Corrido hoy: `123 applets retirados de la receta · OK — nadie
|
||||
los invoca`, `rc=0`. La sección «Falta un guardián» de más arriba quedó vieja el mismo día.
|
||||
|
||||
Lo que el cruce de este instrumento agrega es otra cosa, y conviene no confundirlas: el vigía
|
||||
contesta «¿alguien invoca algo que ya no está?» (una puerta, con `rc≠0`); esto contesta **«¿qué
|
||||
necesitan las cards para arrancar?»** — inventario, no puerta, y por eso incluye las que no son
|
||||
applets (`netup`, `nft`, `ssh-keygen`), que al vigía no le importan y a un perfil de imagen sí.
|
||||
|
||||
## Y los 23 fragmentos, por el banco diferencial
|
||||
|
||||
@@ -775,3 +780,129 @@ sandbox de build sigue siendo otro planeta**, y es el que conviene no mezclar.
|
||||
- **No mide `scripts/`**, que es su propia unidad de trabajo y es mucho más grande.
|
||||
- **No mide las cards REALES de la mudanza**: dependen de un censo que no está en el árbol.
|
||||
- **No ejecuta los fragmentos**, sólo los parsea.
|
||||
|
||||
---
|
||||
|
||||
# La superficie de `scripts/`, MEDIDA — 2026-09-21
|
||||
|
||||
**Instrumento:** `scripts/sh-superficie-scripts.py` · **escáner compartido:**
|
||||
`scripts/lib/sh_analisis.py` · **salida:** `work/sh-superficie-scripts.tsv`.
|
||||
|
||||
⚠ **Es una pregunta DISTINTA a la de las cards, y mezclarlas es el error.** Las cards las ejecuta
|
||||
el producto ⇒ su superficie es el contrato de un `/bin/sh` propio hoy. `scripts/` lo ejecutan el
|
||||
hub y el worker bajo el bash del anfitrión ⇒ su superficie sólo obliga el día del auto-alojamiento.
|
||||
Lo que sí importa hoy acá es otra cosa:
|
||||
|
||||
> **¿algún guion se declara POSIX y no lo es?** Porque ése se rompe HOY: en la imagen `/bin/sh` es
|
||||
> el ash de busybox, no bash.
|
||||
|
||||
## Las cuatro poblaciones, y quién las parsea
|
||||
|
||||
De 118 ficheros descartados por no ser shell (`.html`, `.c`, `.mp4`, `.toml`…):
|
||||
|
||||
| población | n | líneas | parsea ash | parsea bash |
|
||||
|---|---|---|---|---|
|
||||
| **POSIX** (`#!/bin/sh`, `#!/usr/bin/env sh`) | 81 | 10 167 | **81/81** | 81/81 |
|
||||
| **bash** (`#!/bin/bash`, `#!/usr/bin/env bash`) | 76 | 10 269 | **48/76** | 76/76 |
|
||||
| **EMPOTRADOS** (un `/init` escrito por heredoc) | 21 | 714 | **21/21** | 21/21 |
|
||||
| **`source`-only** (`.sh` sin shebang) | 6 | 138 | 6/6 | 6/6 |
|
||||
|
||||
> **Cero guiones se declaran POSIX y fallan bajo ash.** Ni los 81 sueltos ni los 21 empotrados.
|
||||
|
||||
La clasificación es por la **primera línea**, no por «contiene un shebang»: un `grep -rl '^#!/bin/sh'`
|
||||
da 86 porque cuenta los scripts bash que EMPOTRAN un `#!/bin/sh` en un heredoc. Con ese grep,
|
||||
`mirada-usb.sh` y `selfhost-verify.sh` aparecían como «POSIX roto bajo ash» — y son bash legítimos.
|
||||
|
||||
**Los 21 empotrados son la población que importa para el producto**, y hoy nadie los parsea: son los
|
||||
`/init` de los initramfs y los envoltorios que viajan a la imagen, escritos por 13 ficheros
|
||||
(`takana-live-install.sh` ×3, `metal-desktop-image.sh` ×5, `install-image-efi.sh` ×2, `disk-image.sh`,
|
||||
`metal-iso.sh`, `metal-usb-sdboot.sh`, `mirada-usb.sh`, `selfhost-verify.sh`, `cc-por-zig.sh`,
|
||||
`jaula-herramientas.sh`, `atuq-en-cosmic.sh`, `dunst-headless.sh`, `metal-desktop-image-dual.sh`).
|
||||
**Los 21 los parsea el ash.**
|
||||
|
||||
## Por qué los 28 bash son bash: arrays, y casi nada más
|
||||
|
||||
| bashismo | guiones |
|
||||
|---|---|
|
||||
| **`array=( )`** | **31** |
|
||||
| `declare`/`typeset` | 7 |
|
||||
| `+=` en asignación | 6 |
|
||||
| `[[ ]]` | 5 |
|
||||
| `<<<` | 4 |
|
||||
| `<( )` | 4 |
|
||||
| `${arr[i]}` | 3 |
|
||||
| `(( ))` | 3 |
|
||||
| `mapfile` | 1 |
|
||||
|
||||
No aparece ninguna vez: `${v//x/y}`, `${v^^}`, `function nombre`, `PIPESTATUS`/`BASH_*`, `echo -e`,
|
||||
`read -a/-d`, `source` en vez de `.`.
|
||||
|
||||
**De los 28 que el ash no parsea, 26 lo son por arrays y nada más.** Ése es el presupuesto completo
|
||||
de un `sh` propio que quisiera correr la herramienta del proyecto: **arrays indexados**. Los otros
|
||||
dos suman `[[ ]]`, `<( )` y `mapfile`.
|
||||
|
||||
⚠ **Parsear no es correr.** 48 de los 76 `#!bash` parsean tal cual bajo ash y eso NO significa que
|
||||
funcionen: `local`, `echo -e` o un `trap` de subshell parsean en los dos y se portan distinto (lo de
|
||||
brush#1396 es justo eso). La tabla mide sintaxis.
|
||||
|
||||
## La superficie del lenguaje
|
||||
|
||||
Los 81 POSIX usan **todo** lo que las cards no usan: `if` 63/81, funciones 30/81, `${…}` 74/81,
|
||||
posicionales 66/81, here-docs 22/81, `case` 26/81, `until` 3/81. **Comparado con las cards —8
|
||||
builtins, ningún `if`, ninguna función— es otro lenguaje.** Builtins en todo `scripts/`: 27
|
||||
(`echo`×1996, `[`×1270, `exit`×473, `export`×239, `printf`×236, `trap`×40, `local`×19, `ulimit`×3…).
|
||||
|
||||
Órdenes externas: **257 por nombre desnudo** (las resuelve el `PATH`) y **35 por ruta absoluta** (no
|
||||
pasan por `PATH` y por lo tanto **no pueden ser un applet**). Cruzadas con el recorte:
|
||||
**0 applets retirados** — que es lo que dice `busybox-vigia.py`, y ahora los dos instrumentos
|
||||
coinciden.
|
||||
|
||||
## Los siete bugs del INSTRUMENTO, que son la parte que hay que contar
|
||||
|
||||
Cada número de arriba salió mal primero. Vale más anotar esto que la tabla:
|
||||
|
||||
1. **Comentarios.** El escáner no los quitaba, y en este repo los comentarios son prosa llena de
|
||||
`` `backticks` ``: medido en `scripts/farm/cosecha-cron.sh`, **89 backticks y los 89 dentro de
|
||||
comentarios**. Daba «sustitución `` `…` `` 75/81» cuando la cifra real es 9/81.
|
||||
2. **`shlex` sobre el fichero entero.** Se desincroniza —no es un parser de shell— y una comilla
|
||||
suelta desplaza el estado de TODO lo que sigue: `takana-live-install.sh` daba la orden `init`
|
||||
porque el token venía de dentro de un `echo "…(busybox + /init …)"`. Reescrito sobre el texto
|
||||
que el escáner ya deja sin comillas.
|
||||
3. **Cuerpos de heredoc.** No son shell del script que los contiene: `scripts/desplegar-strip.sh`
|
||||
empotra un programa de **Python**, y su `for p in glob.glob(…)` aparecía como la orden `p`.
|
||||
Sacarlos bajó las órdenes externas de 680 a 257 — o sea que **el 62 % del inventario era ruido**.
|
||||
4. **Patrones de `case`.** `case $n in proc|sys|dev|init) continue ;;` daba `init` como orden
|
||||
invocada, y eso marcaba un applet retirado que nadie invoca — contradiciendo al vigía, que tenía
|
||||
razón. Ahora hay estado de `case`.
|
||||
5. **Cuerpos de `$(( ))`.** `i=$((i+1))` daba la orden `i+1`.
|
||||
6. **Descriptores de fichero.** `>&2` daba la orden `2`.
|
||||
7. **Nombre desnudo vs ruta absoluta.** `exec switch_root /newroot /sbin/init` marcaba el applet
|
||||
`init`; pero una ruta absoluta no se resuelve por `PATH` y no puede ser un applet — en el
|
||||
producto ese `/sbin/init` es arje (ADR 0007). Ahora se cuentan por separado, y de paso eso
|
||||
**mide algo útil**: 35 órdenes van por ruta absoluta y no dependen del `PATH`.
|
||||
|
||||
**Ninguno de los siete se veía en la salida.** Todos daban números plausibles. Es la misma lección
|
||||
del banco diferencial —un banco sintético pasa y el consumidor real falla— aplicada al instrumento
|
||||
en vez de al shell.
|
||||
|
||||
## Una librería, no dos copias
|
||||
|
||||
El escáner vive en `scripts/lib/sh_analisis.py` y lo importan los dos instrumentos. Duplicarlo era
|
||||
repetir el problema del ADR 0019 un piso más abajo: dos emisores del mismo formato, cada uno con un
|
||||
campo distinto mal. Comprobado que la extracción no cambió nada: la salida de
|
||||
`sh-superficie-cards.py` es byte-idéntica antes y después (y su md5 se repite entre corridas — el
|
||||
orden de los empates se volvió determinista a propósito, para que la salida sea diffable).
|
||||
|
||||
Y los arreglos 3–7 **corrigieron también la medición de las cards**: apareció `sleep`, que el
|
||||
tokenizador viejo se comía, y desapareció `i+1`.
|
||||
|
||||
## Lo que esto NO mide
|
||||
|
||||
- **`|` dentro de un patrón de `case`** se cuenta como tubería. Inflaba poco y no se arregló: hace
|
||||
falta gramática, no regex.
|
||||
- **`sh -n` no es ejecución.** Nada de esto dice que los 48 `#!bash` que el ash parsea funcionen
|
||||
bajo ash.
|
||||
- **No se midió qué guion corre DÓNDE.** Que `takana-install.sh` sea bash no obliga a nada porque
|
||||
corre en el hub; el que corre dentro de la imagen es `takana-live-install.sh`, y ése es POSIX.
|
||||
La lista de «quién corre en la imagen» sigue sin estar medida, salvo los 21 empotrados, que lo son
|
||||
por construcción.
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
"""sh_analisis — el escáner de shell compartido por los instrumentos de superficie.
|
||||
|
||||
Lo usan `scripts/sh-superficie-cards.py` (las cards de arje) y
|
||||
`scripts/sh-superficie-scripts.py` (los guiones del repo). Vive acá por una razón concreta: es
|
||||
lógica delicada —quoting, anidamiento, posición de orden— y **dos copias divergen**. Es el mismo
|
||||
problema que el ADR 0019 describe un piso más arriba: dos emisores del mismo formato, cada uno con
|
||||
un campo distinto mal.
|
||||
|
||||
Se importa así, que es el idioma que ya usa `scripts/mudanza/`:
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
|
||||
from sh_analisis import piezas_shell, RASGOS, rasgos_y_ordenes, applets_conocidos
|
||||
|
||||
Lo que NO es: un parser. Es detección por patrón sobre un escáner de comillas que sí entiende
|
||||
comillas simples, dobles, comentarios, barra invertida y anidamiento de sustituciones. Aproximado
|
||||
y dicho así — pero el estado de comillas SÍ es correcto, y eso es lo que separa una medición de un
|
||||
montón de falsos positivos.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
RAIZ = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def piezas_shell(codigo: str) -> list[tuple[str, str, str]]:
|
||||
"""Descompone el código en PIEZAS y devuelve, por pieza, `(original, sintaxis, expansiones)`.
|
||||
|
||||
Una pieza es un nivel de shell: el código de la card, y el cuerpo de cada sustitución de
|
||||
comando — que es shell OTRA VEZ y casi siempre vive dentro de comillas dobles. Sin esto, el
|
||||
`df -h /vvv | awk …` de `montar-trabajo` no existía para la medición: ni su tubería ni sus dos
|
||||
órdenes externas.
|
||||
|
||||
Los tres textos, y por qué hacen falta tres:
|
||||
|
||||
- **original**: el texto tal cual, con el cuerpo de cada sustitución retirado (queda en su
|
||||
propia pieza, así no se cuenta dos veces). No lo usa la medición — sirve para depurar y para
|
||||
ver qué vio el escáner.
|
||||
- **sintaxis**: lo de fuera de comillas. Ahí se buscan `;`, `&&`, `|`, `{ }`, `>`, `for`,
|
||||
`case`, `*`. Sin separar por comillas, un mensaje de error que dice «lo genera
|
||||
`minga init`» se cuenta como sustitución de comando — pasó, y daba 11 backticks falsos de
|
||||
23 cards, más una docena de «órdenes externas» que eran palabras sueltas de la prosa.
|
||||
- **expansiones**: lo que el shell EXPANDE, o sea lo de fuera de comillas más lo de dentro de
|
||||
comillas DOBLES. Ahí se buscan `$( )`, `` ` ``, `$(( ))`, `${…}`, `$1`.
|
||||
|
||||
Escáner de estados, no regex: comillas simples (literal absoluto), dobles (expanden), barra
|
||||
invertida y anidamiento de paréntesis.
|
||||
"""
|
||||
piezas: list[tuple[str, str, str]] = []
|
||||
|
||||
def escanear(texto: str) -> None:
|
||||
orig, sin, exp = [], [], []
|
||||
estado = None # None | "'" | '"'
|
||||
i = 0
|
||||
# Un `#` abre comentario sólo si está FUERA de comillas y al principio de una palabra.
|
||||
# Sin esto la medición es basura en este repo, donde los comentarios son prosa llena de
|
||||
# `backticks`: medido en scripts/farm/cosecha-cron.sh, 89 backticks y los 89 en comentarios.
|
||||
# `${x#pre}` y `a#b` NO son comentarios, y por eso hace falta el estado de palabra.
|
||||
palabra = True
|
||||
while i < len(texto):
|
||||
c = texto[i]
|
||||
if estado is None and c == "#" and palabra:
|
||||
j = texto.find("\n", i)
|
||||
if j < 0:
|
||||
break
|
||||
orig.append("\n"); sin.append("\n"); exp.append("\n")
|
||||
i = j + 1
|
||||
continue
|
||||
if estado is None:
|
||||
palabra = c in " \t\n;&|()<>" or (not orig and not sin)
|
||||
if estado is None and c == "\\" and i + 1 < len(texto):
|
||||
orig.append(texto[i:i + 2]); sin.append(" "); exp.append(" ")
|
||||
i += 2
|
||||
continue
|
||||
vivo = estado != "'"
|
||||
if vivo and texto.startswith("$((", i):
|
||||
# Aritmética: NO es una pieza de shell (su cuerpo no son órdenes). Se retira de la
|
||||
# SINTAXIS para que el tokenizador no lea `i+1` como una orden — pasó — y se deja
|
||||
# marcada en las EXPANSIONES para que el rasgo se siga detectando.
|
||||
prof, j = 2, i + 3
|
||||
while j < len(texto) and prof:
|
||||
if texto[j] == "(":
|
||||
prof += 1
|
||||
elif texto[j] == ")":
|
||||
prof -= 1
|
||||
j += 1
|
||||
orig.append(" _ARIT_ "); sin.append(" _ "); exp.append("$((_))")
|
||||
i = j
|
||||
continue
|
||||
if vivo and texto.startswith("$(", i):
|
||||
prof, j = 1, i + 2
|
||||
while j < len(texto) and prof:
|
||||
if texto[j] == "(":
|
||||
prof += 1
|
||||
elif texto[j] == ")":
|
||||
prof -= 1
|
||||
j += 1
|
||||
escanear(texto[i + 2:j - 1])
|
||||
orig.append(" _SUST_ "); sin.append(" "); exp.append("$(_)")
|
||||
i = j
|
||||
continue
|
||||
if vivo and c == "`":
|
||||
j = texto.find("`", i + 1)
|
||||
if j < 0:
|
||||
j = len(texto)
|
||||
escanear(texto[i + 1:j])
|
||||
orig.append(" _SUST_ "); sin.append(" "); exp.append("`_`")
|
||||
i = j + 1
|
||||
continue
|
||||
if estado is None and c in "'\"":
|
||||
estado = c
|
||||
orig.append(c); sin.append("_")
|
||||
i += 1
|
||||
continue
|
||||
if estado is not None and c == estado:
|
||||
estado = None
|
||||
orig.append(c)
|
||||
i += 1
|
||||
continue
|
||||
orig.append(c)
|
||||
if estado is None:
|
||||
sin.append(c); exp.append(c)
|
||||
elif estado == '"':
|
||||
sin.append("_"); exp.append(c)
|
||||
else:
|
||||
sin.append("_")
|
||||
i += 1
|
||||
piezas.append(("".join(orig), "".join(sin), "".join(exp)))
|
||||
|
||||
escanear(codigo)
|
||||
return piezas
|
||||
|
||||
|
||||
RE_HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
||||
|
||||
|
||||
def heredocs(texto: str) -> tuple[str, list[tuple[str, str]]]:
|
||||
"""Separa el texto de sus cuerpos de heredoc: `(texto_sin_cuerpos, [(delim, cuerpo)…])`.
|
||||
|
||||
Hace falta porque **el cuerpo de un heredoc no es shell del script que lo contiene**: puede
|
||||
ser otro lenguaje entero. Medido: `scripts/desplegar-strip.sh` empotra un programa de Python,
|
||||
y su `for p in glob.glob(…)` aparecía como la orden `p` en el inventario del árbol.
|
||||
|
||||
Los cuerpos se devuelven aparte porque algunos SÍ son guiones —un `/init` que viaja a la
|
||||
imagen— y ésos se miden como población propia.
|
||||
"""
|
||||
lineas = texto.splitlines()
|
||||
fuera, cuerpos = [], []
|
||||
i = 0
|
||||
while i < len(lineas):
|
||||
fuera.append(lineas[i])
|
||||
m = RE_HEREDOC.search(lineas[i])
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
delim = m.group(2)
|
||||
cuerpo, j = [], i + 1
|
||||
while j < len(lineas) and lineas[j].strip() != delim:
|
||||
cuerpo.append(lineas[j])
|
||||
j += 1
|
||||
cuerpos.append((delim, "\n".join(cuerpo) + "\n"))
|
||||
i = j + 1
|
||||
return "\n".join(fuera) + "\n", cuerpos
|
||||
|
||||
|
||||
# (rasgo, patrón, sobre-qué): "s" = sintaxis (fuera de comillas), "e" = expansiones.
|
||||
RASGOS = [
|
||||
("bucle for", r"\bfor\b\s+\w+\s+in\b", "s"),
|
||||
("bucle while", r"\bwhile\b", "s"),
|
||||
("bucle until", r"\buntil\b", "s"),
|
||||
("condicional if", r"\bif\b", "s"),
|
||||
("case", r"\bcase\b", "s"),
|
||||
("función", r"\w+\s*\(\)\s*\{", "s"),
|
||||
("sustitución $( )", r"\$\((?!\()", "e"),
|
||||
("sustitución `…`", r"`", "e"),
|
||||
("aritmética $(( ))", r"\$\(\(", "e"),
|
||||
("expansión ${…}", r"\$\{", "e"),
|
||||
("variable $x", r"\$\w", "e"),
|
||||
("posicionales $1 $@", r"\$[0-9@*#]", "e"),
|
||||
("estado $?", r"\$\?", "e"),
|
||||
("y/o && ||", r"&&|\|\|", "s"),
|
||||
("tubería |", r"(?<![|&])\|(?!\|)", "s"),
|
||||
("grupo { }", r"\{[^{}]*;\s*\}", "s"),
|
||||
("subshell ( )", r"(?<![$(])\((?!\()", "s"),
|
||||
("redirección >", r"(?<![0-9<>&])>", "s"),
|
||||
("redirección 2>", r"\d>", "s"),
|
||||
("aquí-doc <<", r"<<", "s"),
|
||||
("glob *", r"\*", "s"),
|
||||
("negación !", r"(?:^|[;&|(])\s*!\s", "s"),
|
||||
("secuencia ;", r";", "s"),
|
||||
("segundo plano &", r"(?<![&>])&(?![&>])", "s"),
|
||||
]
|
||||
|
||||
BUILTINS_POSIX = {
|
||||
":", ".", "break", "continue", "eval", "exec", "exit", "export", "readonly", "return",
|
||||
"set", "shift", "times", "trap", "unset", "cd", "echo", "printf", "pwd", "read", "test",
|
||||
"[", "command", "getopts", "hash", "umask", "wait", "alias", "unalias", "type", "kill",
|
||||
"jobs", "fg", "bg", "ulimit", "local", "true", "false",
|
||||
}
|
||||
|
||||
PALABRAS_CLAVE = {
|
||||
"if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac",
|
||||
"in", "{", "}", "(", ")", "!", "time", "function", "select",
|
||||
}
|
||||
|
||||
SEPARADORES = {";", "&&", "||", "|", "&", "(", ")", "{", "}", "do", "then", "else", "elif"}
|
||||
|
||||
|
||||
RE_SEP = re.compile(r"(\n|;;|;|&&|\|\||\||&>|>&|&|\(|\)|\{|\}|<<-|<<|<|>>|>)")
|
||||
REDIRECCIONES = {"<<-", "<<", "<", ">>", ">", ">&", "&>"}
|
||||
RE_FUNCION = re.compile(r"^[ \t]*(?:function[ \t]+)?([A-Za-z_][A-Za-z0-9_-]*)[ \t]*\(\)", re.M)
|
||||
|
||||
|
||||
def funciones_definidas(sintaxis: str) -> set[str]:
|
||||
"""Nombres de función definidos en el propio texto. No son órdenes externas, y en este repo
|
||||
son muchas: `say`, `log`, `ok`, `dump`… salían como las órdenes más «usadas» del árbol."""
|
||||
return set(RE_FUNCION.findall(sintaxis))
|
||||
|
||||
|
||||
def ordenes(sintaxis: str) -> list[str]:
|
||||
"""Palabras en posición de ORDEN, sobre el texto de SINTAXIS (ya sin comillas ni comentarios).
|
||||
|
||||
⚠ **No usar `shlex` sobre un fichero entero.** Se probó y se desincroniza: `shlex` no es un
|
||||
parser de shell, así que una comilla suelta en cualquier línea —o el cuerpo de un heredoc—
|
||||
desplaza el estado de comillas de TODO lo que sigue. Medido: `takana-live-install.sh` daba
|
||||
`init` como orden porque el token venía de dentro de un `echo "…(busybox + /init …)"`. Acá el
|
||||
estado de comillas ya lo resolvió `piezas_shell`, que sí entiende shell; esto sólo parte en
|
||||
palabras y separadores.
|
||||
"""
|
||||
salida: list[str] = []
|
||||
esperando, saltar_palabra, saltar_hasta = True, False, None
|
||||
# Estado de `case`: entre el `in` (o un `;;`) y el `)` van PATRONES, no órdenes. Sin esto,
|
||||
# `case $n in proc|sys|dev|init) continue ;;` daba `init` como orden invocada — y eso marcaba
|
||||
# un applet retirado que nadie invoca, contradiciendo a `busybox-vigia.py`, que tiene razón.
|
||||
en_case, patron = False, False
|
||||
for trozo in RE_SEP.split(sintaxis):
|
||||
if trozo in REDIRECCIONES:
|
||||
saltar_palabra = True
|
||||
continue
|
||||
if RE_SEP.fullmatch(trozo):
|
||||
if en_case and trozo == ")":
|
||||
patron, esperando = False, True
|
||||
continue
|
||||
if en_case and trozo == ";;":
|
||||
patron = True
|
||||
continue
|
||||
if patron:
|
||||
continue
|
||||
esperando = True
|
||||
continue
|
||||
for palabra in trozo.split():
|
||||
if palabra == "esac":
|
||||
en_case, patron = False, False
|
||||
continue
|
||||
if palabra == "case":
|
||||
en_case, patron = True, False
|
||||
saltar_hasta = {"in"}
|
||||
esperando = False
|
||||
continue
|
||||
if patron:
|
||||
continue
|
||||
if saltar_palabra:
|
||||
saltar_palabra = False
|
||||
continue
|
||||
if saltar_hasta:
|
||||
if palabra in saltar_hasta:
|
||||
saltar_hasta = None
|
||||
if en_case and palabra == "in":
|
||||
patron = True
|
||||
esperando = palabra in {"do", ";"}
|
||||
continue
|
||||
if palabra in ("for", "select"):
|
||||
saltar_hasta = {"do", "in"}
|
||||
esperando = False
|
||||
continue
|
||||
if not esperando:
|
||||
continue
|
||||
if palabra in PALABRAS_CLAVE or palabra.startswith("-"):
|
||||
continue
|
||||
if set(palabra) <= {"_"}: # era un literal entrecomillado
|
||||
continue
|
||||
if palabra.isdigit(): # un descriptor de fichero, no una orden
|
||||
continue
|
||||
if re.fullmatch(r"\w+=.*", palabra): # asignación previa a la orden
|
||||
continue
|
||||
salida.append(palabra)
|
||||
esperando = False
|
||||
return salida
|
||||
|
||||
|
||||
RUTA_APPLETS = RAIZ / "docs/state/busybox-applets.tsv"
|
||||
|
||||
|
||||
def applets_conocidos() -> dict[str, tuple[str, str]]:
|
||||
"""`applet → (destino, sigue|RETIRADO)`, del inventario del recorte 401 → 277.
|
||||
|
||||
Cruzarlo con lo que invocan las cards ES el guardián que `plan-botar-busybox.md` dice que
|
||||
falta: hoy, una card que llame a un applet ya retirado del `defconfig` no falla en ningún
|
||||
test — falla **en el arranque de otra máquina, semanas después**.
|
||||
"""
|
||||
if not RUTA_APPLETS.is_file():
|
||||
return {}
|
||||
filas = {}
|
||||
with RUTA_APPLETS.open(encoding="utf-8") as fh:
|
||||
cabecera = fh.readline().rstrip("\n").split("\t")
|
||||
try:
|
||||
i_dest = cabecera.index("destino")
|
||||
i_rec = [i for i, c in enumerate(cabecera) if c.startswith("recorte")][0]
|
||||
except (ValueError, IndexError):
|
||||
return {}
|
||||
for linea in fh:
|
||||
campos = linea.rstrip("\n").split("\t")
|
||||
if len(campos) > max(i_dest, i_rec):
|
||||
filas[campos[0]] = (campos[i_dest], campos[i_rec])
|
||||
return filas
|
||||
|
||||
|
||||
def rasgos_y_ordenes(codigo: str) -> tuple[set[str], collections.Counter, collections.Counter]:
|
||||
"""`(rasgos, builtins, externas)` de un fragmento de shell.
|
||||
|
||||
Es la entrada única de la librería: quien mide no vuelve a decidir cómo se separan las
|
||||
comillas, qué cuenta como orden ni qué es una función propia.
|
||||
"""
|
||||
piezas = piezas_shell(codigo)
|
||||
sintaxis = "\n".join(s for _, s, _ in piezas)
|
||||
expansiones = "\n".join(e for _, _, e in piezas)
|
||||
rasgos = {n for n, patron, donde in RASGOS
|
||||
if re.search(patron, sintaxis if donde == "s" else expansiones, re.M)}
|
||||
propias = funciones_definidas(sintaxis)
|
||||
builtins: collections.Counter = collections.Counter()
|
||||
externas: collections.Counter = collections.Counter()
|
||||
for _, s, _ in piezas:
|
||||
for o in ordenes(s):
|
||||
base = Path(o).name if o.startswith("/") else o
|
||||
if base in propias:
|
||||
continue
|
||||
if base in BUILTINS_POSIX:
|
||||
builtins[base] += 1
|
||||
elif re.fullmatch(r"[\w.+-]+", o):
|
||||
externas[o] += 1 # nombre desnudo: lo resuelve el PATH
|
||||
elif o.startswith("/") and re.fullmatch(r"[\w./+-]+", o):
|
||||
externas[o] += 1 # ruta ABSOLUTA: no pasa por PATH
|
||||
return rasgos, builtins, externas
|
||||
|
||||
|
||||
def desnudas_y_absolutas(externas: collections.Counter) -> tuple[collections.Counter,
|
||||
collections.Counter]:
|
||||
"""Separa las órdenes por nombre desnudo de las invocadas por ruta absoluta.
|
||||
|
||||
La distinción no es cosmética: **sólo un nombre desnudo se resuelve por `PATH`**, y por lo
|
||||
tanto sólo un nombre desnudo puede ser un applet de busybox. Confundirlas daba un falso
|
||||
positivo medido: `exec switch_root /newroot /sbin/init` marcaba el applet `init` como
|
||||
invocado, cuando lo que se ejecuta es el `/sbin/init` del producto, que es arje (ADR 0007).
|
||||
"""
|
||||
desnudas: collections.Counter = collections.Counter()
|
||||
absolutas: collections.Counter = collections.Counter()
|
||||
for k, v in externas.items():
|
||||
nombre = Path(k).name if "/" in k else k
|
||||
if not nombre: # un token que era sólo `/`
|
||||
continue
|
||||
(absolutas if "/" in k else desnudas)[nombre] += v
|
||||
return desnudas, absolutas
|
||||
+18
-208
@@ -44,6 +44,9 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
RAIZ = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
|
||||
from sh_analisis import (RASGOS, RUTA_APPLETS, applets_conocidos, # noqa: E402
|
||||
desnudas_y_absolutas, rasgos_y_ordenes)
|
||||
|
||||
# Los shells que una card puede invocar, y el multicall que los trae.
|
||||
SHELLS = {"sh", "ash", "bash", "busybox"}
|
||||
@@ -191,213 +194,12 @@ def de_mudanza() -> list[Fragmento]:
|
||||
|
||||
# ───────────────────────────────────────────────── análisis
|
||||
|
||||
def piezas_shell(codigo: str) -> list[tuple[str, str, str]]:
|
||||
"""Descompone el código en PIEZAS y devuelve, por pieza, `(original, sintaxis, expansiones)`.
|
||||
|
||||
Una pieza es un nivel de shell: el código de la card, y el cuerpo de cada sustitución de
|
||||
comando — que es shell OTRA VEZ y casi siempre vive dentro de comillas dobles. Sin esto, el
|
||||
`df -h /vvv | awk …` de `montar-trabajo` no existía para la medición: ni su tubería ni sus dos
|
||||
órdenes externas.
|
||||
|
||||
Los tres textos, y por qué hacen falta tres:
|
||||
|
||||
- **original**: el texto tal cual, con el cuerpo de cada sustitución retirado (queda en su
|
||||
propia pieza, así no se cuenta dos veces). Va a `shlex`, que sí entiende comillas, para
|
||||
sacar las ÓRDENES.
|
||||
- **sintaxis**: lo de fuera de comillas. Ahí se buscan `;`, `&&`, `|`, `{ }`, `>`, `for`,
|
||||
`case`, `*`. Sin separar por comillas, un mensaje de error que dice «lo genera
|
||||
`minga init`» se cuenta como sustitución de comando — pasó, y daba 11 backticks falsos de
|
||||
23 cards, más una docena de «órdenes externas» que eran palabras sueltas de la prosa.
|
||||
- **expansiones**: lo que el shell EXPANDE, o sea lo de fuera de comillas más lo de dentro de
|
||||
comillas DOBLES. Ahí se buscan `$( )`, `` ` ``, `$(( ))`, `${…}`, `$1`.
|
||||
|
||||
Escáner de estados, no regex: comillas simples (literal absoluto), dobles (expanden), barra
|
||||
invertida y anidamiento de paréntesis.
|
||||
"""
|
||||
piezas: list[tuple[str, str, str]] = []
|
||||
|
||||
def escanear(texto: str) -> None:
|
||||
orig, sin, exp = [], [], []
|
||||
estado = None # None | "'" | '"'
|
||||
i = 0
|
||||
while i < len(texto):
|
||||
c = texto[i]
|
||||
if estado is None and c == "\\" and i + 1 < len(texto):
|
||||
orig.append(texto[i:i + 2]); sin.append(" "); exp.append(" ")
|
||||
i += 2
|
||||
continue
|
||||
vivo = estado != "'"
|
||||
if vivo and texto.startswith("$(", i) and not texto.startswith("$((", i):
|
||||
prof, j = 1, i + 2
|
||||
while j < len(texto) and prof:
|
||||
if texto[j] == "(":
|
||||
prof += 1
|
||||
elif texto[j] == ")":
|
||||
prof -= 1
|
||||
j += 1
|
||||
escanear(texto[i + 2:j - 1])
|
||||
orig.append(" _SUST_ "); sin.append(" "); exp.append("$(_)")
|
||||
i = j
|
||||
continue
|
||||
if vivo and c == "`":
|
||||
j = texto.find("`", i + 1)
|
||||
if j < 0:
|
||||
j = len(texto)
|
||||
escanear(texto[i + 1:j])
|
||||
orig.append(" _SUST_ "); sin.append(" "); exp.append("`_`")
|
||||
i = j + 1
|
||||
continue
|
||||
if estado is None and c in "'\"":
|
||||
estado = c
|
||||
orig.append(c); sin.append("_")
|
||||
i += 1
|
||||
continue
|
||||
if estado is not None and c == estado:
|
||||
estado = None
|
||||
orig.append(c)
|
||||
i += 1
|
||||
continue
|
||||
orig.append(c)
|
||||
if estado is None:
|
||||
sin.append(c); exp.append(c)
|
||||
elif estado == '"':
|
||||
sin.append("_"); exp.append(c)
|
||||
else:
|
||||
sin.append("_")
|
||||
i += 1
|
||||
piezas.append(("".join(orig), "".join(sin), "".join(exp)))
|
||||
|
||||
escanear(codigo)
|
||||
return piezas
|
||||
|
||||
|
||||
# (rasgo, patrón, sobre-qué): "s" = sintaxis (fuera de comillas), "e" = expansiones.
|
||||
RASGOS = [
|
||||
("bucle for", r"\bfor\b\s+\w+\s+in\b", "s"),
|
||||
("bucle while", r"\bwhile\b", "s"),
|
||||
("bucle until", r"\buntil\b", "s"),
|
||||
("condicional if", r"\bif\b", "s"),
|
||||
("case", r"\bcase\b", "s"),
|
||||
("función", r"\w+\s*\(\)\s*\{", "s"),
|
||||
("sustitución $( )", r"\$\((?!\()", "e"),
|
||||
("sustitución `…`", r"`", "e"),
|
||||
("aritmética $(( ))", r"\$\(\(", "e"),
|
||||
("expansión ${…}", r"\$\{", "e"),
|
||||
("variable $x", r"\$\w", "e"),
|
||||
("posicionales $1 $@", r"\$[0-9@*#]", "e"),
|
||||
("estado $?", r"\$\?", "e"),
|
||||
("y/o && ||", r"&&|\|\|", "s"),
|
||||
("tubería |", r"(?<![|&])\|(?!\|)", "s"),
|
||||
("grupo { }", r"\{[^{}]*;\s*\}", "s"),
|
||||
("subshell ( )", r"(?<![$(])\((?!\()", "s"),
|
||||
("redirección >", r"(?<![0-9<>&])>", "s"),
|
||||
("redirección 2>", r"\d>", "s"),
|
||||
("aquí-doc <<", r"<<", "s"),
|
||||
("glob *", r"\*", "s"),
|
||||
("negación !", r"(?:^|[;&|(])\s*!\s", "s"),
|
||||
("secuencia ;", r";", "s"),
|
||||
("segundo plano &", r"(?<![&>])&(?![&>])", "s"),
|
||||
]
|
||||
|
||||
BUILTINS_POSIX = {
|
||||
":", ".", "break", "continue", "eval", "exec", "exit", "export", "readonly", "return",
|
||||
"set", "shift", "times", "trap", "unset", "cd", "echo", "printf", "pwd", "read", "test",
|
||||
"[", "command", "getopts", "hash", "umask", "wait", "alias", "unalias", "type", "kill",
|
||||
"jobs", "fg", "bg", "ulimit", "local", "true", "false",
|
||||
}
|
||||
|
||||
PALABRAS_CLAVE = {
|
||||
"if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac",
|
||||
"in", "{", "}", "(", ")", "!", "time", "function", "select",
|
||||
}
|
||||
|
||||
SEPARADORES = {";", "&&", "||", "|", "&", "(", ")", "{", "}", "do", "then", "else", "elif"}
|
||||
|
||||
|
||||
def ordenes(codigo: str) -> list[str]:
|
||||
"""Palabras en posición de ORDEN. Tokeniza respetando comillas (shlex) y toma la primera
|
||||
palabra de cada comando simple. Aproximado: `shlex` no entiende la gramática del shell, sólo
|
||||
las comillas. Sirve para inventariar, no para decidir.
|
||||
|
||||
Casos que sí se tratan porque ensuciaban la cuenta medida: `for h in …` daba `h` como orden,
|
||||
y el `!` de `[ ! -S … ]` daba `-S`.
|
||||
"""
|
||||
try:
|
||||
lex = shlex.shlex(codigo, punctuation_chars=True, posix=True)
|
||||
lex.whitespace_split = True
|
||||
toks = list(lex)
|
||||
except ValueError:
|
||||
return []
|
||||
salida, esperando, saltar_hasta = [], True, None
|
||||
for t in toks:
|
||||
if saltar_hasta:
|
||||
if t in saltar_hasta:
|
||||
saltar_hasta = None
|
||||
esperando = t in {"do", ";"}
|
||||
continue
|
||||
if t in ("for", "case", "select"):
|
||||
saltar_hasta = {"do", ";", "in"} if t == "for" else {"in"}
|
||||
esperando = False
|
||||
continue
|
||||
if t in SEPARADORES:
|
||||
esperando = True
|
||||
continue
|
||||
if not esperando:
|
||||
continue
|
||||
if t in PALABRAS_CLAVE or t.startswith("-") or t == "_SUST_":
|
||||
continue
|
||||
if re.fullmatch(r"\w+=.*", t): # asignación previa al comando
|
||||
continue
|
||||
salida.append(t)
|
||||
esperando = False
|
||||
return salida
|
||||
|
||||
|
||||
def analizar(f: Fragmento) -> None:
|
||||
"""Rellena rasgos/builtins/externas del fragmento. El escáner vive en
|
||||
`scripts/lib/sh_analisis.py`, compartido con `sh-superficie-scripts.py`."""
|
||||
if not f.codigo:
|
||||
return
|
||||
piezas = piezas_shell(f.codigo)
|
||||
sintaxis = "\n".join(s for _, s, _ in piezas)
|
||||
expansiones = "\n".join(e for _, _, e in piezas)
|
||||
for nombre, patron, donde in RASGOS:
|
||||
if re.search(patron, sintaxis if donde == "s" else expansiones, re.M):
|
||||
f.rasgos.add(nombre)
|
||||
todas = []
|
||||
for original, _, _ in piezas:
|
||||
todas.extend(ordenes(original))
|
||||
for o in todas:
|
||||
base = Path(o).name if o.startswith("/") else o
|
||||
if base in BUILTINS_POSIX:
|
||||
f.builtins[base] += 1
|
||||
elif re.fullmatch(r"[\w.+-]+", base):
|
||||
f.externas[base] += 1
|
||||
|
||||
|
||||
RUTA_APPLETS = RAIZ / "docs/state/busybox-applets.tsv"
|
||||
|
||||
|
||||
def applets_conocidos() -> dict[str, tuple[str, str]]:
|
||||
"""`applet → (destino, sigue|RETIRADO)`, del inventario del recorte 401 → 277.
|
||||
|
||||
Cruzarlo con lo que invocan las cards ES el guardián que `plan-botar-busybox.md` dice que
|
||||
falta: hoy, una card que llame a un applet ya retirado del `defconfig` no falla en ningún
|
||||
test — falla **en el arranque de otra máquina, semanas después**.
|
||||
"""
|
||||
if not RUTA_APPLETS.is_file():
|
||||
return {}
|
||||
filas = {}
|
||||
with RUTA_APPLETS.open(encoding="utf-8") as fh:
|
||||
cabecera = fh.readline().rstrip("\n").split("\t")
|
||||
try:
|
||||
i_dest = cabecera.index("destino")
|
||||
i_rec = [i for i, c in enumerate(cabecera) if c.startswith("recorte")][0]
|
||||
except (ValueError, IndexError):
|
||||
return {}
|
||||
for linea in fh:
|
||||
campos = linea.rstrip("\n").split("\t")
|
||||
if len(campos) > max(i_dest, i_rec):
|
||||
filas[campos[0]] = (campos[i_dest], campos[i_rec])
|
||||
return filas
|
||||
f.rasgos, f.builtins, f.externas = rasgos_y_ordenes(f.codigo)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────── informe
|
||||
@@ -434,7 +236,10 @@ def main() -> int:
|
||||
for f in con_shell:
|
||||
hist.update(f.rasgos)
|
||||
ancho = max((len(r) for r in hist), default=10)
|
||||
for rasgo, n in hist.most_common():
|
||||
catalogo = [r for r, _, _ in RASGOS]
|
||||
# Orden DETERMINISTA: por cuenta y, en el empate, por el orden del catálogo. Con `most_common`
|
||||
# los empates salían en el orden de iteración de un `set`, y la salida no era diffable.
|
||||
for rasgo, n in sorted(hist.items(), key=lambda kv: (-kv[1], catalogo.index(kv[0]))):
|
||||
barra = "█" * n
|
||||
print(f" {rasgo:<{ancho}} {n:3d} {barra}")
|
||||
no_usados = [r for r, _, _ in RASGOS if r not in hist]
|
||||
@@ -447,15 +252,20 @@ def main() -> int:
|
||||
bi.update(f.builtins)
|
||||
ex.update(f.externas)
|
||||
print(f"\n── Builtins que el shell tiene que traer ({len(bi)})")
|
||||
print(" " + " ".join(f"{k}×{v}" for k, v in bi.most_common()))
|
||||
print(" " + " ".join(f"{k}×{v}" for k, v in sorted(bi.items(), key=lambda kv: (-kv[1], kv[0]))))
|
||||
print(f"\n── Órdenes EXTERNAS que la imagen tiene que traer ({len(ex)})")
|
||||
print(" " + " ".join(f"{k}×{v}" for k, v in ex.most_common()))
|
||||
desnudas, absolutas = desnudas_y_absolutas(ex)
|
||||
orden_ex = sorted(desnudas.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
print(" desnudas (las resuelve el PATH): "
|
||||
+ " ".join(f"{k}×{v}" for k, v in orden_ex))
|
||||
print(" por ruta absoluta (no pasan por PATH): "
|
||||
+ " ".join(f"{k}×{v}" for k, v in sorted(absolutas.items(), key=lambda kv: (-kv[1], kv[0]))))
|
||||
|
||||
# ── el cruce con el recorte de busybox: acá vive el guardián que el plan dice que falta
|
||||
applets = applets_conocidos()
|
||||
if applets:
|
||||
retirados, siguen, ajenas = [], [], []
|
||||
for orden, n in ex.most_common():
|
||||
for orden, n in orden_ex:
|
||||
fila = applets.get(orden)
|
||||
if fila is None:
|
||||
ajenas.append(orden)
|
||||
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mide la superficie de shell de `scripts/` — SDD: docs/plan-botar-busybox.md.
|
||||
|
||||
Hermano de `sh-superficie-cards.py`, con el escáner compartido en `scripts/lib/sh_analisis.py`.
|
||||
**Pero contesta una pregunta distinta, y conviene no mezclarlas:**
|
||||
|
||||
- las CARDS las ejecuta el producto ⇒ su superficie es el contrato de un `/bin/sh` propio HOY;
|
||||
- `scripts/` lo ejecuta el HUB y el worker, bajo el bash del anfitrión ⇒ su superficie sólo
|
||||
obliga el día del auto-alojamiento (Etapa C). Hoy lo que importa acá es otra cosa:
|
||||
|
||||
**¿algún guion se declara POSIX y no lo es?** Porque ése SÍ se rompe hoy —
|
||||
en la imagen `/bin/sh` es el ash de busybox, no bash.
|
||||
|
||||
Tres poblaciones, por la PRIMERA línea (no por «contiene un shebang»: dos scripts bash empotran un
|
||||
`#!/bin/sh` en un heredoc y un grep ingenuo los cuenta como POSIX — pasó):
|
||||
|
||||
1. **POSIX** — `#!/bin/sh`, `#!/usr/bin/env sh`, `#!/bin/ash`
|
||||
2. **bash** — `#!/bin/bash`, `#!/usr/bin/env bash`
|
||||
3. **EMPOTRADOS** — guiones `#!/bin/sh` que otro script ESCRIBE con un heredoc (un `/init`, un
|
||||
envoltorio). Ésos viajan a la imagen y los corre el ash, así que son los que más importan y
|
||||
los que nadie parsea.
|
||||
|
||||
De cada uno: si lo parsea el ash, si lo parsea bash, qué construcciones usa y qué órdenes invoca.
|
||||
Para la población bash, además, **por qué** es bash: qué bashismo concreto trae.
|
||||
|
||||
Uso:
|
||||
scripts/sh-superficie-scripts.py # resumen + TSV
|
||||
scripts/sh-superficie-scripts.py --list # el detalle por fichero
|
||||
scripts/sh-superficie-scripts.py --dump-dir D # los empotrados como ficheros, para el banco
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import glob as globmod
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
RAIZ = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
|
||||
from sh_analisis import (RASGOS, RUTA_APPLETS, applets_conocidos, # noqa: E402
|
||||
desnudas_y_absolutas, heredocs, piezas_shell,
|
||||
rasgos_y_ordenes)
|
||||
|
||||
SHEBANG_POSIX = {"#!/bin/sh", "#!/usr/bin/env sh", "#!/bin/ash", "#!/bin/busybox sh"}
|
||||
SHEBANG_BASH = {"#!/bin/bash", "#!/usr/bin/env bash", "#!/bin/bash -e"}
|
||||
|
||||
# Lo que hace que un guion NO sea POSIX. Se busca sobre el texto de SINTAXIS (fuera de comillas),
|
||||
# así un `[[` dentro de un mensaje no cuenta.
|
||||
BASHISMOS = [
|
||||
("[[ ]]", r"\[\["),
|
||||
("array=( )", r"^\s*\w+=\(|\bdeclare\s+-[aA]\b|\blocal\s+-[aA]\b"),
|
||||
("${arr[i]}", r"\$\{\w+\[[^]]*\]"),
|
||||
("(( ))", r"(?<!\$)\(\("),
|
||||
("<<< (here-string)", r"<<<"),
|
||||
("<( ) proceso", r"<\("),
|
||||
("declare/typeset", r"\bdeclare\b|\btypeset\b"),
|
||||
("mapfile/readarray", r"\bmapfile\b|\breadarray\b"),
|
||||
("${v//x/y}", r"\$\{\w+//"),
|
||||
("${v^^} ${v,,}", r"\$\{\w+\^\^|\$\{\w+,,"),
|
||||
("+= en asignación", r"^\s*\w+\+="),
|
||||
("function nombre", r"\bfunction\s+\w+"),
|
||||
("PIPESTATUS/BASH_*", r"\bPIPESTATUS\b|\bBASH_\w+"),
|
||||
("echo -e", r"\becho\s+-e\b"),
|
||||
("read -a / -d", r"\bread\s+-[ad]\b"),
|
||||
("source (no `.`)", r"^\s*source\s"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Guion:
|
||||
ruta: str
|
||||
poblacion: str # posix | bash | empotrado | sin-shebang
|
||||
shebang: str
|
||||
codigo: str
|
||||
padre: str | None = None # para los empotrados: quién lo escribe
|
||||
ash_ok: bool | None = None
|
||||
bash_ok: bool | None = None
|
||||
ash_error: str = ""
|
||||
rasgos: set[str] = field(default_factory=set)
|
||||
bashismos: set[str] = field(default_factory=set)
|
||||
builtins: collections.Counter = field(default_factory=collections.Counter)
|
||||
externas: collections.Counter = field(default_factory=collections.Counter)
|
||||
lineas: int = 0
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────── recolección
|
||||
|
||||
# Ficheros de shell SIN extensión `.sh` y sin shebang que igual son shell. Se nombran uno por uno
|
||||
# a propósito: el balde automático traía `.html`, `.mp4` y `.c`, y un balde así no es una medición.
|
||||
# `perfil/profile` importa doble porque VIAJA A LA IMAGEN: lo lee toda shell de login.
|
||||
EXTRAS = {"scripts/servidor/perfil/profile"}
|
||||
|
||||
FUERA = ("/fixtures/sh-casos/", "/__pycache__/", "/.git/")
|
||||
|
||||
|
||||
def es_shell(ruta: Path, primera: str) -> bool:
|
||||
"""Un fichero cuenta como shell si lo dice su extensión, su shebang, o la lista EXTRAS."""
|
||||
if str(ruta.relative_to(RAIZ)) in EXTRAS:
|
||||
return True
|
||||
if ruta.suffix == ".sh":
|
||||
return True
|
||||
return primera in SHEBANG_POSIX or primera in SHEBANG_BASH
|
||||
|
||||
|
||||
def ficheros_shell() -> list[tuple[Path, str, str]]:
|
||||
"""`(ruta, texto, primera línea)` de cada fichero de shell, y cuántos se descartaron."""
|
||||
salida, descartados = [], 0
|
||||
for ruta in sorted(RAIZ.glob("scripts/**/*")):
|
||||
if not ruta.is_file() or any(f in f"/{ruta.as_posix()}/" for f in FUERA):
|
||||
continue
|
||||
try:
|
||||
texto = ruta.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
primera = texto.splitlines()[0].strip() if texto.strip() else ""
|
||||
if es_shell(ruta, primera):
|
||||
salida.append((ruta, texto, primera))
|
||||
else:
|
||||
descartados += 1
|
||||
ficheros_shell.descartados = descartados
|
||||
return salida
|
||||
|
||||
|
||||
def empotrados(texto: str) -> list[tuple[str, str]]:
|
||||
"""Los cuerpos de heredoc que SON guiones de shell: `(delimitador, cuerpo)`.
|
||||
|
||||
Un heredoc de configuración —o de Python— no es un guion. La separación la hace
|
||||
`sh_analisis.heredocs`; acá sólo se filtra por shebang.
|
||||
"""
|
||||
return [(d, c) for d, c in heredocs(texto)[1]
|
||||
if c.lstrip().startswith("#!") and ("sh" in c.lstrip().splitlines()[0])]
|
||||
|
||||
|
||||
def recolectar() -> list[Guion]:
|
||||
guiones: list[Guion] = []
|
||||
for ruta, texto, primera in ficheros_shell():
|
||||
rel = str(ruta.relative_to(RAIZ))
|
||||
if primera in SHEBANG_POSIX:
|
||||
pob = "posix"
|
||||
elif primera in SHEBANG_BASH:
|
||||
pob = "bash"
|
||||
elif primera.startswith("#!"):
|
||||
pob = "otro-interprete"
|
||||
else:
|
||||
pob = "sin-shebang" # se hace `source`, no se ejecuta
|
||||
if pob != "otro-interprete":
|
||||
guiones.append(Guion(ruta=rel, poblacion=pob, shebang=primera or "(ninguno)",
|
||||
codigo=texto, lineas=texto.count("\n")))
|
||||
for delim, cuerpo in empotrados(texto):
|
||||
guiones.append(Guion(ruta=f"{rel}«{delim}»", poblacion="empotrado",
|
||||
shebang=cuerpo.lstrip().splitlines()[0], codigo=cuerpo,
|
||||
padre=rel, lineas=cuerpo.count("\n")))
|
||||
return guiones
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────── análisis
|
||||
|
||||
def parsea(binario: list[str], codigo: str, tmp: Path) -> tuple[bool, str]:
|
||||
tmp.write_text(codigo, encoding="utf-8")
|
||||
p = subprocess.run(binario + ["-n", str(tmp)], capture_output=True, timeout=30)
|
||||
err = p.stderr.decode("utf-8", "replace").strip().splitlines()
|
||||
return p.returncode == 0, (err[0].split(": ", 1)[-1] if err else "")
|
||||
|
||||
|
||||
def analizar(g: Guion, ash: list[str], bash: list[str], tmp: Path) -> None:
|
||||
# El ANÁLISIS va sobre el texto sin cuerpos de heredoc (ésos se miden como población
|
||||
# `empotrado`, o no se miden si no son shell); el PARSEO va sobre el fichero entero, que es
|
||||
# lo que el shell ve de verdad.
|
||||
propio = g.codigo if g.poblacion == "empotrado" else heredocs(g.codigo)[0]
|
||||
g.rasgos, g.builtins, g.externas = rasgos_y_ordenes(propio)
|
||||
sintaxis = "\n".join(s for _, s, _ in piezas_shell(propio))
|
||||
g.bashismos = {n for n, patron in BASHISMOS if re.search(patron, sintaxis, re.M)}
|
||||
g.ash_ok, g.ash_error = parsea(ash, g.codigo, tmp)
|
||||
g.bash_ok, _ = parsea(bash, g.codigo, tmp)
|
||||
|
||||
|
||||
def descubrir(patron: str, sufijo: str) -> list[str] | None:
|
||||
for d in sorted(globmod.glob(patron)):
|
||||
p = Path(d) / sufijo
|
||||
if p.is_file():
|
||||
return [str(p)]
|
||||
return None
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────── informe
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Mide la superficie de shell de scripts/.")
|
||||
ap.add_argument("--ash", metavar="RUTA", help="binario del shell POSIX de control (busybox)")
|
||||
ap.add_argument("--bash", metavar="RUTA", help="binario de bash")
|
||||
ap.add_argument("--list", action="store_true", help="detalle por fichero")
|
||||
ap.add_argument("--dump-dir", metavar="DIR", help="volcar los guiones EMPOTRADOS como ficheros")
|
||||
ap.add_argument("--tsv", metavar="RUTA", default=str(RAIZ / "work/sh-superficie-scripts.tsv"))
|
||||
args = ap.parse_args()
|
||||
|
||||
ash = [args.ash, "sh"] if args.ash else (
|
||||
(descubrir("/store/*-busybox", "bin/busybox") or [None]) + ["sh"])
|
||||
bash = [args.bash] if args.bash else descubrir("/store/*-bash", "bin/bash")
|
||||
if not ash[0] or not bash:
|
||||
print("✗ no encontré los shells. Pasá --ash y --bash "
|
||||
"(p.ej. /store/…-busybox/bin/busybox y /store/…-bash/bin/bash)", file=sys.stderr)
|
||||
return 2
|
||||
print(f"Control POSIX: {' '.join(ash)}\nbash: {bash[0]}\n")
|
||||
|
||||
guiones = recolectar()
|
||||
print(f"Ficheros de scripts/ descartados por no ser shell (.html, .c, .mp4, …): "
|
||||
f"{ficheros_shell.descartados}")
|
||||
tmp = Path(RAIZ / "work" / ".sh-superficie-tmp.sh")
|
||||
tmp.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
for g in guiones:
|
||||
analizar(g, ash, bash, tmp)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
por = collections.defaultdict(list)
|
||||
for g in guiones:
|
||||
por[g.poblacion].append(g)
|
||||
|
||||
# ── inventario y la matriz de parseo
|
||||
print("── Poblaciones, y quién las parsea")
|
||||
print(f" {'población':<16} {'n':>4} {'líneas':>7} parsea ash parsea bash")
|
||||
for pob in ("posix", "bash", "empotrado", "sin-shebang"):
|
||||
gs = por.get(pob, [])
|
||||
if not gs:
|
||||
continue
|
||||
print(f" {pob:<16} {len(gs):>4} {sum(g.lineas for g in gs):>7} "
|
||||
f"{sum(1 for g in gs if g.ash_ok):>5}/{len(gs):<5} "
|
||||
f"{sum(1 for g in gs if g.bash_ok):>5}/{len(gs)}")
|
||||
|
||||
# ── lo que se rompe HOY: un guion que se declara POSIX y el ash no lo parsea
|
||||
rotos = [g for g in guiones if g.poblacion in ("posix", "empotrado") and not g.ash_ok]
|
||||
print(f"\n── Se declaran POSIX y el ash NO los parsea: {len(rotos)}")
|
||||
if rotos:
|
||||
for g in rotos:
|
||||
print(f" ⛔ {g.ruta} ({g.shebang})")
|
||||
print(f" ash: {g.ash_error}")
|
||||
print(f" bash lo parsea: {'sí ⇒ es un bashismo' if g.bash_ok else 'no ⇒ error de sintaxis'}")
|
||||
if g.bashismos:
|
||||
print(f" bashismos detectados: {', '.join(sorted(g.bashismos))}")
|
||||
else:
|
||||
print(" ✓ ninguno. Todo lo que dice `#!/bin/sh` lo parsea el ash de busybox.")
|
||||
|
||||
# ── los bash: ¿por qué son bash?
|
||||
bashs = por.get("bash", [])
|
||||
if bashs:
|
||||
solo_bash = [g for g in bashs if not g.ash_ok]
|
||||
print(f"\n── Los {len(bashs)} guiones `#!bash`: {len(bashs)-len(solo_bash)} parsean tal cual "
|
||||
f"bajo ash, {len(solo_bash)} no")
|
||||
print(" (parsear no es correr: un `echo -e` o un `local` parsean en los dos y se portan distinto)")
|
||||
hist = collections.Counter()
|
||||
for g in bashs:
|
||||
hist.update(g.bashismos)
|
||||
cat = [n for n, _ in BASHISMOS]
|
||||
print("\n Bashismos, por cuántos guiones los traen:")
|
||||
for n, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))):
|
||||
print(f" {n:<20} {c:>3} {'█' * c}")
|
||||
sin = [n for n, _ in BASHISMOS if n not in hist]
|
||||
if sin:
|
||||
print(f" no aparece: {', '.join(sin)}")
|
||||
|
||||
# ── la superficie del lenguaje, por población
|
||||
for pob in ("posix", "bash", "empotrado"):
|
||||
gs = por.get(pob, [])
|
||||
if not gs:
|
||||
continue
|
||||
hist = collections.Counter()
|
||||
for g in gs:
|
||||
hist.update(g.rasgos)
|
||||
cat = [r for r, _, _ in RASGOS]
|
||||
print(f"\n── Construcciones · población {pob} ({len(gs)} guiones)")
|
||||
for r, c in sorted(hist.items(), key=lambda kv: (-kv[1], cat.index(kv[0]))):
|
||||
print(f" {r:<20} {c:>4}/{len(gs)}")
|
||||
faltan = [r for r in cat if r not in hist]
|
||||
if faltan:
|
||||
print(f" no aparece: {', '.join(faltan)}")
|
||||
|
||||
# ── órdenes
|
||||
bi, ex = collections.Counter(), collections.Counter()
|
||||
for g in guiones:
|
||||
bi.update(g.builtins)
|
||||
ex.update(g.externas)
|
||||
print(f"\n── Builtins usados en todo scripts/ ({len(bi)})")
|
||||
print(" " + " ".join(f"{k}×{v}" for k, v in sorted(bi.items(), key=lambda kv: (-kv[1], kv[0]))))
|
||||
desnudas, absolutas = desnudas_y_absolutas(ex)
|
||||
top = sorted(desnudas.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
print(f"\n── Órdenes externas por NOMBRE DESNUDO ({len(desnudas)} distintas, las resuelve el "
|
||||
f"PATH) · las 30 más usadas")
|
||||
print(" " + " ".join(f"{k}×{v}" for k, v in top[:30]))
|
||||
abs_top = sorted(absolutas.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
print(f"\n── …y por RUTA ABSOLUTA ({len(absolutas)} distintas, no pasan por PATH) · las 15 más "
|
||||
f"usadas")
|
||||
print(" " + " ".join(f"{k}×{v}" for k, v in abs_top[:15]))
|
||||
|
||||
applets = applets_conocidos()
|
||||
if applets:
|
||||
retiradas = [(k, v) for k, v in top if applets.get(k, ("", ""))[1] == "RETIRADO"]
|
||||
print(f"\n── Cruce con el recorte ({RUTA_APPLETS.name})")
|
||||
print(f" nombres desnudos que son applet RETIRADO: {len(retiradas)}"
|
||||
+ (f" → {', '.join(k for k, _ in retiradas)}" if retiradas else ""))
|
||||
print(" ⚠ sólo se cruzan los DESNUDOS: una ruta absoluta no se resuelve por PATH y por lo"
|
||||
"\n tanto no puede ser un applet. Cruzar las absolutas daba un falso positivo"
|
||||
"\n medido (`/sbin/init`, que en el producto es arje).")
|
||||
print(" ⚠ la PUERTA es `scripts/busybox-vigia.py`, que ya falla si alguien invoca uno;"
|
||||
"\n esto es inventario, y su lista es más ancha (incluye no-applets).")
|
||||
|
||||
if args.list:
|
||||
print("\n── Detalle")
|
||||
for g in sorted(guiones, key=lambda x: (x.poblacion, x.ruta)):
|
||||
marca = "" if g.ash_ok else " ⛔ ash no parsea"
|
||||
print(f"\n [{g.poblacion}] {g.ruta} ({g.lineas} líneas){marca}")
|
||||
if g.bashismos:
|
||||
print(f" bashismos: {', '.join(sorted(g.bashismos))}")
|
||||
print(f" rasgos: {', '.join(sorted(g.rasgos))}")
|
||||
|
||||
if args.dump_dir:
|
||||
d = Path(args.dump_dir)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
n = 0
|
||||
for g in por.get("empotrado", []):
|
||||
nombre = re.sub(r"[^\w.-]", "_", g.ruta) + ".sh"
|
||||
(d / nombre).write_text(g.codigo, encoding="utf-8")
|
||||
n += 1
|
||||
print(f"\nEmpotrados escritos en {d} ({n} ficheros)")
|
||||
|
||||
tsv = Path(args.tsv)
|
||||
tsv.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tsv.open("w", encoding="utf-8") as fh:
|
||||
fh.write("poblacion\truta\tpadre\tshebang\tlineas\tash_ok\tbash_ok\tash_error\t"
|
||||
"bashismos\trasgos\tbuiltins\texternas\n")
|
||||
for g in guiones:
|
||||
fh.write(f"{g.poblacion}\t{g.ruta}\t{g.padre or ''}\t{g.shebang}\t{g.lineas}\t"
|
||||
f"{'si' if g.ash_ok else 'no'}\t{'si' if g.bash_ok else 'no'}\t{g.ash_error}\t"
|
||||
f"{'|'.join(sorted(g.bashismos))}\t{'|'.join(sorted(g.rasgos))}\t"
|
||||
f"{'|'.join(sorted(g.builtins))}\t{'|'.join(sorted(g.externas))}\n")
|
||||
print(f"\nTSV: {tsv}")
|
||||
return len(rotos)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user