//! `plan` — de una selección de bundles a una **receta derivada**. //! //! ## Por qué una receta y no un parámetro //! Las fases de build entran en `Recipe::hash_inputs` y el config del kernel vive en la fase //! `configure` ⇒ **el config ES la identidad del artefacto** (SDD 22 §1). No hay forma de tener un //! config variable sin generar recetas: una «perilla» de la UI no es un parámetro de runtime, es //! una edición de receta. Fingir lo contrario daría un store que dice que todo está al día mientras //! los artefactos ya no corresponden — en un sistema direccionado por contenido, el peor fallo //! posible no es fallar, es **mentir**. //! //! De ahí sale gratis lo que §7 del handoff pedía: el plan **determina** el artefacto, así que el //! JSON lleva su `ArtifactHash` y la UI puede decir «esto ya está construido y firmado» sin //! construir nada. //! //! ## Qué se emite y qué no //! Se emiten **raíces**, no clausuras. `-d WIRELESS -d WLAN` son dos banderas; los ~400 símbolos //! que caen con ellas los calcula el `olddefconfig` **del propio kernel**. hammer sabe cuáles son //! (para poder explicarlos), pero no los escribe: la app nunca escribe un `.config`. //! //! ## Procedencia por símbolo //! Cada símbolo emitido carga quién lo pidió ([`PlannedSymbol::from`]). Es el #7 del handoff, y con //! él el diff entre dos generaciones deja de ser «347 símbolos cambiaron» para ser «activaste //! bluetooth y entraron 12 símbolos». use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use super::catalog::{Catalog, Side}; use super::kconfig::KconfigTree; use crate::recipe::Recipe; /// Qué se le pide a `scripts/config` para un símbolo. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Action { Enable, Disable, } impl Action { fn flag(self) -> &'static str { match self { Action::Enable => "-e", Action::Disable => "-d", } } } /// Un símbolo emitido, con su procedencia. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlannedSymbol { pub symbol: String, pub action: Action, /// `bundle:sin-wifi`, `knob:tick-1000`. pub from: String, /// Por qué está: raíz declarada, o fuga `select` que hubo que cerrar. pub reason: String, } /// Una perilla que **no** cae del lado de Kconfig: el plan la declara y no la aplica. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecipeKnob { pub id: String, pub title: String, pub recipe_field: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Plan { pub base_recipe: String, pub base_name: String, pub derived_name: String, pub bundles: Vec, pub knobs: Vec, /// Símbolos que se emiten, con procedencia. pub symbols: Vec, /// **Diff-back estático**: pedidos que no existen en este árbol. Un `-d` sobre uno de éstos es /// un no-op que nadie ve. Le pasaba a `THUNDERBOLT` y a `REISERFS_FS` en las cuatro recetas de /// kernel del repo hasta que se corrigieron (`2602218`), que es cómo se descubrió el patrón. pub unknown_symbols: Vec, /// Perillas `side = "recipe"` seleccionadas: declaradas, no aplicadas. pub recipe_side: Vec, /// Cuántos símbolos apaga de verdad la selección, contando la clausura. Es informativo: quien /// la calcula de vuelta, y manda, es `olddefconfig`. pub closure_size: usize, /// La invocación de `scripts/config` que se inserta. pub fragment: String, /// La fase `configure` derivada, entera. pub configure: String, /// El `ArtifactHash` de la receta derivada. `None` si no se pudo calcular (deps sin resolver). #[serde(skip_serializing_if = "Option::is_none")] pub artifact_hash: Option, } /// Construye el plan. No toca el disco. pub fn make_plan( base: &Recipe, base_path: &str, cat: &Catalog, tree: &KconfigTree, bundle_ids: &[String], knob_ids: &[String], ) -> crate::Result { let mut wanted: BTreeMap = BTreeMap::new(); let mut disable_roots: Vec = Vec::new(); let push = |wanted: &mut BTreeMap, sym: &str, action: Action, from: String, reason: &str| -> crate::Result<()> { // Dos selecciones que se contradicen sobre el mismo símbolo no se resuelven por orden de // aparición: se rechazan. El orden sería una respuesta plausible y arbitraria. if let Some(prev) = wanted.get(sym) { if prev.action != action { return Err(crate::Error::Recipe(format!( "conflicto en {sym}: «{}» lo quiere {:?} y «{from}» lo quiere {:?}", prev.from, prev.action, action ))); } return Ok(()); } wanted.insert( sym.to_string(), PlannedSymbol { symbol: sym.to_string(), action, from, reason: reason.to_string(), }, ); Ok(()) }; for id in bundle_ids { let b = cat.bundle(id).ok_or_else(|| { crate::Error::Recipe(format!("no existe el bundle «{id}» en el catálogo")) })?; let from = format!("bundle:{id}"); for s in &b.disable { push(&mut wanted, s, Action::Disable, from.clone(), "raíz del bundle")?; disable_roots.push(s.clone()); } for s in &b.close_leaks { push( &mut wanted, s, Action::Disable, from.clone(), "fuga `select` cerrada: sin esto el olddefconfig lo reenciende", )?; disable_roots.push(s.clone()); } for s in &b.enable { push(&mut wanted, s, Action::Enable, from.clone(), "raíz del bundle")?; } } let mut recipe_side = Vec::new(); for id in knob_ids { let k = cat.knob(id).ok_or_else(|| { crate::Error::Recipe(format!("no existe la perilla «{id}» en el catálogo")) })?; let from = format!("knob:{id}"); if k.side == Side::Recipe { recipe_side.push(RecipeKnob { id: k.id.clone(), title: k.title.clone(), recipe_field: k.recipe_field.clone().unwrap_or_default(), }); continue; } for s in &k.enable { push(&mut wanted, s, Action::Enable, from.clone(), "perilla")?; } for s in &k.disable { push(&mut wanted, s, Action::Disable, from.clone(), "perilla")?; disable_roots.push(s.clone()); } } // Un `enable` que cae DENTRO de lo que otro bundle apaga es un conflicto que el símbolo solo no // delata: `olddefconfig` lo descartaría en silencio por dependencia insatisfecha. let closure = tree.closure_off(&disable_roots); for p in wanted.values() { if p.action == Action::Enable && closure.contains(&p.symbol) { return Err(crate::Error::Recipe(format!( "conflicto: «{}» quiere encender {} pero cae dentro de lo que apaga la selección \ (el olddefconfig lo descartaría sin decir nada)", p.from, p.symbol ))); } } let (known, unknown_symbols): (Vec<_>, Vec<_>) = wanted .into_values() .partition(|p| tree.symbols.contains_key(&p.symbol)); let arch = detect_arch(base); let fragment = render_fragment(&known); let base_configure = base .build .phases .configure .as_deref() .ok_or_else(|| { crate::Error::Recipe(format!( "la receta base «{}» no tiene fase `configure`: no hay dónde insertar el config", base.name )) })? .trim_end() .trim_end_matches('\\') .trim_end() .to_string(); // Se AÑADE una segunda ronda en vez de reescribir la fase base: no hay que parsear el shell de // nadie, la base sigue siendo literalmente la de siempre en el diff, y `olddefconfig` es // idempotente. Lo que la app aporta es un fragmento; resolver sigue siendo del kernel. let configure = format!("{base_configure} && \\\n{fragment} && \\\nmake ARCH={arch} olddefconfig\n"); let derived_name = derive_name(&base.name, bundle_ids, knob_ids); Ok(Plan { base_recipe: base_path.to_string(), base_name: base.name.clone(), derived_name, bundles: bundle_ids.to_vec(), knobs: knob_ids.to_vec(), symbols: known, unknown_symbols, recipe_side, closure_size: closure.len(), fragment, configure, artifact_hash: None, }) } /// La receta derivada: la base con la fase `configure` del plan y un nombre propio. /// /// Conserva el `base_dir` de la base a propósito — es lo que hace resolubles sus `deps.build` y, /// con ellas, calculable el `ArtifactHash`. pub fn derive_recipe(base: &Recipe, plan: &Plan) -> Recipe { let mut r = base.clone(); r.name = plan.derived_name.clone(); r.build.phases.configure = Some(plan.configure.clone()); r } /// Serializa la receta derivada a TOML. Vive acá y no en la CLI porque el formato de receta es del /// core: quien lo cambie tiene que cambiarlo en un solo sitio. pub fn render_recipe_toml(r: &Recipe) -> crate::Result { toml::to_string_pretty(r).map_err(|e| crate::Error::Serde(e.to_string())) } fn detect_arch(base: &Recipe) -> String { let hay = base .build .phases .configure .as_deref() .unwrap_or_default(); for tok in hay.split_whitespace() { if let Some(v) = tok.strip_prefix("ARCH=") { if !v.is_empty() { return v.to_string(); } } } "x86_64".to_string() } /// `scripts/config -d A -d B -e C …`, en líneas de ancho razonable y **orden estable**: el texto /// entra al hash, así que un orden que dependiera del recorrido daría dos hashes para el mismo plan. fn render_fragment(syms: &[PlannedSymbol]) -> String { let mut out = String::from("scripts/config"); let mut col = out.len(); for p in syms { let pieza = format!(" {} {}", p.action.flag(), p.symbol); if col + pieza.len() > 96 { out.push_str(" \\\n "); col = 1; } col += pieza.len(); out.push_str(&pieza); } out } /// Nombre de la receta derivada. Lleva un hash corto de la SELECCIÓN, no del contenido: dos planes /// con los mismos bundles dan el mismo nombre, y el `ArtifactHash` sigue siendo quien manda. fn derive_name(base: &str, bundles: &[String], knobs: &[String]) -> String { let mut material = String::new(); let mut b: Vec<&String> = bundles.iter().collect(); b.sort(); for x in b { material.push_str("bundle:"); material.push_str(x); material.push('\n'); } let mut k: Vec<&String> = knobs.iter().collect(); k.sort(); for x in k { material.push_str("knob:"); material.push_str(x); material.push('\n'); } let h = crate::hash::ArtifactHash::of_bytes(material.as_bytes()); let hex = h.as_str().trim_start_matches("b3:"); format!("{base}-plan-{}", &hex[..8]) } /// **Diff-back** (§6 del handoff, la mitad que faltaba): ¿sobrevivió cada símbolo pedido al /// `olddefconfig`? /// /// Sin esto la UI miente: un `-e FOO` cuya dependencia no se cumple se pierde en silencio. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiffBack { pub honored: Vec, /// Pedidos que el `.config` resultante contradice. **Cada uno es una promesa incumplida.** pub violated: Vec, /// Apagados que el `.config` ni menciona. **Esto es éxito, no fallo**: Kconfig no emite un /// símbolo cuyas dependencias no se cumplen, así que un `-d` sobre algo que ya cayó con su /// raíz simplemente no aparece. Contarlo como incumplido —el error que cometí antes de correr /// un `olddefconfig` de verdad— hace que un plan perfecto se reporte como roto. pub off_by_dependency: Vec, /// Símbolos que no existen en este árbol: la bandera no hace nada. Ya se sabía al planear; se /// repite acá porque quien lee el diff-back no siempre vio el plan. pub never_existed: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Violation { pub symbol: String, pub asked: Action, /// Cómo quedó de verdad. pub got: String, pub from: String, } impl DiffBack { /// Limpio = ninguna promesa incumplida. Los `off_by_dependency` son el resultado buscado y los /// `never_existed` ya los reportó el plan: ninguno de los dos ensucia el veredicto. pub fn is_clean(&self) -> bool { self.violated.is_empty() } } /// Contrasta el plan contra el `.config` que produjo el kernel. pub fn diff_back(plan: &Plan, produced: &super::KernelConfig) -> DiffBack { use super::ConfigValue; let mut d = DiffBack { honored: Vec::new(), violated: Vec::new(), off_by_dependency: Vec::new(), never_existed: Vec::new(), }; for p in &plan.symbols { match (p.action, produced.get(&p.symbol)) { // Ausente + se pedía apagar = está apagado. Kconfig omite lo que no puede existir. (Action::Disable, None) => d.off_by_dependency.push(p.symbol.clone()), // Ausente + se pedía encender = NO está. Eso sí es una promesa rota. (Action::Enable, None) => d.violated.push(Violation { symbol: p.symbol.clone(), asked: Action::Enable, got: "ausente del .config".to_string(), from: p.from.clone(), }), (Action::Enable, Some(v)) if v.is_on() => d.honored.push(p.symbol.clone()), (Action::Disable, Some(ConfigValue::No)) => d.honored.push(p.symbol.clone()), (asked, Some(v)) => d.violated.push(Violation { symbol: p.symbol.clone(), asked, got: format!("{v:?}"), from: p.from.clone(), }), } } d.never_existed = plan .unknown_symbols .iter() .map(|p| p.symbol.clone()) .collect(); d.never_existed.sort(); d.never_existed.dedup(); d } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; fn escenario() -> (tempfile::TempDir, KconfigTree, Catalog, Recipe) { let d = tempfile::tempdir().unwrap(); std::fs::write( d.path().join("Kconfig"), r#" config WIRELESS bool "Wireless" config CFG80211 tristate "cfg80211" depends on WIRELESS config WLAN bool "WLAN" select WIRELESS config AUDIT bool "Audit" config MAC80211_LEDS bool "leds" depends on CFG80211 "#, ) .unwrap(); let tree = KconfigTree::parse(d.path(), &BTreeMap::new()).unwrap(); let cat: Catalog = toml::from_str( r#" version = 1 [[bundle]] id = "sin-wifi" title = "No necesito wifi" disable = ["WIRELESS"] close_leaks = ["WLAN"] [[bundle]] id = "con-leds" title = "Quiero los leds" enable = ["MAC80211_LEDS"] [[knob]] id = "auditoria" title = "Auditoría" side = "kconfig" enable = ["AUDIT"] [[knob]] id = "lto" title = "LTO" side = "recipe" recipe_field = "build.compiler" "#, ) .unwrap(); let recipe: Recipe = toml::from_str( r#" name = "linux" version = "6.16.12" [source] tarball = "https://x/linux.tar.gz" sha256 = "aa" [build] compiler = "zig-cc" target = "x86_64-linux-musl" link = "dynamic" [build.phases] configure = "make ARCH=x86_64 defconfig && make ARCH=x86_64 olddefconfig" compile = "make bzImage" "#, ) .unwrap(); (d, tree, cat, recipe) } #[test] fn emite_raices_no_clausuras() { let (_d, tree, cat, base) = escenario(); let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap(); // Dos banderas, no cuatro símbolos: la clausura la calcula olddefconfig. assert_eq!(p.symbols.len(), 2); assert!(p.fragment.contains("-d WIRELESS")); assert!(p.fragment.contains("-d WLAN")); assert!(!p.fragment.contains("CFG80211")); // …pero hammer SABE cuántos caen, para poder explicarlo. assert_eq!(p.closure_size, 4); } #[test] fn la_fase_derivada_conserva_la_base_y_reresuelve() { let (_d, tree, cat, base) = escenario(); let p = make_plan(&base, "recipes/linux.toml", &cat, &tree, &["sin-wifi".into()], &[]).unwrap(); assert!(p.configure.starts_with("make ARCH=x86_64 defconfig")); assert!(p.configure.trim_end().ends_with("make ARCH=x86_64 olddefconfig")); // El fragmento va ANTES del olddefconfig final: si fuera después, no se resolvería. let i_frag = p.configure.find("scripts/config").unwrap(); let i_last = p.configure.rfind("olddefconfig").unwrap(); assert!(i_frag < i_last); } #[test] fn el_nombre_derivado_es_estable_e_independiente_del_orden() { let (_d, tree, cat, base) = escenario(); let a = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap(); let b = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap(); assert_eq!(a.derived_name, b.derived_name); assert!(a.derived_name.starts_with("linux-plan-")); let c = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap(); assert_ne!(a.derived_name, c.derived_name); } #[test] fn encender_algo_que_la_seleccion_apaga_es_conflicto() { let (_d, tree, cat, base) = escenario(); // MAC80211_LEDS depende de CFG80211, que cae con WIRELESS. olddefconfig lo tiraría callado. let e = make_plan( &base, "r", &cat, &tree, &["sin-wifi".into(), "con-leds".into()], &[], ) .unwrap_err() .to_string(); assert!(e.contains("MAC80211_LEDS"), "{e}"); } #[test] fn la_perilla_de_receta_se_declara_y_no_se_aplica() { let (_d, tree, cat, base) = escenario(); let p = make_plan(&base, "r", &cat, &tree, &[], &["lto".into()]).unwrap(); assert!(p.symbols.is_empty()); assert_eq!(p.recipe_side.len(), 1); assert_eq!(p.recipe_side[0].recipe_field, "build.compiler"); } #[test] fn el_simbolo_inexistente_no_se_pierde_en_silencio() { let (_d, tree, _cat, base) = escenario(); let cat: Catalog = toml::from_str( r#" version = 1 [[bundle]] id = "fantasma" title = "Apaga algo que ya no existe" disable = ["REISERFS_FS"] "#, ) .unwrap(); let p = make_plan(&base, "r", &cat, &tree, &["fantasma".into()], &[]).unwrap(); assert!(p.symbols.is_empty(), "no se emite una bandera que sería no-op"); assert_eq!(p.unknown_symbols.len(), 1); assert_eq!(p.unknown_symbols[0].symbol, "REISERFS_FS"); } #[test] fn el_diff_back_ve_la_promesa_incumplida() { let (_d, tree, cat, base) = escenario(); let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap(); // El kernel produjo un config donde WLAN sobrevivió y AUDIT no se pudo encender. let cfg = super::super::KernelConfig::parse( "# CONFIG_WIRELESS is not set\nCONFIG_WLAN=y\n# CONFIG_AUDIT is not set\n", ); let d = diff_back(&p, &cfg); assert!(!d.is_clean()); let violados: Vec<&str> = d.violated.iter().map(|v| v.symbol.as_str()).collect(); assert_eq!(violados, vec!["AUDIT", "WLAN"]); assert_eq!(d.honored, vec!["WIRELESS"]); } /// La lección de correr un `olddefconfig` de verdad (SDD 22 §12): un `-d` sobre un símbolo que /// el `.config` ni menciona **es el resultado buscado**, no una promesa rota. Contarlo como /// fallo hacía que un plan perfecto se reportara roto. #[test] fn apagar_algo_ausente_es_exito_y_encenderlo_es_fallo() { let (_d, tree, cat, base) = escenario(); let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &["auditoria".into()]).unwrap(); // WIRELESS apagado; WLAN ni aparece (cayó con él); AUDIT tampoco aparece, y ése SÍ se pidió // encender. let cfg = super::super::KernelConfig::parse("# CONFIG_WIRELESS is not set\n"); let d = diff_back(&p, &cfg); assert_eq!(d.honored, vec!["WIRELESS"]); assert_eq!(d.off_by_dependency, vec!["WLAN"]); assert_eq!(d.violated.len(), 1); assert_eq!(d.violated[0].symbol, "AUDIT"); assert!(!d.is_clean(), "falta un símbolo que se pidió ENCENDER"); } #[test] fn el_diff_back_limpio_es_limpio() { let (_d, tree, cat, base) = escenario(); let p = make_plan(&base, "r", &cat, &tree, &["sin-wifi".into()], &[]).unwrap(); let cfg = super::super::KernelConfig::parse( "# CONFIG_WIRELESS is not set\n# CONFIG_WLAN is not set\n", ); assert!(diff_back(&p, &cfg).is_clean()); } }