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…

feat(instalador): la etiqueta sin e2fsprogs, que es lo que le faltaba a adoptar para arrancar

El initramfs de pivote encuentra la raiz por su etiqueta, asi que una particion
adoptada sin etiqueta tiene el sistema adentro y nadie lo encuentra al arrancar:
una instalacion que dice que salio bien con una maquina que no enciende.
Normalmente la pone el formateo, pero adoptar no formatea, y adentro del
initramfs no viajan ni e2label ni tune2fs.

El binario del instalador si viaja ahi, asi que lo hace el. Lo que no es trivial
—y lo que convierte un cambio inocente en un filesystem que el kernel monta de
solo lectura— es que un ext4 moderno lleva su propia suma de verificacion en el
superbloque: tocarlo sin recalcularla lo invalida.

Y la primera version la calculaba mal. La convencion habitual de esa suma
incluye un complemento final que ext4 no usa, y con el complemento el verificador
decia «no coincide» sobre un filesystem perfecto. Los tests de unidad seguian en
verde porque su superbloque sintetico no traia el flag: el banco nuevo arma uno
de verdad y le pregunta al verificador, que es el unico que sabe.

Ese banco encontro ademas un defecto suyo: leia la etiqueta con un patron que no
casaba nunca y acusaba al codigo por no haber sabido mirar. Ahora empieza
comprobando que sepa leer la que acaba de poner el formateo — si no la ve, el
roto es el lector.

Mas el reparcheo de los guiones de un paquete ya armado, para la maquina de
desarrollo que no tiene el almacen sellado: reemplaza lo que es texto, deja el
sistema y el kernel intactos, y al terminar vuelve a abrirlo y compara. Un
paquete que dice llevar el arreglo y no lo lleva es peor que uno que no lo
lleva, y eso ya paso dos veces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

f1e6628312689e3fa33cce0e04c3809f8424a3e7 · by sergio · (19 d ago) · parent 4b1f19d8d · browse the tree at this commit

Semantic delta · 14

crates/takana-cli/src/fs_label.rs

crates/takana-cli/src/main.rs

5 files · +357 −1

