Files
Sergio a4105cba11 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.
2026-09-21 19:54:12 +00:00

367 lines
16 KiB
Python

#!/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