gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1405Shell 141Markdown 84Rust 802738 Sutichaykuna

⎇ ac3a2afc4f89c4347078c7ac1d5c5a0c387e8ed8 scripts/hydrate-profile.py Python · 401 siq'ikuna · 22.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

paso 2: el bit de privilegio se declara y se verifica — y `doas` queda solo (SDD 28 §5.11) 0523ea066 · Sergio ·

1#!/usr/bin/env python3
2# hydrate-profile.py — proyecta al FHS el cierre de IMAGEN de un perfil de `targets.toml`.
3#
4# ⚠ Decía «el cierre ENTERO», y eso era literal y era el bug: proyectaba `build ∪ runtime`, o sea la
5# cadena de compilación adentro de cada imagen (`llvm21`, `go`, `python3`, `perl`, `cmake`…). Desde el
6# 2026-09-22 proyecta `yupana.membresia_imagen()`, que descuenta las herramientas declaradas en
7# `[solo-build]` de `targets.toml` — salvo las que un perfil declare como raíz, donde gana el perfil.
8#
9# ══ POR QUÉ EXISTE, SI YA HAY UN hydrate POR ESCRITORIO ════════════════════════════════════════
10# `scripts/{kde,gnome,cosmic}/hydrate-*.sh` traen la lista de raíces ESCRITA A MANO dentro del
11# propio script. Desde que `docs/state/targets.toml` es la declaración de qué lleva cada imagen,
12# eso son DOS fuentes de verdad para lo mismo, y divergen sin que nada lo diga: el 2026-09-04 se
13# añadió `adwaita-cursors` a las raíces de cosmic y sway, y los scripts de hidratación —que no la
14# conocen— habrían seguido armando un rootfs sin cursores mientras el perfil decía que los lleva.
15# La métrica y la imagen dirían cosas distintas, que es exactamente el modo de fallo que
16# `vigia-imagen.py` existe para cazar.
17#
18# Acá el cierre sale de donde tiene que salir: `yupana.membresia()` sobre `targets.toml`, la misma
19# función que usan `build-state.py` y `vigia-imagen.py`. Si el perfil cambia, esto cambia solo.
20#
21# ══ EL ARTEFACTO SE ELIGE POR HASH, NO POR FECHA ═══════════════════════════════════════════════
22# `takana hash <receta>` da el ArtifactHash de la receta de HOY sin construir. Con dos artefactos
23# del mismo paquete conviviendo en el store —lo normal tras cualquier re-hash— el más reciente
24# puede ser el viejo. La fecha miente; el hash no.
25#
26# ══ UN NODO SIN ARTEFACTO NO SE SALTEA EN SILENCIO ═════════════════════════════════════════════
27# Se listan todos y se sale con código ≠ 0 salvo `--allow-missing`. Un rootfs al que le faltan
28# tres paquetes arranca y falla tres capas más abajo (`CLAUDE.md` regla 3, y la cicatriz de sway
29# llegando a 121/121 sin emulador de terminal).
30#
31#   uso:  scripts/hydrate-profile.py <perfil> [--into DIR] [--store DIR]
32#                                    [--link static|dynamic] [--keep] [--allow-missing]
33#                                    [--closure image|build]
34#         --closure build  → proyecta el cierre ENTERO (con herramientas), como antes del
35#                            2026-09-22. Es el CONTROL para poder medir el antes y el después.
36#
37#   La superficie va en INGLÉS (`CLAUDE.md` regla 4); los mensajes, en castellano.
38import os, sys, glob, shutil, subprocess
39
40sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41import yupana
42import targets as targets_mod
43
44ROOT = yupana.ROOT
45
46def _binario_takana():
47    """El binario, buscado en los TRES sitios donde puede estar — no sólo en el árbol de desarrollo.
48
49    Estaba cableado a `ROOT/target/release/takana` y por eso este script no corría en una caja
50    INSTALADA, donde el binario es `/usr/bin/takana` y `target/` ni existe. Es la misma deuda que el
51    frente del servidor ya pagó en `respaldo-storagebox.sh` y en los grafos («unhashable 875» con el
52    lab perfecto): el instrumental asume que el hub es un árbol de desarrollo. Falla al invocar, con
53    un `FileNotFoundError` que nombra una ruta que en esa máquina nunca va a existir.
54    """
55    for c in (os.environ.get("TAKANA"), os.environ.get("HAMMER"), str(ROOT / "target/release/takana")):
56        if c and os.path.exists(c):
57            return c
58    return shutil.which("takana") or shutil.which("hammer") or str(ROOT / "target/release/takana")
59
60HAMMER = _binario_takana()
61
62
63def opcion(nombre, defecto=None):
64    """`--flag valor`. Devuelve `defecto` si no está."""
65    if nombre in sys.argv:
66        i = sys.argv.index(nombre)
67        if i + 1 < len(sys.argv):
68            return sys.argv[i + 1]
69        sys.exit("falta el valor de %s" % nombre)
70    return defecto
71
72
73# Lo que SÍ puede aparecer en la raíz de ciertos artefactos, con el motivo al lado. Sin esta tabla
74# el barrido canta tres cosas cada vez y dos son correctas ⇒ se deja de leer, que es como se muere un
75# guardián. La clave es el nombre de receta y el valor `(motivo, patrón de lo permitido)`.
76RAIZ_POR_CONTRATO = {
77    "seed-zig":       ("el toolchain zig ES el artefacto: su directorio versionado va en la raíz",
78                       r"^zig-"),
79    "product-rootfs": ("es un ROOTFS, no un paquete: `store` y `ente` son suyos por diseño",
80                       r"^(store|ente)$"),
81    "stage1-rootfs":  ("es un ROOTFS, no un paquete: `ente` es suyo por diseño",
82                       r"^(store|ente)$"),
83}
84
85# Suciedad REAL, medida, y cuyo arreglo está DECIDIDO en contra por ahora. Se imprime como contexto y
86# NO hace fallar: si fallara siempre, un ofensor NUEVO se perdería entre el ruido de uno viejo.
87RAIZ_DEUDA_DECIDIDA = {
88    "perl": "945 páginas nroff en `/` porque su Configure eligió man1dir=' '. El arreglo es UNA "
89            "línea y cuesta 305 rebuilds ⇒ va con el próximo bump. Ver recipes/perl.toml.",
90}
91
92
93def auditar_raices(store):
94    """`--auditar-raices`: la misma pregunta que el bloque «quién ensucia la RAÍZ», pero sobre el
95    STORE ENTERO en vez de sobre el cierre de un perfil, y sin hidratar nada.
96
97    ⚠ **POR QUÉ HACÍA FALTA**: la comprobación de abajo sólo ve los artefactos que un perfil
98    proyecta, y hoy hay **649 recetas que no alcanza ninguna imagen** (catálogo, no distro). Una fase
99    `install` que se equivoca de destino en una de ésas es invisible hasta el día que alguien la
100    declare en un perfil — y entonces ensucia la raíz del rootfs sin que se sepa quién fue.
101
102    Lo destapó `qdrant` (2026-09-11): selló con **69 M** de cabeceras de protobuf bajo `/src`, porque
103    el sandbox exporta `DESTDIR=/out` GLOBAL y el `make install` ANIDADO de `protobuf-src` lo hereda.
104    No está en ningún perfil ⇒ ningún guardián lo habría visto. Barrido el store entero tras
105    arreglarlo: los únicos que quedan son `perl` (deuda medida y decidida, ver `recipes/perl.toml`) y
106    los tres rootfs/seed, que ponen ahí lo suyo por contrato."""
107    import re as _re
108    import json as _json
109
110    # ⚠ **SÓLO EL ARTEFACTO VIGENTE DE CADA RECETA.** El store guarda TODOS los sellados históricos
111    # (bzip2 tenía dos al arreglarlo), así que auditarlos todos acusa a recetas YA SANAS por un
112    # sellado viejo — el mismo sobre-reporte que `static-audit.sh` tuvo que quitarse de encima
113    # («auditábamos el más reciente por mtime»). Un guardián que grita sobre lo ya arreglado se
114    # empieza a ignorar.
115    #   El hash vigente no se recalcula con `takana hash` receta a receta (1150 procesos): sale de
116    # los grafos de estado, que es donde el repo YA lo tiene escrito, y cubre a la vez el corpus y
117    # las cinco colas.
118    vigentes = set()
119    for g in glob.glob(str(ROOT / "docs/state/build-state*.json")):
120        try:
121            for nodo in _json.load(open(g)).get("nodes", {}).values():
122                h = nodo.get("hash")
123                if h:
124                    vigentes.add(h[3:] if h.startswith("b3:") else h)
125        except (OSError, ValueError):
126            continue
127
128    filas = []
129    mirados = 0
130    for d in sorted(glob.glob(os.path.join(store, "*"))):
131        m = _re.match(r"^([0-9a-f]{64})-(.+)$", os.path.basename(d))
132        if not m or not os.path.isdir(d) or (vigentes and m.group(1) not in vigentes):
133            continue
134        mirados += 1
135        try:
136            extra = sorted(e for e in os.listdir(d) if e not in FHS_RAIZ)
137        except OSError:
138            continue
139        if extra:
140            filas.append((len(extra), m.group(2), extra))
141    # ── SEGUNDA PREGUNTA, LA MISMA FAMILIA: symlinks que apuntan a una raíz que NO ES EL FHS ─────
142    # Un `install` se puede equivocar de destino de dos maneras. Una deja ficheros donde no van (lo
143    # de arriba). La otra deja **enlaces** con la ruta del SANDBOX horneada dentro:
144    #
145    #     usr/bin/bzcmp -> /out/usr/bin/bzdiff
146    #
147    # El Makefile de bzip2 crea sus symlinks con `ln -s $(PREFIX)/bin/bzdiff`, y como bzip2 no
148    # soporta `DESTDIR` la receta pasa `PREFIX=/out/usr` ⇒ el `/out` queda DENTRO del destino. Al
149    # hidratar, esos cuatro enlaces apuntan a un directorio que en el sistema real no existe.
150    #
151    # ⚠ Nada lo delata: el artefacto tiene contenido, los binarios de verdad corren, el audit de
152    # enlace estático pasa y la receta REPRODUCE. Cinco verdes sobre cuatro comandos rotos.
153    #
154    # **La regla es independiente del perfil, y por eso se puede aplicar al store entero**: un enlace
155    # a otro ARTEFACTO es normal y correcto (`kinfocenter -> /usr/bin/systemsettings`: los dos están
156    # en `escritorio-kde` y en el rootfs fundido resuelve). Lo que NUNCA puede estar bien es un
157    # destino absoluto cuya PRIMERA componente no sea del FHS — `/out`, `/src`, `/tmp`: ésas son
158    # rutas del lab, no del sistema que se instala.
159    malos_enlaces = []
160    for d in sorted(glob.glob(os.path.join(store, "*"))):
161        m = _re.match(r"^([0-9a-f]{64})-(.+)$", os.path.basename(d))
162        if not m or not os.path.isdir(d) or (vigentes and m.group(1) not in vigentes):
163            continue
164        for root, dirs, files in os.walk(d):
165            for e in files + dirs:
166                p_ = os.path.join(root, e)
167                if not os.path.islink(p_):
168                    continue
169                t = os.readlink(p_)
170                if not t.startswith("/"):
171                    continue                       # relativo: siempre correcto en un CAS
172                raiz = t.lstrip("/").split("/", 1)[0]
173                if raiz not in FHS_RAIZ:
174                    malos_enlaces.append((m.group(2), os.path.relpath(p_, d), t))
175
176    # ⚠ **UN BARRIDO QUE NO MIRÓ NADA TIENE QUE DECIRLO.** Filtrar por hash vigente significa que un
177    # `--store` que no case con los grafos (otra máquina, otro checkout, un store de prueba) deja
178    # CERO artefactos auditados — y sin esta guarda eso se imprimiría como «✓ 0 ofensores», que es
179    # una respuesta falsa con forma de respuesta. Es la regla de siempre: un ausente falla ruidoso.
180    if vigentes and mirados == 0:
181        print("!! 0 artefactos auditados: ninguno de los %d hashes vigentes está en `%s`.\n"
182              "   ¿`--store` apunta a otro sitio, o los grafos están sin regenerar?"
183              % (len(vigentes), store))
184        return 2
185
186    print("== raíces sucias en el STORE ENTERO ==")
187    nuevos = []
188    for n, nombre, extra in sorted(filas, reverse=True):
189        muestra = ", ".join(extra[:3]) + (", …" if n > 3 else "")
190        contrato = RAIZ_POR_CONTRATO.get(nombre)
191        if contrato and all(_re.match(contrato[1], e) for e in extra):
192            print("   ⊘ %-22s %4d  (%s) — %s" % (nombre, n, muestra, contrato[0]))
193            continue
194        if nombre in RAIZ_DEUDA_DECIDIDA:
195            print("   • %-22s %4d  (%s)" % (nombre, n, muestra))
196            print("     DEUDA DECIDIDA: %s" % RAIZ_DEUDA_DECIDIDA[nombre])
197            continue
198        print("   ✗ %-22s %4d  (%s)" % (nombre, n, muestra))
199        nuevos.append(nombre)
200    print("\n== symlinks con una raíz que NO es del FHS (ruta del lab horneada) ==")
201    if not malos_enlaces:
202        print("   ✓ ninguno")
203    else:
204        for nombre, origen, destino in malos_enlaces:
205            print("   ✗ %-18s %s -> %s" % (nombre, origen, destino))
206            if nombre not in nuevos:
207                nuevos.append(nombre)
208
209    if not nuevos:
210        print("\n   ✓ 0 ofensores NUEVOS sobre %d artefactos." % len(filas)
211              if filas else "\n   ✓ ningún artefacto pone nada fuera del FHS en su raíz")
212        return 0
213    print("\n   ✗ %d ofensor(es) NUEVO(s): %s\n"
214          "   Un `install` que se equivoca de destino se lee, en el rootfs fundido, como si fuera\n"
215          "   parte del sistema." % (len(nuevos), ", ".join(nuevos)))
216    return 1
217
218
219def main():
220    libres = [a for i, a in enumerate(sys.argv[1:], 1)
221              if not a.startswith("--") and not (sys.argv[i - 1].startswith("--")
222                                                 and sys.argv[i - 1] in FLAGS_CON_VALOR)]
223    if "--auditar-raices" in sys.argv:
224        sys.exit(auditar_raices(opcion("--store",
225                                       os.environ.get("HAMMER_STORE", str(ROOT / "store")))))
226    if not libres:
227        sys.exit(__doc__ or "uso: scripts/hydrate-profile.py <perfil> [--into DIR]")
228    perfil = libres[0]
229    store = opcion("--store", os.environ.get("HAMMER_STORE", str(ROOT / "store")))
230    # Dónde cae el rootfs por defecto. Se puede fijar por entorno porque el sitio CORRECTO depende
231    # de cómo esté montado el store en cada máquina, no del perfil (ver la sonda EXDEV de abajo).
232    base_into = os.environ.get("HAMMER_ROOTFS_DIR", str(ROOT / "work/out"))
233    into = opcion("--into", os.path.join(base_into, perfil.replace("escritorio-", "") + "-rootfs"))
234    link = opcion("--link", "static")
235    keep = "--keep" in sys.argv
236    permitir_faltantes = "--allow-missing" in sys.argv
237
238    perfiles = targets_mod.load()
239    if perfil not in perfiles:
240        sys.exit("perfil desconocido: %s (hay: %s)" % (perfil, ", ".join(sorted(perfiles))))
241    colas = yupana.cargar_colas()
242    # ⚠ **EL CIERRE DE IMAGEN, NO EL DE BUILD** (2026-09-22). Esto proyectaba `membresia()`, que es
243    # `build ∪ runtime`, así que el rootfs salía con la cadena de compilación adentro: `llvm21`
244    # (1.527 MiB), `go`, `python3`, `binutils`, `cmake`, `perl`, `nasm`, `wasi-sdk`, `nodejs`… — el
245    # 44 % de los bytes de `servidor`. Nadie lo había medido porque el cierre de build es la
246    # respuesta correcta a OTRA pregunta («qué hay que construir»), y acá se usaba para ésta.
247    #   La lista de lo que no viaja está declarada en `[solo-build]` de `targets.toml`, con sus dos
248    # vigías. No toca ninguna receta ⇒ CERO reconstrucciones.
249    # `--closure build` proyecta el cierre VIEJO (con herramientas). No es una opción de uso: es el
250    # control del experimento — sin poder medir el antes, «−X MiB» es una afirmación sin testigo.
251    cierre = opcion("--closure", "image")
252    if cierre not in ("image", "build"):
253        sys.exit("--closure: `image` (lo que viaja) o `build` (el cierre entero, para comparar)")
254    memb = (yupana.membresia_imagen(colas, perfiles) if cierre == "image"
255            else yupana.membresia(colas, perfiles))
256    nodos = sorted(n for n, ps in memb.items() if perfil in ps and n[0] != "wanted")
257    if not nodos:
258        sys.exit("el cierre de %s salió vacío — ¿el perfil declara raíces?" % perfil)
259
260    # Se imprimen los DOS números y la diferencia. Un recorte que no se ve en la salida es un recorte
261    # que alguien va a diagnosticar como «faltan paquetes» dentro de seis meses.
262    memb_build = yupana.membresia(colas, perfiles)
263    n_build = sum(1 for n, ps in memb_build.items() if perfil in ps and n[0] != "wanted")
264    print("== %s: %d nodos en el cierre de %s → %s"
265          % (perfil, len(nodos), "IMAGEN" if cierre == "image" else "BUILD", into))
266    if cierre == "image":
267        print("   (%d en el cierre de build; %d herramientas de `[solo-build]` no viajan)"
268              % (n_build, n_build - len(nodos)))
269
270    # ── resolver ANTES de tocar el disco: si falta algo, que se sepa antes de borrar nada ──────
271    artefactos, faltan = [], []
272    for cola, nombre in nodos:
273        receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
274                         else "recipes/%s/%s.toml" % (cola, nombre))
275        if not receta.exists():
276            faltan.append((nombre, "sin receta"))
277            continue
278        r = subprocess.run([HAMMER, "--store", store, "hash", str(receta)],
279                           capture_output=True, text=True)
280        h = r.stdout.strip().replace("b3:", "")
281        if len(h) != 64:
282            faltan.append((nombre, "hash no computable"))
283            continue
284        cand = glob.glob(os.path.join(store, h + "-*"))
285        if not cand:
286            faltan.append((nombre, "no sellado (%s)" % h[:12]))
287            continue
288        artefactos.append((nombre, h, cand[0]))
289
290    if faltan:
291        print("\n   ⚠ %d nodo(s) SIN artefacto en el store:" % len(faltan))
292        for nombre, motivo in faltan:
293            print("     %-28s %s" % (nombre, motivo))
294        if not permitir_faltantes:
295            sys.exit("\nabortado sin tocar %s — con `--allow-missing` se hidrata lo que hay" % into)
296        print("   → se continúa por `--allow-missing`: el rootfs sale INCOMPLETO\n")
297
298    if os.path.isdir(into) and not keep:
299        shutil.rmtree(into)
300    os.makedirs(into, exist_ok=True)
301
302    # ── EXDEV: `hydrate` proyecta por HARDLINK, y `linkat()` NO cruza un punto de MONTAJE ───────
303    # aunque los dos lados sean el mismo filesystem y el mismo disco. Acá el store y `work/out` son
304    # dos bind mounts del MISMO /dev/sdb: `st_dev` coincide y el hardlink falla igual — por eso la
305    # sonda es FUNCIONAL (se intenta un enlace de verdad) y no una comparación de `st_dev`, que fue
306    # el primer intento y no disparó. Sin esto salen 174 «artefactos que no proyectaron» y un rootfs
307    # de 272 ficheros que parece un problema de recetas.
308    sonda_src = os.path.join(store, ".hydrate-profile-probe")
309    sonda_dst = os.path.join(into, ".hydrate-profile-probe")
310    try:
311        with open(sonda_src, "w"):
312            pass
313        os.link(sonda_src, sonda_dst)
314        os.unlink(sonda_dst)
315    except OSError as e:
316        mp = subprocess.run(["findmnt", "-n", "-T", store, "-o", "TARGET"],
317                            capture_output=True, text=True).stdout.strip()
318        sys.exit("EXDEV: no se puede enlazar del store a `%s` (%s).\n"
319                 "       `hydrate` ENLAZA, no copia, y un hardlink no cruza un montaje.\n"
320                 "       el store está montado en: %s\n"
321                 "       elegí un `--into` bajo ESE montaje (o fijá HAMMER_ROOTFS_DIR)."
322                 % (into, e.strerror, mp or "?"))
323    finally:
324        if os.path.exists(sonda_src):
325            os.unlink(sonda_src)
326
327    fallidos = []
328    for i, (nombre, h, _d) in enumerate(artefactos, 1):
329        r = subprocess.run([HAMMER, "--store", store, "hydrate", h,
330                            "--into", into, "--link", link],
331                           capture_output=True, text=True)
332        if r.returncode != 0:
333            fallidos.append((nombre, (r.stderr or r.stdout).strip().splitlines()[-1:] or [""]))
334        if i % 25 == 0 or i == len(artefactos):
335            print("   %d/%d" % (i, len(artefactos)))
336
337    if fallidos:
338        print("\n   ✗ %d artefacto(s) no proyectaron:" % len(fallidos))
339        for nombre, msg in fallidos:
340            print("     %-28s %s" % (nombre, msg[0] if msg else ""))
341
342    # ── quién ensucia la RAÍZ del rootfs ────────────────────────────────────────────────────────
343    # Se mira por ARTEFACTO y no sobre el árbol fundido, porque en el árbol fundido el nombre del
344    # culpable ya se perdió: 945 ficheros sueltos en `/` no se parecen a «una fase install con el
345    # directorio de destino vacío», que es lo que son. Cazado el 2026-09-04 mirando un rootfs de
346    # verdad, no una métrica: `perl` instala sus 945 páginas nroff en `/` porque su Configure eligió
347    # `man1dir=' '`. Ver el comentario en `recipes/perl.toml`.
348    sucios = []
349    for nombre, _h, d in artefactos:
350        extra = sorted(e for e in os.listdir(d) if e not in FHS_RAIZ)
351        if extra:
352            sucios.append((nombre, extra))
353    if sucios:
354        print("\n   ⚠ entradas FUERA del FHS en la raíz del rootfs:")
355        for nombre, extra in sorted(sucios, key=lambda x: -len(x[1])):
356            muestra = ", ".join(extra[:3]) + (", …" if len(extra) > 3 else "")
357            print("     %-28s %4d  (%s)" % (nombre, len(extra), muestra))
358
359    # ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, respondido el 2026-09-22) ──────────────────────────
360    # Se aplica ACÁ y no en la receta porque no puede vivir en el artefacto: `of_tree` sólo hashea el
361    # bit de EJECUCIÓN (⇒ el hash no lo cubriría) y la hidratación es por HARDLINK (⇒ el `chmod`
362    # viajaría al artefacto que comparten todas las imágenes). `takana setuid --apply` rompe el
363    # enlace duro del fichero afectado y deja el store intacto; verifica en las dos direcciones —lo
364    # declarado está, y nada sin declarar tiene el bit— y sale ≠0 si algo no cuadra.
365    #
366    # Va en este script, que es por donde pasan TODAS las imágenes de perfil, y no en cada script de
367    # imagen: un paso de seguridad que hay que acordarse de invocar se olvida. Y se imprime siempre,
368    # incluso cuando no hay declaraciones: «0 declaraciones» es una respuesta, y el silencio no.
369    rutas_recetas = [str(ROOT / ("recipes/%s.toml" % n if q == "corpus" else "recipes/%s/%s.toml" % (q, n)))
370                     for q, n in nodos]
371    print("\n== bit de privilegio (`[[setuid]]`) → %s" % into)
372    r = subprocess.run([HAMMER, "--store", store, "setuid", *rutas_recetas, "--apply", into],
373                       capture_output=True, text=True)
374    print((r.stdout or "").rstrip() or "   (sin salida)")
375    if r.returncode != 0:
376        # No se aborta el rootfs ya hidratado —borrarlo sería peor— pero se sale ≠0: un rootfs con el
377        # privilegio mal puesto NO puede pasar por bueno en un script de imagen que encadena.
378        print((r.stderr or "").rstrip())
379        print("   ✗ el bit de privilegio no quedó bien; el rootfs está hidratado pero NO es apto.")
380        salida_setuid = 1
381    else:
382        salida_setuid = 0
383
384    n_fich = sum(len(f) for _, _, f in os.walk(into))
385    du = subprocess.run(["du", "-sh", into], capture_output=True, text=True).stdout.split()
386    print("\n== %s: %d ficheros, %s" % (into, n_fich, du[0] if du else "?"))
387    sys.exit(1 if (fallidos or salida_setuid or (faltan and not permitir_faltantes)) else 0)
388
389
390FLAGS_CON_VALOR = {"--into", "--store", "--link", "--closure"}
391
392# Lo que un artefacto PUEDE poner en la raíz. Todo lo demás es una fase `install` que se equivocó de
393# destino, y en un rootfs fundido eso se lee como si fuera parte del sistema.
394FHS_RAIZ = {"bin", "sbin", "lib", "lib64", "usr", "etc", "var", "run", "opt", "srv",
395            "home", "root", "boot", "dev", "proc", "sys", "tmp", "mnt", "media",
396            "ente",      # el manifiesto de atestación del producto (hammer bootstrap)
397            "linuxrc",   # el enlace de busybox, que va en la raíz por contrato del kernel
398            ".hammer"}   # metadatos del store: los pone hammer al sellar, no la receta
399
400if __name__ == "__main__":
401    main()