#!/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"(?", r"(?&])>", "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