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.
367 lines
16 KiB
Python
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
|