diff --git a/crates/takana-cli/src/fs_label.rs b/crates/takana-cli/src/fs_label.rs
new file mode 100644
index 00000000..bd1749dc
--- /dev/null
+++ b/crates/takana-cli/src/fs_label.rs
@@ -0,0 +1,150 @@
+//! Ponerle la **etiqueta** a un filesystem ext2/3/4 sin e2fsprogs.
+//!
+//! # Por qué existe
+//!
+//! El initramfs de pivote encuentra la raíz con `findfs LABEL=hammer-root`, así que una partición
+//! sin esa etiqueta tiene el sistema adentro y **nadie lo encuentra al arrancar**. Normalmente la
+//! pone `mke2fs -L` al formatear — pero `--adopt` no formatea, y en el initramfs del instalador no
+//! viaja ni `e2label` ni `tune2fs`: busybox no los trae y traerlos obligaría a rearmar el paquete
+//! contra el store, que es justo lo que no se puede hacer desde una máquina de desarrollo.
+//!
+//! El binario `takana` **sí** viaja ahí. Esto es lo mínimo que hace falta, hecho bien.
+//!
+//! # Lo que «bien» quiere decir acá
+//!
+//! La etiqueta son 16 bytes en el offset `0x78` del superbloque, y escribirlos es trivial. Lo que
+//! no es trivial —y lo que convierte un cambio inocente en un filesystem que el kernel monta de
+//! sólo lectura— es que un ext4 moderno trae `metadata_csum`: el superbloque lleva su propio
+//! crc32c en `0x3FC`, y tocarlo sin recalcularlo lo deja inválido. Por eso acá se mira el flag, y
+//! si está, se recalcula.
+
+use std::fs::OpenOptions;
+use std::io::{Read, Seek, SeekFrom, Write};
+use std::path::Path;
+
+const SB_OFFSET: u64 = 1024;
+const SB_LEN: usize = 1024;
+const MAGIC: u16 = 0xEF53;
+/// `RO_COMPAT_METADATA_CSUM`, el bit que dice que el superbloque lleva su propio crc32c.
+const RO_COMPAT_METADATA_CSUM: u32 = 0x0400;
+
+/// crc32c (Castagnoli), que es el que usa ext4. Sin tabla: 16 bytes de superbloque por instalación
+/// no justifican 1 KiB de tabla estática, y así no hay un segundo sitio donde equivocarse.
+fn crc32c(mut crc: u32, datos: &[u8]) -> u32 {
+    const POLY: u32 = 0x82F6_3B78; // el reflejado de 0x1EDC6F41
+    for b in datos {
+        crc ^= *b as u32;
+        for _ in 0..8 {
+            crc = if crc & 1 != 0 { (crc >> 1) ^ POLY } else { crc >> 1 };
+        }
+    }
+    crc
+}
+
+/// Le pone `etiqueta` al ext2/3/4 que empieza en `offset` dentro de `dispositivo`.
+///
+/// Devuelve la etiqueta que había antes, que es lo que permite decir en el informe qué se
+/// reemplazó en vez de sólo que se escribió algo.
+pub fn poner_etiqueta(
+    dispositivo: &Path,
+    offset: u64,
+    etiqueta: &str,
+) -> std::io::Result<String> {
+    let bytes = etiqueta.as_bytes();
+    if bytes.len() > 16 {
+        return Err(std::io::Error::new(
+            std::io::ErrorKind::InvalidInput,
+            format!("la etiqueta no puede pasar de 16 bytes (pediste {})", bytes.len()),
+        ));
+    }
+    let mut f = OpenOptions::new().read(true).write(true).open(dispositivo)?;
+    let mut sb = [0u8; SB_LEN];
+    f.seek(SeekFrom::Start(offset + SB_OFFSET))?;
+    f.read_exact(&mut sb)?;
+    if u16::from_le_bytes([sb[0x38], sb[0x39]]) != MAGIC {
+        return Err(std::io::Error::new(
+            std::io::ErrorKind::InvalidData,
+            "ahí no hay un superbloque ext2/3/4 (la magia no casa)",
+        ));
+    }
+    let antes = String::from_utf8_lossy(
+        &sb[0x78..0x88].split(|b| *b == 0).next().unwrap_or(&[]),
+    )
+    .to_string();
+
+    sb[0x78..0x88].fill(0);
+    sb[0x78..0x78 + bytes.len()].copy_from_slice(bytes);
+
+    // Y el checksum, si este filesystem lo lleva. Sin esto el kernel monta de sólo lectura y el
+    // síntoma —un escritorio que no puede escribir nada— no menciona la etiqueta por ningún lado.
+    let ro_compat = u32::from_le_bytes([sb[0x64], sb[0x65], sb[0x66], sb[0x67]]);
+    if ro_compat & RO_COMPAT_METADATA_CSUM != 0 {
+        // El checksum cubre el superbloque entero MENOS sus propios cuatro bytes finales.
+        //
+        // ⚠ **Y va SIN el complemento final.** La convención de «el crc32c de una cadena» lo lleva
+        // —es lo que hace que `"123456789"` dé `0xE3069283`— pero ext4 guarda el valor CRUDO con
+        // semilla `~0` (`ext4_superblock_csum`: `ext4_chksum(~0, es, offset)`, sin invertir).
+        // Medido: con el complemento, `e2fsck` dice «Superblock checksum does not match» sobre un
+        // filesystem perfecto, y el test de unidad no lo veía porque su superbloque sintético no
+        // traía el flag. Por eso hay un banco contra un ext4 de verdad.
+        let crc = crc32c(!0u32, &sb[..0x3FC]);
+        sb[0x3FC..0x400].copy_from_slice(&crc.to_le_bytes());
+    }
+    f.seek(SeekFrom::Start(offset + SB_OFFSET))?;
+    f.write_all(&sb)?;
+    f.sync_all()?;
+    Ok(antes)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// El vector de prueba estándar de crc32c: `"123456789"` da `0xE3069283`.
+    ///
+    /// Está acá porque una implementación de crc con el polinomio al derecho en vez de reflejado
+    /// compila, corre y devuelve un número distinto en cada byte — y el síntoma no sería «el crc
+    /// está mal» sino «el filesystem se monta de sólo lectura», tres capas más allá.
+    #[test]
+    fn el_crc32c_es_el_de_castagnoli() {
+        assert_eq!(!crc32c(!0u32, b"123456789"), 0xE306_9283);
+    }
+
+    /// Y la etiqueta se escribe donde la busca todo el mundo, sin tocar nada más.
+    #[test]
+    fn la_etiqueta_va_en_su_sitio_y_no_pisa_al_vecino() {
+        let dir = std::env::temp_dir().join(format!("takana-label-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let p = dir.join("fs.img");
+        let mut sb = vec![0u8; 2048];
+        sb[1024 + 0x38] = 0x53;
+        sb[1024 + 0x39] = 0xEF;
+        sb[1024 + 0x78..1024 + 0x80].copy_from_slice(b"anterior");
+        // Un vecino justo después de la etiqueta: si se escribieran 17 bytes, se nota.
+        sb[1024 + 0x88] = 0xAB;
+        std::fs::write(&p, &sb).unwrap();
+
+        let antes = poner_etiqueta(&p, 0, "hammer-root").unwrap();
+        assert_eq!(antes, "anterior");
+        let leido = std::fs::read(&p).unwrap();
+        assert_eq!(&leido[1024 + 0x78..1024 + 0x83], b"hammer-root");
+        assert_eq!(leido[1024 + 0x83], 0, "lo que sobra queda en cero");
+        assert_eq!(leido[1024 + 0x88], 0xAB, "no se pisó al vecino");
+        // Sin el flag de metadata_csum, el checksum NO se toca: escribir uno donde no lo hay es
+        // tan dañino como no escribirlo donde sí.
+        assert_eq!(&leido[1024 + 0x3FC..1024 + 0x400], &[0, 0, 0, 0]);
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    /// Y donde no hay superbloque, no se escribe nada.
+    #[test]
+    fn sin_superbloque_no_escribe() {
+        let dir = std::env::temp_dir().join(format!("takana-label-no-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let p = dir.join("basura.img");
+        std::fs::write(&p, vec![0u8; 2048]).unwrap();
+        assert!(poner_etiqueta(&p, 0, "hammer-root").is_err());
+        assert_eq!(std::fs::read(&p).unwrap(), vec![0u8; 2048]);
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}
diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index fb859f71..9e759957 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -8,6 +8,7 @@ use std::path::PathBuf;
 
 use clap::{Parser, Subcommand};
 
+mod fs_label;
 mod alpine_import;
 mod efi_boot;
 mod fat_ro;
@@ -39,6 +40,22 @@ struct Cli {
 
 #[derive(Subcommand)]
 enum Cmd {
+    /// Poner la **etiqueta** de un filesystem ext2/3/4, sin e2fsprogs.
+    ///
+    /// Existe para `metal install --adopt`, que no formatea: ahí la etiqueta por la que el
+    /// initramfs de pivote encuentra la raíz (`findfs LABEL=hammer-root`) hay que ponerla a mano, y
+    /// en el initramfs del instalador no viajan ni `e2label` ni `tune2fs`. Recalcula el crc32c del
+    /// superbloque si el filesystem trae `metadata_csum` — sin eso el kernel lo monta de sólo
+    /// lectura y el síntoma no menciona la etiqueta por ningún lado.
+    FsLabel {
+        /// El dispositivo (o un fichero de imagen).
+        device: String,
+        /// La etiqueta nueva. Máximo 16 bytes.
+        label: String,
+        /// Byte donde empieza el filesystem dentro de `device`. 0 si es la partición misma.
+        #[arg(long, default_value_t = 0)]
+        offset: u64,
+    },
     /// [Fase 0] Compila una receta y la sella en el store.
     ///
     /// `forja` es alias de marca (ADR 0016): el verbo CANÓNICO sigue siendo `build` —
@@ -2321,6 +2338,10 @@ del ensamblado mapea al que CORRE ⇒ este bit no saldría root en la imagen");
             }
         }
         Cmd::Kernel { sub } => kernel_cmd::run(sub, &cli.store)?,
+        Cmd::FsLabel { device, label, offset } => {
+            let antes = fs_label::poner_etiqueta(std::path::Path::new(&device), offset, &label)?;
+            println!("takana fs-label: {device} ahora es «{label}» (antes {antes:?})");
+        }
         Cmd::Metal { sub } => metal::run(sub)?,
         Cmd::Qorpa { sub } => qorpa::run(sub, std::path::Path::new(&cli.store))?,
     }
diff --git a/scripts/fs-label-test.sh b/scripts/fs-label-test.sh
new file mode 100755
index 00000000..663b4ddb
--- /dev/null
+++ b/scripts/fs-label-test.sh
@@ -0,0 +1,76 @@
+#!/bin/sh
+# fs-label-test.sh — `takana fs-label` contra un ext4 DE VERDAD.
+#
+# ── POR QUÉ NO ALCANZAN LOS TESTS DE UNIDAD ────────────────────────────────────────────────────
+# Los de `fs_label.rs` trabajan sobre un superbloque sintético, y por eso no traían `metadata_csum`
+# — el flag que obliga a recalcular el crc32c. Con el cálculo mal, esos tests seguían verdes y
+# `e2fsck` decía «Superblock checksum does not match» sobre un filesystem perfecto. Medido el
+# 2026-10-02: el error era el complemento final del crc, que la convención de «crc32c de una
+# cadena» lleva y ext4 no.
+#
+# Acá se arma un ext4 real, se le cambia la etiqueta y se le pregunta a `e2fsck`. Es la única forma
+# de saber que el checksum quedó bien, porque el único que lo valida es quien lo valida de verdad.
+#
+#   sh scripts/fs-label-test.sh
+set -eu
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
+for h in mke2fs e2fsck dumpe2fs; do
+    command -v "$h" >/dev/null 2>&1 || { echo "falta $h (e2fsprogs): no puedo probar esto" >&2; exit 2; }
+done
+TAKANA="${TAKANA:-$ROOT/target/release/takana}"
+[ -x "$TAKANA" ] || TAKANA="$ROOT/target/debug/takana"
+[ -x "$TAKANA" ] || { echo "no hay binario takana (cargo build -p takana-cli)" >&2; exit 2; }
+
+T="${TMPDIR:-/var/tmp}/fs-label-test.$$"; rm -rf "$T"; mkdir -p "$T/arbol/etc"
+trap 'rm -rf "$T"' EXIT
+echo contenido > "$T/arbol/etc/algo"
+OK=0; MAL=0
+ok()  { OK=$((OK+1));  printf '  ✓ %s\n' "$1"; }
+mal() { MAL=$((MAL+1)); printf '  ✗ %s\n' "$1"; }
+
+armar() { rm -f "$T/fs.img"; truncate -s 64M "$T/fs.img"
+          mke2fs -q -t ext4 -L "antes" -d "$T/arbol" "$T/fs.img"; }
+# ⚠ El rótulo es «Filesystem volume name», no «Volume name»: con el patrón corto el lector devolvía
+# vacío SIEMPRE y el banco acusaba al código por no haber sabido mirar. Por eso el caso 3 empieza
+# leyendo la etiqueta vieja — si el lector no la ve, el que está roto es el lector.
+etiqueta() { dumpe2fs -h "$T/fs.img" 2>/dev/null | sed -n 's/^Filesystem volume name: *//p'; }
+fsck_limpio() { e2fsck -fn "$T/fs.img" 2>&1 | grep -qi "checksum does not match" && return 1 || return 0; }
+
+echo "== 1. el ext4 de prueba trae metadata_csum (si no, este banco no prueba nada) =="
+armar
+if dumpe2fs -h "$T/fs.img" 2>/dev/null | grep -q "metadata_csum"; then
+    ok "trae metadata_csum: el checksum del superbloque está en juego"
+else
+    mal "este mke2fs NO pone metadata_csum: el banco no puede ver el defecto que busca"
+fi
+
+echo "== 2. el brazo de control: antes de tocar nada, e2fsck está contento =="
+fsck_limpio && ok "el filesystem recién hecho pasa e2fsck" || mal "ya venía roto: el banco no sirve"
+
+echo "== 3. la etiqueta se cambia =="
+# El control del instrumento, antes de usarlo: si no sabe leer la etiqueta que mke2fs acaba de
+# poner, cualquier veredicto suyo sobre la nueva no significa nada.
+[ "$(etiqueta)" = "antes" ] && ok "control: el lector ve la etiqueta que puso mke2fs" \
+    || mal "control: el lector NO ve la etiqueta original (leyó «$(etiqueta)»): el banco es ciego"
+"$TAKANA" fs-label "$T/fs.img" hammer-root >/dev/null
+[ "$(etiqueta)" = "hammer-root" ] && ok "dumpe2fs lee «hammer-root»" || mal "la etiqueta quedó en «$(etiqueta)»"
+
+echo "== 4. y el superbloque SIGUE VÁLIDO =="
+# Ésta es la comprobación entera. Sin ella, el banco diría que todo anda con un filesystem que el
+# kernel monta de sólo lectura.
+fsck_limpio && ok "e2fsck no se queja del checksum" \
+    || mal "e2fsck: $(e2fsck -fn "$T/fs.img" 2>&1 | grep -i checksum | head -1)"
+
+echo "== 5. los datos no se tocaron =="
+debugfs -R "ls /etc" "$T/fs.img" 2>/dev/null | grep -q algo \
+    && ok "el contenido sigue ahí" || mal "se perdió el contenido"
+
+echo "== 6. donde no hay superbloque, no se escribe =="
+rm -f "$T/basura.img"; head -c 4096 /dev/zero > "$T/basura.img"
+ANTES=$(sha256sum "$T/basura.img" | cut -d' ' -f1)
+"$TAKANA" fs-label "$T/basura.img" hammer-root >/dev/null 2>&1 && mal "aceptó un fichero sin ext" || ok "rechaza lo que no es ext"
+[ "$(sha256sum "$T/basura.img" | cut -d' ' -f1)" = "$ANTES" ] && ok "y no lo tocó" || mal "escribió igual"
+
+echo
+printf '%d bien · %d mal\n' "$OK" "$MAL"
+[ "$MAL" = "0" ] || exit 1
diff --git a/scripts/lib/adoptar.sh b/scripts/lib/adoptar.sh
index c08a6017..60788ab6 100755
--- a/scripts/lib/adoptar.sh
+++ b/scripts/lib/adoptar.sh
@@ -162,13 +162,26 @@ adopt_mudar() {
     adopt_desmontar
     # 5) La etiqueta, que es por donde el arranque encuentra la raíz. Sin esto la partición
     #    adoptada tiene el sistema nuevo adentro y nadie la reconoce al bootear.
+    # El initramfs de pivote encuentra la raíz con `findfs LABEL=hammer-root`: sin la etiqueta, el
+    # sistema está adentro y **nadie lo encuentra al arrancar**. Tres caminos, en orden de
+    # preferencia, porque ninguno está siempre:
     if [ -d "$DEST" ]; then
         return 0   # modo de prueba: un directorio no tiene etiqueta de filesystem que poner
     elif [ -n "${E2LABEL:-}" ] && [ -x "$E2LABEL" ]; then
         "$E2LABEL" "$DEST" hammer-root || {
             echo "takana-install-partition: no pude ponerle LABEL=hammer-root a $DEST." >&2; exit 1; }
+    elif command -v takana >/dev/null 2>&1; then
+        # Dentro del initramfs no viaja e2fsprogs —busybox no trae `e2label` ni `tune2fs`— pero el
+        # binario `takana` sí. Escribe los 16 bytes de la etiqueta y recalcula el crc32c del
+        # superbloque si el filesystem trae `metadata_csum`; sin ese recálculo el kernel lo monta
+        # de sólo lectura, y el síntoma no menciona la etiqueta por ningún lado.
+        takana fs-label "$DEST" hammer-root || {
+            echo "takana-install-partition: no pude ponerle LABEL=hammer-root a $DEST." >&2; exit 1; }
     else
-        echo "takana-install-partition: hace falta e2label para --adopt y no lo encontré." >&2; exit 1
+        echo "takana-install-partition: para --adopt hace falta e2label o el binario takana, y no" >&2
+        echo "                          encontré ninguno. El sistema anterior sigue entero en" >&2
+        echo "                          /$ADOPT_SUB y el nuevo está copiado: falta sólo la etiqueta." >&2
+        exit 1
     fi
 }
 
diff --git a/scripts/reparchar-paquete-instalador.sh b/scripts/reparchar-paquete-instalador.sh
new file mode 100755
index 00000000..9d071f26
--- /dev/null
+++ b/scripts/reparchar-paquete-instalador.sh
@@ -0,0 +1,96 @@
+#!/bin/sh
+# reparchar-paquete-instalador.sh — poner los GUIONES del repo dentro de un paquete ya armado.
+#
+# ── POR QUÉ EXISTE, Y QUÉ NO ES ────────────────────────────────────────────────────────────────
+# `instalador-paquete.sh` arma el paquete entero, y para eso necesita el `product-rootfs` y el
+# `linux-metal` del store. En una máquina que no tiene store —la de desarrollo— ese camino no corre,
+# y entonces un cambio en los guiones no se puede probar en metal: el paquete sigue llevando los de
+# la víspera.
+#
+# Esto hace lo único que falta en ese caso: **reemplazar los guiones**, que son texto y no dependen
+# de ningún artefacto sellado. El rootfs y el kernel del paquete quedan exactamente como estaban —
+# no se tocan, no se rearman, y por eso esto NO reemplaza a `instalador-paquete.sh`.
+#
+# Y lleva la misma comprobación que aquél, por el mismo motivo: el paquete se armó a mano dos veces
+# y a la segunda ya mentía —por fuera el script corregido, por dentro el de la víspera—. Acá, al
+# terminar, se extrae de nuevo lo que quedó adentro y se compara por sha256 contra el repo. Un
+# paquete que dice llevar el arreglo y no lo lleva es peor que uno que no lo lleva.
+#
+# Uso:  scripts/reparchar-paquete-instalador.sh <directorio-del-paquete>
+set -eu
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+PAQ="${1:?uso: $0 <directorio del paquete>}"
+CPIO="$PAQ/takana-instalador.cpio.gz"
+[ -r "$CPIO" ] || { echo "no encuentro $CPIO" >&2; exit 1; }
+
+T="${TMPDIR:-/var/tmp}/reparchar-paquete.$$"; rm -rf "$T"; mkdir -p "$T/x"
+limpiar() { rm -rf "$T"; }
+trap limpiar EXIT
+
+echo "==> abriendo $CPIO"
+( cd "$T/x" && zcat "$CPIO" | cpio -idm --quiet )
+
+# Los guiones que viajan adentro, con su sitio. El `init` del initramfs ES `instalador-initramfs.sh`.
+copiar() {  # <origen-en-el-repo> <destino-dentro-del-cpio>
+    [ -r "$ROOT/$1" ] || { echo "falta $ROOT/$1" >&2; exit 1; }
+    mkdir -p "$(dirname "$T/x/$2")"
+    cp "$ROOT/$1" "$T/x/$2"; chmod 755 "$T/x/$2"
+    echo "    · $1 → /$2"
+}
+copiar scripts/instalador-initramfs.sh            init
+copiar scripts/takana-install-partition.sh        instalador/takana-install-partition.sh
+copiar scripts/lib/adoptar.sh                     instalador/lib/adoptar.sh
+copiar scripts/lib/usuarios-del-home.sh           instalador/lib/usuarios-del-home.sh
+copiar scripts/lib/herramientas-desde-store.sh    instalador/lib/herramientas-desde-store.sh
+# **Y el binario, que adentro del initramfs es quien pone la etiqueta** (`takana fs-label`): sin
+# e2fsprogs ahí, es el único que puede, y un paquete con el guion nuevo y el binario viejo falla
+# justo al final — con el sistema anterior ya mudado y el nuevo ya copiado.
+if [ -x "$ROOT/target/x86_64-unknown-linux-musl/release/takana" ]; then
+    cp "$ROOT/target/x86_64-unknown-linux-musl/release/takana" "$T/x/bin/takana"
+    chmod 755 "$T/x/bin/takana"
+    echo "    · target/x86_64-unknown-linux-musl/release/takana → /bin/takana"
+else
+    echo "    ⚠ no hay takana musl: el de adentro queda como estaba (sin fs-label no se adopta)" >&2
+fi
+# Y los applets que la adopción usa y no estaban enlazados: `df` mide el espacio (la guarda que
+# evita mover un sistema a un sitio donde el nuevo no entra) y `date` sella la marca de la mudanza.
+for _a in df date; do
+    [ -e "$T/x/bin/$_a" ] || { ln -sf busybox "$T/x/bin/$_a"; echo "    · /bin/$_a ⇒ busybox"; }
+done
+# Y el de afuera, que es el que mira quien abre el paquete. Que los dos sean el mismo fichero es la
+# mitad del asunto: la copia de afuera es la que se lee y la de adentro la que corre.
+cp "$ROOT/scripts/takana-install-partition.sh" "$PAQ/takana-install-partition.sh"
+mkdir -p "$PAQ/lib"
+cp "$ROOT/scripts/lib/adoptar.sh" "$ROOT/scripts/lib/usuarios-del-home.sh" \
+   "$ROOT/scripts/lib/herramientas-desde-store.sh" "$PAQ/lib/"
+
+echo "==> cerrando el paquete"
+( cd "$T/x" && find . | cpio -o -H newc --quiet | gzip -9 ) > "$T/nuevo.cpio.gz"
+mv "$T/nuevo.cpio.gz" "$CPIO"
+
+# ── LA COMPROBACIÓN: lo que quedó ADENTRO es lo del repo ───────────────────────────────────────
+echo "==> comprobando lo que quedó adentro"
+rm -rf "$T/v"; mkdir -p "$T/v"
+# Se extrae ENTERO y no por patrones: la extracción selectiva de cpio salió vacía y el banco lo
+# leyó como «el archivo no es el del repo», que es el diagnóstico contrario al verdadero. Una
+# comprobación que falla cuando no puede mirar está bien; una que falla por mirar mal, no.
+( cd "$T/v" && zcat "$CPIO" | cpio -idm --quiet )
+MAL=0
+comparar() {  # <en-el-repo> <extraído>
+    _a=$(sha256sum "$ROOT/$1" | cut -d' ' -f1)
+    _b=$(sha256sum "$T/v/$2" 2>/dev/null | cut -d' ' -f1 || echo "AUSENTE")
+    if [ "$_a" = "$_b" ]; then echo "    ✓ $2"
+    else echo "    ✗ $2 NO es el del repo ($_b)"; MAL=$((MAL+1)); fi
+}
+comparar scripts/instalador-initramfs.sh     init
+comparar scripts/takana-install-partition.sh instalador/takana-install-partition.sh
+comparar scripts/lib/adoptar.sh              instalador/lib/adoptar.sh
+comparar scripts/lib/usuarios-del-home.sh    instalador/lib/usuarios-del-home.sh
+if [ -x "$ROOT/target/x86_64-unknown-linux-musl/release/takana" ]; then
+    comparar target/x86_64-unknown-linux-musl/release/takana bin/takana
+fi
+for _a in df date; do
+    [ -e "$T/v/bin/$_a" ] && echo "    ✓ bin/$_a" || { echo "    ✗ falta bin/$_a"; MAL=$((MAL+1)); }
+done
+[ "$MAL" = "0" ] || { echo "✗ el paquete NO quedó con los guiones del repo" >&2; exit 1; }
+echo "✓ $PAQ al día ($(du -h "$CPIO" | cut -f1)) — el rootfs y el kernel quedaron intactos"