#!/usr/bin/env python3 # vigia-interpretes.py — ¿qué script de una imagen pide un `#!` que esa imagen NO lleva? # # ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════ # `vigia-sonames.py` contesta la misma pregunta para los `.so` y es el guardián del cierre dinámico. # **No ve un `#!`.** Y el 2026-09-22, al declarar `[solo-build]` en `targets.toml` para sacar la # cadena de compilación de las imágenes, esa diferencia era justo la que importaba: con `python3` y # `perl` fuera, el vigía de sonames daba **cero huecos en los 8 perfiles** —los dos intérpretes no # los enlaza nadie— y sin embargo quedaban **10 scripts de usuario inertes**, entre ellos # `git send-email` y `sensors-detect`. Ningún instrumento del repo lo habría dicho. # # La regla: **un cierre no es sólo lo que se enlaza; es también lo que se ejecuta.** Un `#!` es una # dependencia de ejecución igual de real que un `NEEDED`, con la diferencia de que falla RUIDOSO y # nombrando al intérprete (`exec: no such file or directory`), no silenciosamente contra el sysroot # del lab. Eso hace la poda defendible — y este vigía es lo que la mantiene honesta. # # ══ LAS DOS TABLAS, Y POR QUÉ SON DOS ══════════════════════════════════════════════════════════ # Igual que `hydrate-profile.py` separa `RAIZ_POR_CONTRATO` de `RAIZ_DEUDA_DECIDIDA`: # · `POR_CONTRATO` — andamiaje de BUILD horneado dentro de una librería que sí viaja # (`glib-genmarshal`, `g-ir-scanner`, los módulos de ECM, el `libexec` de qtbase). Inerte por # diseño: nadie los ejecuta en una caja instalada. Se imprimen con ⊘ y no fallan. # · `POR_DECISION` — los que un usuario SÍ podría ejecutar y quedaron inertes a propósito al no # hornear 293 MiB de intérpretes en las 8 imágenes. Se imprimen con • y **no fallan, pero se # listan siempre**: una decisión que deja de verse se convierte en un olvido. # Cualquier otra cosa sale con ✗ y hace fallar con `--fail`. Un ofensor NUEVO no se puede perder en # el ruido de uno viejo — es la misma lección que el bloque de raíces sucias. # # uso: python3 scripts/vigia-interpretes.py [perfil...] [--fail] [--store DIR] [--rootfs DIR] # --fail → exit 1 si hay un `#!` sin proveedor que no esté en las tablas # --rootfs → comprueba contra un rootfs YA hidratado (exacto: no asume nada de la capa # base). Sin él se mide el cierre de imagen del perfil + `CAPA_BASE`. # # La superficie va en INGLÉS (regla 4 del CLAUDE.md); los mensajes, en castellano. import os, re, sys, glob, subprocess sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import yupana import targets as targets_mod ROOT = yupana.ROOT HAMMER = str(ROOT / "target/release/takana") # Lo que pone la CAPA BASE y no el cierre del perfil: el `product-rootfs` del bootstrap # (`STAGE1_COMPONENTS` = musl busybox hammerd arje-zero · `USERLAND_COMPONENTS` = uutils findutils # findutils-xargs diffutils ripgrep). Se declara explícito y corto porque asumirlo en silencio sería # el mismo error que este vigía existe para cazar. Con `--rootfs` no se asume: se mira el árbol. CAPA_BASE = {"bin/sh", "bin/busybox", "usr/bin/env", "bin/env", "usr/bin/coreutils"} # Andamiaje de build horneado dentro de un paquete de runtime. {paquete: (motivo, patrón de rutas)} POR_CONTRATO = { # `gtester-report` y `glib-gettextize` no empiezan por `glib-`: el patrón corto los dejaba en ✗. "glib": ("codegen del build de GLib, no programas del sistema", r"usr/bin/(glib-|gdbus-codegen|gtester-report)"), "glib-shared": ("idem glib", r"usr/bin/(glib-|gdbus-codegen|gtester-report)"), "glib-introspected": ("idem glib", r"usr/bin/(glib-|gdbus-codegen|gtester-report)"), "gobject-introspection":("el escáner de introspección corre en el BUILD", r"usr/bin/g-ir-"), "extra-cmake-modules": ("son módulos de cmake, no ejecutables de la imagen", r"usr/share/ECM/"), "qtbase": ("andamiaje de build de Qt (qt-cmake, mkspecs, libexec)", r"usr/lib/(qt6/(libexec|bin|mkspecs)/|cmake/Qt6/)"), "libevent": ("generador de RPC del build", r"usr/bin/event_rpcgen\.py"), "upower": ("los dos son TESTS del upstream, no parte del servicio", r"usr/libexec/upower/"), "nghttp2": ("`fetch-ocsp-response`: helper opcional del servidor de ejemplo", r"usr/share/nghttp2/"), "libpsl": ("generador de la lista de sufijos, del build", r"usr/bin/psl-make-dafsa"), "nspr": ("generador de tablas de error, del build", r"usr/bin/compile-et\.pl"), "vim": ("ejemplos que vim DOCUMENTA bajo share/vim, no comandos", r"usr/share/vim/"), "helix": ("completado de OTRA shell (fish): es dato, no un comando", r"usr/share/fish/"), "zsh": ("fixtures de test del upstream (con su `/usr/local` horneado)", r"usr/share/zsh/functions/"), "libtool": ("fuentes de libltdl, no ejecutables", r"usr/share/libtool/"), "opencv": ("script de entorno para compilar CONTRA opencv", r"usr/bin/setup_vars_opencv4\.sh"), "plasma-desktop": ("script de extracción de traducciones del tema", r"usr/share/sddm/themes/"), # CPython hornea `#!/usr/local/bin/python` en `cgi.py` desde siempre (es un módulo que se # IMPORTA, no un programa). Aparece sólo en los perfiles donde `python3` sí viaja. "python3": ("el `#!` del upstream en el stdlib: `cgi.py` se importa, no se ejecuta", r"usr/lib/python3\.\d+/"), } # Inertes por la DECISIÓN de `[solo-build]` (los 293 MiB de `python3` + `perl`). Cada entrada dice # qué se pierde, para que la decisión se pueda revisar con el dato delante y no de memoria. POR_DECISION = { # ⚠ Las rutas de git son TRES sitios, no uno, y el patrón corto las dejaba caer a ✗: además del # `libexec`, hay `usr/bin/git-cvsserver`, el `gitweb.cgi` y un hook de plantilla. Lo destapó este # vigía en su primera corrida, que es exactamente para lo que se escribió. "git": ("10 scripts perl: send-email, svn, cvs*, instaweb, gitweb.cgi y el hook " "fsmonitor-watchman.sample", r"usr/(libexec/git-core/|bin/git-cvsserver|share/git(-core|web)/)"), "lm-sensors": ("4 scripts perl: sensors-detect, fancontrol, pwmconfig, sensors-conf-convert " "(el `sensors` en C y libsensors siguen)", r"usr/(sbin/|bin/sensors-conf-convert)"), "libinput": ("las herramientas de diagnóstico libinput-analyze/measure", r"usr/libexec/libinput/"), "iproute2": ("`routel`, el listador de rutas en python", r"sbin/routel"), "rsync": ("`rrsync`, el envoltorio restringido para ssh", r"usr/bin/rrsync"), "usbutils": ("`lsusb.py`, la vista de árbol (el `lsusb` en C sigue)", r"usr/bin/lsusb\.py"), "aerc": ("el filtro de invitaciones .ics y `carddav-query`", r"usr/(libexec/aerc/filters/|bin/carddav-query)"), "gnome-shell":("`gnome-shell-test-tool`, sólo para pruebas", r"usr/bin/gnome-shell-test-tool"), "evolution-data-server": ("`csv2vcard`, importador de contactos", r"usr/libexec/evolution-data-server/"), "squid": ("`helper-mux`, multiplexor de helpers perl", r"usr/lib/squid/helper-mux"), } _cache = {} def artefacto(cola, nombre, store): if (cola, nombre) in _cache: return _cache[(cola, nombre)] receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus" else "recipes/%s/%s.toml" % (cola, nombre)) d = None if receta.exists(): r = subprocess.run([HAMMER, "--store", store, "hash", str(receta)], capture_output=True, text=True) h = r.stdout.strip().replace("b3:", "") if len(h) == 64: cand = glob.glob(os.path.join(store, h + "-*")) d = cand[0] if cand else None _cache[(cola, nombre)] = d return d def shebang(p): """La línea `#!` de un fichero, o None. Lee 2 bytes antes de decidir: abrir y leer una línea de todo el rootfs es caro y la inmensa mayoría no son scripts.""" try: with open(p, "rb") as fh: if fh.read(2) != b"#!": return None fh.seek(0) return fh.readline(300).decode("utf8", "replace").strip() except OSError: return None def interprete(linea, provee): """Del `#!` a la RUTA del intérprete, resuelta. `#!/usr/bin/env python3` no nombra una ruta: se busca `python3` entre lo que la imagen provee — y si no está, se devuelve el nombre para poder decir QUÉ falta (`env python3`, no `env`).""" partes = linea[2:].strip().split() if not partes: return None ruta = partes[0].lstrip("/") if os.path.basename(ruta) == "env" and len(partes) > 1: nombre = partes[1] if nombre.startswith("-"): # `env -S foo` y amigos nombre = partes[-1] for cand in provee: if os.path.basename(cand) == nombre: return cand return "env " + nombre return ruta def escanear_dir(raiz_dir, etiqueta, provee, scripts): for raiz, _, ficheros in os.walk(raiz_dir): for fn in ficheros: p = os.path.join(raiz, fn) rel = os.path.relpath(p, raiz_dir) if os.path.islink(p): provee.add(rel) continue provee.add(rel) # `/share/doc` y `/testdata` no son cierre: nadie los ejecuta. Mismo criterio que el # vigía de sonames con el `testdata/` de Go. if "/share/doc/" in p or "/testdata/" in p: continue linea = shebang(p) if linea: scripts.append((etiqueta, rel, linea)) def main(): args = [a for a in sys.argv[1:] if not a.startswith("--")] fallar = "--fail" in sys.argv def opcion(f): return sys.argv[sys.argv.index(f) + 1] if f in sys.argv else None store = opcion("--store") or os.environ.get("HAMMER_STORE", str(ROOT / "store")) rootfs = opcion("--rootfs") # los valores de las flags no son perfiles args = [a for a in args if a not in (store, rootfs)] perfiles = targets_mod.load() ofensores = 0 if rootfs: provee, scripts = set(), [] escanear_dir(rootfs, "(rootfs)", provee, scripts) casos = [("%s" % rootfs, provee, scripts)] else: memb = yupana.membresia_imagen() casos = [] for perfil in (args or sorted(perfiles)): provee, scripts = set(CAPA_BASE), [] for (cola, nombre) in sorted(n for n, ps in memb.items() if perfil in ps and n[0] != "wanted"): d = artefacto(cola, nombre, store) if d: escanear_dir(d, nombre, provee, scripts) casos.append((perfil, provee, scripts)) for etiqueta, provee, scripts in casos: faltan = [(pkg, rel, it) for pkg, rel, ln in scripts for it in [interprete(ln, provee)] if it and it not in provee] print("== %-20s %4d scripts con `#!` · %3d sin intérprete en la imagen" % (etiqueta, len(scripts), len(faltan))) # agrupado por (paquete, intérprete): 22 líneas iguales de libinput no informan más que una grupos = {} for pkg, rel, it in faltan: grupos.setdefault((pkg, it), []).append(rel) nuevos = [] for (pkg, it), rutas in sorted(grupos.items()): tabla, marca = None, "✗" for t, m in ((POR_CONTRATO, "⊘"), (POR_DECISION, "•")): motivo_patron = t.get(pkg) if motivo_patron and all(re.match(motivo_patron[1], r) for r in rutas): tabla, marca = motivo_patron, m break print(" %s %-22s %-14s %3d %s" % (marca, pkg, it, len(rutas), rutas[0])) if tabla: print(" %s" % tabla[0]) else: nuevos.append("%s (%s)" % (pkg, it)) if nuevos: ofensores += len(nuevos) print("\n ✗ %d caso(s) NO declarado(s): %s\n" " Un `#!` que no resuelve falla al ejecutar, no al instalar: o el intérprete\n" " entra a la imagen (una línea en `paquetes`), o el caso entra a una tabla de\n" " este vigía con su motivo." % (len(nuevos), ", ".join(nuevos))) return 1 if (fallar and ofensores) else 0 if __name__ == "__main__": sys.exit(main())