tawasuyu

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 1543Shell 203Rust 109Markdown 993728 Definiciones

⎇ 3d6d25661cc690e2779a7e873089691493f2c539 crates/hammer-bootstrap/src/lib.rs Rust · 233 líneas · 8.5 KB Historia Quién tocó qué crudo

Track posterior: Stage 0 del bootstrap (crate hammer-bootstrap) + ADR 0008 3d6d25661 · Sergio ·

1//! Bootstrap from-scratch de hammer — el track posterior del [SDD 11](../../docs/11-bootstrap.md).
2//!
3//! Esta primera entrega cubre el **Stage 0**: ingerir un toolchain semilla ya construido
4//! (no se compila; ver [ADR 0008](../../docs/adr/0008-bootstrap-stages.md)) como una *fuente
5//! fijada por sha256* y sellarlo en el `Store`. El `ArtifactHash` resultante se deriva de la
6//! **identidad pinned** de la semilla (clase + versión + sha256), no de rutas del host, así
7//! que el resto del bootstrap depende de un artefacto reproducible y auditable.
8//!
9//! Stage 1 (userland mínimo cross-compilado) y Stage 2 (rebuild nativo + diff de hashes)
10//! llegan en entregas siguientes y reusan este mismo store.
11
12use std::path::Path;
13use std::process::Command;
14
15use hammer_build::download;
16use hammer_core::{ArtifactHash, Store};
17
18#[derive(Debug, thiserror::Error)]
19pub enum Error {
20    #[error("io: {0}")]
21    Io(#[from] std::io::Error),
22    #[error("core: {0}")]
23    Core(#[from] hammer_core::Error),
24    #[error("tar -x de la semilla falló (exit {0:?})")]
25    Untar(Option<i32>),
26    #[error("bootstrap: {0}")]
27    Other(String),
28}
29
30pub type Result<T> = std::result::Result<T, Error>;
31
32/// Qué toolchain semilla usamos. `zig` es la semilla primaria (ADR 0003/0007); `musl-cross-make`
33/// (gcc + musl) es la escotilla para paquetes con gcc-ismos.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum SeedKind {
37    Zig,
38    MuslCrossMake,
39}
40
41impl SeedKind {
42    pub fn as_str(self) -> &'static str {
43        match self {
44            SeedKind::Zig => "zig",
45            SeedKind::MuslCrossMake => "musl-cross-make",
46        }
47    }
48}
49
50/// Especificación de una semilla: su identidad pinned. La integridad la garantiza `sha256`
51/// (fijado, [ADR 0006](../../docs/adr/0006-pinned-commits.md)); `url` puede ser cualquier
52/// esquema que entienda curl, incluido `file://` para ingestión offline.
53#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
54pub struct SeedSpec {
55    pub kind: SeedKind,
56    pub version: String,
57    pub url: String,
58    /// sha256 hex del tarball, fijado. Se verifica **antes** de extraer y sellar.
59    pub sha256: String,
60}
61
62impl SeedSpec {
63    /// Hash del artefacto del store para esta semilla. Función pura de la identidad pinned:
64    /// misma `(kind, version, sha256)` ⇒ mismo hash, en cualquier máquina. El `url` queda
65    /// fuera a propósito (un espejo distinto del mismo tarball produce el mismo artefacto).
66    pub fn seed_hash(&self) -> ArtifactHash {
67        ArtifactHash::of_inputs(&[
68            b"hammer-seed-v1",
69            self.kind.as_str().as_bytes(),
70            self.version.as_bytes(),
71            self.sha256.as_bytes(),
72        ])
73    }
74
75    /// Nombre del artefacto en el store: `seed-<kind>`.
76    pub fn store_name(&self) -> String {
77        format!("seed-{}", self.kind.as_str())
78    }
79}
80
81/// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`.
82///
83/// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a
84/// descargar. El sha256 se verifica **antes** de extraer, así que un tarball envenenado nunca
85/// llega al árbol que se sella.
86pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
87    let h = seed.seed_hash();
88    let name = seed.store_name();
89    if store.has(&h, &name) {
90        tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)");
91        return Ok(h);
92    }
93
94    // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda
95    // hacer el rename atómico.
96    let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name));
97    let _ = std::fs::remove_dir_all(&work);
98    std::fs::create_dir_all(&work)?;
99
100    let tarball = work.join("seed.tar");
101    download::fetch_url_to_file(&seed.url, &tarball)?;
102    // Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella.
103    download::verify_sha256(&tarball, &seed.sha256)?;
104
105    let tree = work.join("tree");
106    std::fs::create_dir_all(&tree)?;
107    untar(&tarball, &tree)?;
108
109    let sealed = store.seal(&tree, &h, &name)?;
110    tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada");
111
112    // Limpieza del staging (el árbol ya se movió por rename; queda el tarball + el dir padre).
113    let _ = std::fs::remove_dir_all(&work);
114    Ok(h)
115}
116
117/// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado.
118fn untar(tarball: &Path, into: &Path) -> Result<()> {
119    let st = Command::new("tar")
120        .arg("-xf")
121        .arg(tarball)
122        .arg("-C")
123        .arg(into)
124        .status()
125        .map_err(|e| Error::Other(format!("spawn tar: {e}")))?;
126    if !st.success() {
127        return Err(Error::Untar(st.code()));
128    }
129    Ok(())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn spec(url: &str, sha256: &str) -> SeedSpec {
137        SeedSpec {
138            kind: SeedKind::Zig,
139            version: "0.13.0".into(),
140            url: url.into(),
141            sha256: sha256.into(),
142        }
143    }
144
145    fn sha256_hex(bytes: &[u8]) -> String {
146        use sha2::{Digest, Sha256};
147        let mut h = Sha256::new();
148        h.update(bytes);
149        hex::encode(h.finalize())
150    }
151
152    /// Crea un tarball con un archivo `bin/zig` dentro y devuelve `(url file://, sha256 hex)`.
153    fn make_seed_tarball(dir: &Path) -> (String, String) {
154        let src = dir.join("src");
155        std::fs::create_dir_all(src.join("bin")).unwrap();
156        std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap();
157        let tarball = dir.join("seed.tar.gz");
158        let st = Command::new("tar")
159            .arg("-czf")
160            .arg(&tarball)
161            .arg("-C")
162            .arg(&src)
163            .arg(".")
164            .status()
165            .unwrap();
166        assert!(st.success(), "tar -c falló");
167        let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
168        (format!("file://{}", tarball.display()), sha)
169    }
170
171    #[test]
172    fn seed_hash_is_identity_based() {
173        let a = spec("file:///mirror-a.tar", "abc123");
174        let b = spec("file:///mirror-b.tar", "abc123"); // mismo sha, distinto espejo
175        assert_eq!(a.seed_hash(), b.seed_hash(), "el url no entra al hash");
176
177        let c = spec("file:///mirror-a.tar", "deadbeef"); // sha distinto
178        assert_ne!(a.seed_hash(), c.seed_hash(), "el sha256 sí cambia el hash");
179
180        let mut d = spec("file:///mirror-a.tar", "abc123");
181        d.kind = SeedKind::MuslCrossMake;
182        assert_ne!(a.seed_hash(), d.seed_hash(), "la clase cambia el hash");
183    }
184
185    #[test]
186    fn stage0_ingests_and_seals() {
187        let tmp = tempfile::tempdir().unwrap();
188        let (url, sha) = make_seed_tarball(tmp.path());
189        let store = Store::open(tmp.path().join("store")).unwrap();
190        let seed = spec(&url, &sha);
191
192        let h = stage0(&seed, &store).expect("stage0");
193        assert_eq!(h, seed.seed_hash());
194        assert!(store.has(&h, &seed.store_name()), "la semilla debe quedar sellada");
195
196        // El contenido extraído está presente y de sólo-lectura (sellado).
197        let zig = store.path_of(&h, &seed.store_name()).join("bin/zig");
198        assert!(zig.is_file(), "bin/zig debe existir en el artefacto");
199        use std::os::unix::fs::PermissionsExt;
200        let mode = std::fs::metadata(&zig).unwrap().permissions().mode();
201        assert_eq!(mode & 0o222, 0, "el árbol sellado no debe ser escribible");
202    }
203
204    #[test]
205    fn stage0_is_idempotent() {
206        let tmp = tempfile::tempdir().unwrap();
207        let (url, sha) = make_seed_tarball(tmp.path());
208        let store = Store::open(tmp.path().join("store")).unwrap();
209        let seed = spec(&url, &sha);
210
211        let h1 = stage0(&seed, &store).unwrap();
212        let h2 = stage0(&seed, &store).unwrap(); // segunda vez: short-circuit
213        assert_eq!(h1, h2);
214    }
215
216    #[test]
217    fn stage0_rejects_bad_sha256_and_seals_nothing() {
218        let tmp = tempfile::tempdir().unwrap();
219        let (url, _real_sha) = make_seed_tarball(tmp.path());
220        let store = Store::open(tmp.path().join("store")).unwrap();
221        let seed = spec(&url, &"0".repeat(64)); // sha intencionalmente equivocado
222
223        let err = stage0(&seed, &store).unwrap_err();
224        assert!(
225            err.to_string().contains("sha256 mismatch"),
226            "esperaba mismatch de sha256, vino: {err}"
227        );
228        assert!(
229            !store.has(&seed.seed_hash(), &seed.store_name()),
230            "un sha erróneo no debe sellar nada"
231        );
232    }
233}