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…
⎇ 3d6d25661cc690e2779a7e873089691493f2c539
crates/hammer-bootstrap/src/lib.rs
Historia
Quién tocó qué
crudo
Track posterior: Stage 0 del bootstrap (crate hammer-bootstrap) + ADR 0008
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}