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 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ ba4d520c44ef442f0c2cb29c3fb578fd9e5e3b7d crates/hammer-bootstrap/src/lib.rs Rust · 312 siq'ikuna · 11.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

Bootstrap: manifiesto versionado (bootstrap.json) + Stage 0 anota su línea ba4d520c4 · 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//!
12//! Cada etapa anota su línea en el [`manifest`] de bootstrap (`bootstrap.json`), el embrión del
13//! log de transparencia ([SDD 11 §4](../../docs/11-bootstrap.md)).
14
15pub mod manifest;
16
17use std::path::Path;
18use std::process::Command;
19
20use hammer_build::download;
21use hammer_core::{ArtifactHash, Store};
22
23pub use manifest::{BootstrapManifest, StageEntry};
24
25#[derive(Debug, thiserror::Error)]
26pub enum Error {
27    #[error("io: {0}")]
28    Io(#[from] std::io::Error),
29    #[error("core: {0}")]
30    Core(#[from] hammer_core::Error),
31    #[error("tar -x de la semilla falló (exit {0:?})")]
32    Untar(Option<i32>),
33    #[error("bootstrap: {0}")]
34    Other(String),
35}
36
37pub type Result<T> = std::result::Result<T, Error>;
38
39/// Qué toolchain semilla usamos. `zig` es la semilla primaria (ADR 0003/0007); `musl-cross-make`
40/// (gcc + musl) es la escotilla para paquetes con gcc-ismos.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum SeedKind {
44    Zig,
45    MuslCrossMake,
46}
47
48impl SeedKind {
49    pub fn as_str(self) -> &'static str {
50        match self {
51            SeedKind::Zig => "zig",
52            SeedKind::MuslCrossMake => "musl-cross-make",
53        }
54    }
55}
56
57/// Especificación de una semilla: su identidad pinned. La integridad la garantiza `sha256`
58/// (fijado, [ADR 0006](../../docs/adr/0006-pinned-commits.md)); `url` puede ser cualquier
59/// esquema que entienda curl, incluido `file://` para ingestión offline.
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61pub struct SeedSpec {
62    pub kind: SeedKind,
63    pub version: String,
64    pub url: String,
65    /// sha256 hex del tarball, fijado. Se verifica **antes** de extraer y sellar.
66    pub sha256: String,
67}
68
69impl SeedSpec {
70    /// Hash del artefacto del store para esta semilla. Función pura de la identidad pinned:
71    /// misma `(kind, version, sha256)` ⇒ mismo hash, en cualquier máquina. El `url` queda
72    /// fuera a propósito (un espejo distinto del mismo tarball produce el mismo artefacto).
73    pub fn seed_hash(&self) -> ArtifactHash {
74        ArtifactHash::of_inputs(&[
75            b"hammer-seed-v1",
76            self.kind.as_str().as_bytes(),
77            self.version.as_bytes(),
78            self.sha256.as_bytes(),
79        ])
80    }
81
82    /// Nombre del artefacto en el store: `seed-<kind>`.
83    pub fn store_name(&self) -> String {
84        format!("seed-{}", self.kind.as_str())
85    }
86}
87
88/// **Stage 0** — ingiere la semilla al store y devuelve su `ArtifactHash`.
89///
90/// Idempotente: si la semilla ya está sellada (mismo hash de identidad), no la vuelve a
91/// descargar. El sha256 se verifica **antes** de extraer, así que un tarball envenenado nunca
92/// llega al árbol que se sella.
93pub fn stage0(seed: &SeedSpec, store: &Store) -> Result<ArtifactHash> {
94    let h = seed.seed_hash();
95    let name = seed.store_name();
96    if store.has(&h, &name) {
97        tracing::info!(hash = %h, seed = %seed.kind.as_str(), "stage0: semilla ya sellada (idempotente)");
98    } else {
99        // Staging bajo el root del store ⇒ mismo filesystem que el destino, para que `seal` pueda
100        // hacer el rename atómico.
101        let work = store.root().join(".bootstrap-tmp").join(h.store_dir_name(&name));
102        let _ = std::fs::remove_dir_all(&work);
103        std::fs::create_dir_all(&work)?;
104
105        // El cuerpo fallible va aparte para limpiar el staging pase lo que pase (un sha erróneo
106        // no debe dejar el tarball a medio descargar bajo el store). Si falla, salimos antes de
107        // anotar el manifiesto: una semilla que no se selló no genera línea.
108        let result = stage0_into(seed, store, &h, &name, &work);
109        let _ = std::fs::remove_dir_all(&work);
110        result?;
111    }
112
113    // La semilla es a la vez insumo y producto de Stage 0: `artifact_hash == seed_hash`, sin
114    // receta (se ingiere, no se compila). Idempotente en disco: re-correr no duplica la línea.
115    manifest::append_line(
116        store,
117        StageEntry {
118            stage: 0,
119            recipe_hash: None,
120            artifact_hash: h.clone(),
121            seed_hash: Some(h.clone()),
122            ts: now_unix(),
123        },
124    )?;
125    Ok(h)
126}
127
128/// Segundos Unix actuales para el `ts` del manifiesto. Best-effort: un reloj anterior a la época
129/// (imposible en la práctica) cae a 0. No entra en ningún hash, así que su precisión no afecta a
130/// la reproducibilidad.
131fn now_unix() -> u64 {
132    std::time::SystemTime::now()
133        .duration_since(std::time::UNIX_EPOCH)
134        .map(|d| d.as_secs())
135        .unwrap_or(0)
136}
137
138fn stage0_into(
139    seed: &SeedSpec,
140    store: &Store,
141    h: &ArtifactHash,
142    name: &str,
143    work: &Path,
144) -> Result<ArtifactHash> {
145    let tarball = work.join("seed.tar");
146    download::fetch_url_to_file(&seed.url, &tarball)?;
147    // Integridad antes de tocar nada: hash erróneo ⇒ no se extrae ni se sella.
148    download::verify_sha256(&tarball, &seed.sha256)?;
149
150    let tree = work.join("tree");
151    std::fs::create_dir_all(&tree)?;
152    untar(&tarball, &tree)?;
153
154    let sealed = store.seal(&tree, h, name)?;
155    tracing::info!(path = %sealed.display(), hash = %h, "stage0: semilla sellada");
156    Ok(h.clone())
157}
158
159/// Extrae `tarball` dentro de `into`. `tar -xf` autodetecta gzip/xz/zstd según lo instalado.
160fn untar(tarball: &Path, into: &Path) -> Result<()> {
161    let st = Command::new("tar")
162        .arg("-xf")
163        .arg(tarball)
164        .arg("-C")
165        .arg(into)
166        .status()
167        .map_err(|e| Error::Other(format!("spawn tar: {e}")))?;
168    if !st.success() {
169        return Err(Error::Untar(st.code()));
170    }
171    Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn spec(url: &str, sha256: &str) -> SeedSpec {
179        SeedSpec {
180            kind: SeedKind::Zig,
181            version: "0.13.0".into(),
182            url: url.into(),
183            sha256: sha256.into(),
184        }
185    }
186
187    fn sha256_hex(bytes: &[u8]) -> String {
188        use sha2::{Digest, Sha256};
189        let mut h = Sha256::new();
190        h.update(bytes);
191        hex::encode(h.finalize())
192    }
193
194    /// Crea un tarball con un archivo `bin/zig` dentro y devuelve `(url file://, sha256 hex)`.
195    fn make_seed_tarball(dir: &Path) -> (String, String) {
196        let src = dir.join("src");
197        std::fs::create_dir_all(src.join("bin")).unwrap();
198        std::fs::write(src.join("bin/zig"), b"#!/bin/sh\necho fake zig\n").unwrap();
199        let tarball = dir.join("seed.tar.gz");
200        let st = Command::new("tar")
201            .arg("-czf")
202            .arg(&tarball)
203            .arg("-C")
204            .arg(&src)
205            .arg(".")
206            .status()
207            .unwrap();
208        assert!(st.success(), "tar -c falló");
209        let sha = sha256_hex(&std::fs::read(&tarball).unwrap());
210        (format!("file://{}", tarball.display()), sha)
211    }
212
213    #[test]
214    fn seed_hash_is_identity_based() {
215        let a = spec("file:///mirror-a.tar", "abc123");
216        let b = spec("file:///mirror-b.tar", "abc123"); // mismo sha, distinto espejo
217        assert_eq!(a.seed_hash(), b.seed_hash(), "el url no entra al hash");
218
219        let c = spec("file:///mirror-a.tar", "deadbeef"); // sha distinto
220        assert_ne!(a.seed_hash(), c.seed_hash(), "el sha256 sí cambia el hash");
221
222        let mut d = spec("file:///mirror-a.tar", "abc123");
223        d.kind = SeedKind::MuslCrossMake;
224        assert_ne!(a.seed_hash(), d.seed_hash(), "la clase cambia el hash");
225    }
226
227    #[test]
228    fn stage0_ingests_and_seals() {
229        let tmp = tempfile::tempdir().unwrap();
230        let (url, sha) = make_seed_tarball(tmp.path());
231        let store = Store::open(tmp.path().join("store")).unwrap();
232        let seed = spec(&url, &sha);
233
234        let h = stage0(&seed, &store).expect("stage0");
235        assert_eq!(h, seed.seed_hash());
236        assert!(store.has(&h, &seed.store_name()), "la semilla debe quedar sellada");
237
238        // El contenido extraído está presente y de sólo-lectura (sellado).
239        let zig = store.path_of(&h, &seed.store_name()).join("bin/zig");
240        assert!(zig.is_file(), "bin/zig debe existir en el artefacto");
241        use std::os::unix::fs::PermissionsExt;
242        let mode = std::fs::metadata(&zig).unwrap().permissions().mode();
243        assert_eq!(mode & 0o222, 0, "el árbol sellado no debe ser escribible");
244    }
245
246    #[test]
247    fn stage0_is_idempotent() {
248        let tmp = tempfile::tempdir().unwrap();
249        let (url, sha) = make_seed_tarball(tmp.path());
250        let store = Store::open(tmp.path().join("store")).unwrap();
251        let seed = spec(&url, &sha);
252
253        let h1 = stage0(&seed, &store).unwrap();
254        let h2 = stage0(&seed, &store).unwrap(); // segunda vez: short-circuit
255        assert_eq!(h1, h2);
256    }
257
258    #[test]
259    fn stage0_records_one_manifest_line() {
260        let tmp = tempfile::tempdir().unwrap();
261        let (url, sha) = make_seed_tarball(tmp.path());
262        let store = Store::open(tmp.path().join("store")).unwrap();
263        let seed = spec(&url, &sha);
264
265        let h = stage0(&seed, &store).unwrap();
266        stage0(&seed, &store).unwrap(); // re-correr no debe duplicar la línea
267
268        let m = BootstrapManifest::load(&store).unwrap();
269        assert_eq!(m.entries.len(), 1, "una sola línea para Stage 0");
270        let e = &m.entries[0];
271        assert_eq!(e.stage, 0);
272        assert_eq!(e.recipe_hash, None, "la semilla se ingiere, no se compila");
273        assert_eq!(e.artifact_hash, h);
274        assert_eq!(e.seed_hash, Some(h), "en Stage 0 la semilla es su propio artefacto");
275    }
276
277    #[test]
278    fn stage0_bad_sha_records_no_manifest_line() {
279        let tmp = tempfile::tempdir().unwrap();
280        let (url, _real_sha) = make_seed_tarball(tmp.path());
281        let store = Store::open(tmp.path().join("store")).unwrap();
282        let seed = spec(&url, &"0".repeat(64));
283
284        assert!(stage0(&seed, &store).is_err());
285        // Un fallo de integridad no debe dejar ni artefacto ni línea de manifiesto.
286        assert!(!BootstrapManifest::path_for(&store).exists(), "no debe escribir bootstrap.json");
287    }
288
289    #[test]
290    fn stage0_rejects_bad_sha256_and_seals_nothing() {
291        let tmp = tempfile::tempdir().unwrap();
292        let (url, _real_sha) = make_seed_tarball(tmp.path());
293        let store = Store::open(tmp.path().join("store")).unwrap();
294        let seed = spec(&url, &"0".repeat(64)); // sha intencionalmente equivocado
295
296        let err = stage0(&seed, &store).unwrap_err();
297        assert!(
298            err.to_string().contains("sha256 mismatch"),
299            "esperaba mismatch de sha256, vino: {err}"
300        );
301        assert!(
302            !store.has(&seed.seed_hash(), &seed.store_name()),
303            "un sha erróneo no debe sellar nada"
304        );
305        // Tampoco debe dejar staging a medio descargar bajo el store.
306        assert!(
307            !store.root().join(".bootstrap-tmp").exists()
308                || std::fs::read_dir(store.root().join(".bootstrap-tmp")).unwrap().next().is_none(),
309            "el staging debe quedar limpio tras el fallo"
310        );
311    }
312}