diff --git a/crates/takana-build/src/lib.rs b/crates/takana-build/src/lib.rs index afb8c080..8802c535 100644 --- a/crates/takana-build/src/lib.rs +++ b/crates/takana-build/src/lib.rs @@ -970,7 +970,15 @@ fn resolve_phases(recipe: &Recipe, src: &Path) -> takana_core::Result { // (Go puro estático). Validado: usql (sqlite, estático y corre). let (cgo_env, ldflags) = if recipe.build.cgo { ( - "CGO_ENABLED=1 CC=\"zig cc\" CXX=\"zig c++\"", + // ⚠ `-mcpu=baseline` NO es opcional y este sitio lo perdía: el sandbox + // exporta `CC` apuntando a un wrapper que ya lo pone (ver `sandbox.rs`), y + // esta línea lo PISABA con `zig cc` a secas — que por defecto es + // `-mcpu=native` y hornea la ISA del builder. El síntoma no aparece al + // compilar ni al sellar: aparece al EJECUTAR en otra CPU, como + // `trap invalid opcode` (SIGILL). Medido 2026-09-14 con `gitea` dentro de + // QEMU-TCG: el binario construido en el worker moría al arrancar, y el + // mismo con baseline corre. Afecta a toda receta `cgo = true`. + "CGO_ENABLED=1 CC=\"zig cc -mcpu=baseline\" CXX=\"zig c++ -mcpu=baseline\"", "-ldflags=\"-buildid= -linkmode=external -extldflags=-static\"", ) } else { diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs index da418808..cf7da82e 100644 --- a/crates/takana-cli/src/main.rs +++ b/crates/takana-cli/src/main.rs @@ -1046,7 +1046,7 @@ fn main() -> anyhow::Result<()> { } for (rel, p, nuevo, anadidas) in plan { if !anadidas.is_empty() { - std::fs::write(&p, &nuevo)?; + takana_core::user::escribir_rompiendo_hardlink(&p, &nuevo)?; } eprintln!("{}: +{} cuenta(s)", rel, anadidas.len()); for a in &anadidas { diff --git a/crates/takana-core/src/user.rs b/crates/takana-core/src/user.rs index 3a46fa19..6008a087 100644 --- a/crates/takana-core/src/user.rs +++ b/crates/takana-core/src/user.rs @@ -213,6 +213,35 @@ pub fn fusionar(contenido: &str, lineas: &[String]) -> (String, Vec, Vec (out, anadidas, conflictos) } +/// Escribe `contenido` en `p` **rompiendo el hardlink**: temporal en el mismo directorio + `rename`. +/// +/// ⚠ Esto no es higiene, es lo único correcto, y se descubrió al armar la primera imagen: un rootfs +/// hidratado se arma con **hardlinks contra el store**, así que su `/etc/passwd` y el del artefacto +/// `product-rootfs` **son el mismo inode** (medido: mismo número de inode, `2 links`, modo `444`). +/// Un `fs::write` sobre él abre ESE inode en truncate y **modifica el artefacto sellado** — todas +/// las imágenes futuras saldrían con la cuenta metida dentro del producto, y el store mentiría +/// sobre lo que contiene su propio hash. Acá se salvó porque el store es de sólo lectura y el +/// primer intento murió con `Permission denied`; confiar en eso sería confiar en un permiso. +/// +/// `rename` sobre el mismo directorio es atómico y deja el fichero del store intacto con un link +/// menos. El modo sale 0644: el del store es 0444 y un `passwd` de sólo lectura rompe a cualquiera +/// que después añada una cuenta. +pub fn escribir_rompiendo_hardlink(p: &std::path::Path, contenido: &str) -> crate::Result<()> { + let dir = p.parent().unwrap_or(std::path::Path::new(".")); + let tmp = dir.join(format!( + ".{}.users-tmp", + p.file_name().and_then(|s| s.to_str()).unwrap_or("fichero") + )); + std::fs::write(&tmp, contenido)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))?; + } + std::fs::rename(&tmp, p)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -272,6 +301,42 @@ mod tests { assert!(validate_all(&[a.clone(), a]).unwrap_err().to_string().contains("name duplicado")); } + /// El control del fallo que casi envenena el store: escribir el `passwd` de un rootfs + /// hidratado NO puede tocar el fichero del artefacto con el que comparte inode. + #[test] + fn escribir_no_toca_el_fichero_hardlinkeado() { + use std::os::unix::fs::MetadataExt; + let dir = std::env::temp_dir().join(format!("takana-user-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("store")).unwrap(); + std::fs::create_dir_all(dir.join("rootfs")).unwrap(); + let en_store = dir.join("store/passwd"); + let en_rootfs = dir.join("rootfs/passwd"); + std::fs::write(&en_store, "root:x:0:0:root:/root:/bin/sh\n").unwrap(); + std::fs::hard_link(&en_store, &en_rootfs).unwrap(); + assert_eq!( + std::fs::metadata(&en_store).unwrap().ino(), + std::fs::metadata(&en_rootfs).unwrap().ino(), + "el montaje del test tiene que hardlinkear para que el control valga" + ); + + escribir_rompiendo_hardlink(&en_rootfs, "root:x:0:0:root:/root:/bin/sh\ngitea:x:916:916:gitea:/var/lib/gitea:/bin/false\n").unwrap(); + + assert_eq!( + std::fs::read_to_string(&en_store).unwrap(), + "root:x:0:0:root:/root:/bin/sh\n", + "SE MODIFICÓ EL ARTEFACTO DEL STORE" + ); + assert!(std::fs::read_to_string(&en_rootfs).unwrap().contains("gitea:")); + assert_ne!( + std::fs::metadata(&en_store).unwrap().ino(), + std::fs::metadata(&en_rootfs).unwrap().ino(), + "el hardlink no se rompió" + ); + assert_eq!(std::fs::metadata(&en_store).unwrap().nlink(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn fusionar_es_idempotente_y_no_pisa() { let base = "root:x:0:0:root:/root:/bin/sh\nsshd:x:74:74:sshd privsep:/var/empty:/bin/false\n"; diff --git a/recipes/gitea.toml b/recipes/gitea.toml index f8f7c578..aa1962ad 100644 --- a/recipes/gitea.toml +++ b/recipes/gitea.toml @@ -51,7 +51,7 @@ build = ["go"] configure = "true" compile = """ export GOCACHE=/src/.gocache GOTMPDIR=/src/.gotmp GOPATH=/tmp/gopath GOTOOLCHAIN=local \ - CGO_ENABLED=1 CC="zig cc" CXX="zig c++" GOFLAGS=-mod=vendor GOPROXY=off + CGO_ENABLED=1 CC="zig cc -mcpu=baseline" CXX="zig c++ -mcpu=baseline" GOFLAGS=-mod=vendor GOPROXY=off mkdir -p /src/.gotmp /out/usr/bin go build -trimpath -tags 'bindata sqlite sqlite_unlock_notify' \ -ldflags '-buildid= -linkmode=external -extldflags=-static -X "main.Version=1.27.0" -X "main.Tags=bindata sqlite sqlite_unlock_notify"' \ diff --git a/scripts/servidor-image.sh b/scripts/servidor-image.sh index 87d7c829..b44cfa2c 100755 --- a/scripts/servidor-image.sh +++ b/scripts/servidor-image.sh @@ -99,6 +99,26 @@ else echo "==> cuentas : ninguna receta del perfil declara [[user]]" fi +# ── 2 ter. las CARDS de los servicios que el perfil arranca (SDD 30 §4c) ───────────────────────── +# Sin esto la imagen trae el binario, trae la cuenta… y NADIE lo arranca: la seed del producto sólo +# conoce las cards que `takana-bootstrap` hornea (`sshd`, `console-getty`, `hammerd`). Medido en la +# primera imagen de este perfil: `gitea` instalado, su usuario en `/etc/passwd`, y en el `genesis` +# NADA — un servidor que arranca perfecto sin el servicio por el que existe. La métrica no lo ve: +# `--services` dice que el perfil lo habilita, y lo habilita; lo que faltaba era el paso que lleva +# esa declaración a la imagen. +# +# `service-cards` es la ÚNICA traducción `[[service]]` → Card, y `inyectar-cards.py` sólo compone la +# seed (idempotente por label, y con `os.replace` porque la seed es un hardlink al store). +SVCPATHS=$(python3 scripts/targets.py --service-paths "$PERFIL") +if [ -n "$SVCPATHS" ]; then + # shellcheck disable=SC2086 + "$TAKANA" --store "$STORE" service-cards $SVCPATHS > "$WORKDIR/cards.json" + echo "==> cards : $(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$WORKDIR/cards.json") card(s) de $(echo "$SVCPATHS" | wc -l) receta(s)" + python3 scripts/gnome/inyectar-cards.py "$RFS/ente/seed.card.json" "$WORKDIR/cards.json" +else + echo "==> cards : el perfil no arranca ningún servicio de paquete" +fi + # ── 3. reparar el init que la hidratación pisó (ver cabecera, punto 2) ─────────────────────────── antes=$(readlink "$RFS/sbin/init" 2>/dev/null || echo "(fichero)") ln -sf /usr/bin/arje-zero "$RFS/sbin/init"