//! Roundtrip e2e de Fase 4: construimos un `.swm` a mano (los tipos viven en takana-core), //! lo aplicamos a un prefix temporal, leemos el resultado y verificamos que los archivos //! coinciden con lo esperado. //! //! NO ejercitamos `source_patch` porque arrastra todo el lab (red + sandbox); para esos //! casos existen los tests gated en `TAKANA_NETWORK_TESTS`. Aquí cubrimos los caminos puros //! de takana-core: `verify_schema`, `verify_base`, `apply_config_edit`, `apply_file_drop`. use std::collections::BTreeMap; use std::path::Path; use base64::{engine::general_purpose::STANDARD, Engine as _}; use takana_core::apply::{apply_config_edit, apply_file_drop, rebase_path, verify_content_hash}; use takana_core::swm::{Base, BaseCompat, BaseRef, Mutation, Swm}; use takana_core::ArtifactHash; fn write(p: &Path, content: &str) { if let Some(parent) = p.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(p, content).unwrap(); } fn base(distro: &str, pins: &[(&str, &str)]) -> Base { Base { distro_version: distro.into(), pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(), } } fn baseref(distro: &str, pins: &[(&str, &str)]) -> BaseRef { BaseRef { distro_version: distro.into(), pins: pins.iter().map(|(k, v)| ((*k).into(), (*v).into())).collect(), } } fn mk_swm(mutations: Vec) -> Swm { Swm { swm_version: 1, base: base("2026-06-06", &[("musl", "deadbeef")]), mutations, signature: None, } } /// Verifica que un `.swm` pasa schema y base, y luego aplica cada mutación bajo un prefix. /// Este es el camino que la CLI ejercita; aquí lo reproducimos como librería para que el /// test se mantenga aunque la CLI cambie superficialmente. fn apply_swm_under_prefix(swm: &Swm, prefix: &Path) { swm.verify_schema().expect("schema válido"); // Base compatible (idéntica a la del .swm). let local = BaseRef::from(&swm.base); assert_eq!(swm.verify_base(&local), BaseCompat::Ok); for m in &swm.mutations { match m { Mutation::ConfigEdit { file, inline_diff } => { let target = rebase_path(file, Some(prefix)); apply_config_edit(&target, inline_diff).unwrap(); } Mutation::FileDrop { path, content_hash, content_b64, .. } => { let target = rebase_path(path, Some(prefix)); let b64 = content_b64.as_ref().expect("inline para tests"); apply_file_drop(&target, b64, content_hash).unwrap(); } _ => panic!("este test sólo cubre config_edit/file_drop"), } } } #[test] fn roundtrip_yaml_apply_reproduces_files() { let payload = b"hola hammer\n"; let payload_hash = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string(); let payload_b64 = STANDARD.encode(payload); let swm = mk_swm(vec![ Mutation::ConfigEdit { file: "/etc/network.conf".into(), inline_diff: "- DHCP=yes\n+ IP=192.168.1.100\n".into(), }, Mutation::FileDrop { path: "/var/lib/hammer/saludo.txt".into(), content_hash: payload_hash.clone(), content_b64: Some(payload_b64), content_url: None, }, ]); // Roundtrip YAML completo: serializa, deserializa y reusa. let yaml = swm.to_yaml().unwrap(); let swm2 = Swm::from_yaml(&yaml).unwrap(); assert_eq!(swm2.mutations.len(), 2); let prefix_dir = tempfile::tempdir().unwrap(); let prefix = prefix_dir.path(); // El archivo objetivo del config_edit debe existir antes (config_edit no crea archivos). write( &prefix.join("etc/network.conf"), "USE=eth0\nDHCP=yes\nMTU=1500\n", ); apply_swm_under_prefix(&swm2, prefix); let got_net = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap(); assert_eq!(got_net, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n"); let got_drop = std::fs::read(prefix.join("var/lib/hammer/saludo.txt")).unwrap(); assert_eq!(got_drop, payload); } #[test] fn verify_base_distinguishes_compatible_and_not() { let swm = mk_swm(vec![Mutation::ConfigEdit { file: "/etc/x".into(), inline_diff: "- a\n+ b\n".into(), }]); // distro distinta let other = baseref("2026-09-09", &[("musl", "deadbeef")]); assert!(matches!( swm.verify_base(&other), BaseCompat::DistroMismatch { .. } )); // pin distinta let bad_pin = baseref("2026-06-06", &[("musl", "ZZZZ")]); assert!(matches!(swm.verify_base(&bad_pin), BaseCompat::PinMismatch(_))); // pins extras locales no son problema let mut more = BTreeMap::new(); more.insert("musl".into(), "deadbeef".into()); more.insert("kernel".into(), "abcdef".into()); let ok = BaseRef { distro_version: "2026-06-06".into(), pins: more }; assert_eq!(swm.verify_base(&ok), BaseCompat::Ok); } #[test] fn verify_content_hash_catches_payload_tampering() { let payload = b"datos integros\n"; let b64 = STANDARD.encode(payload); let h = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string(); verify_content_hash(&b64, &h).expect("hash ok"); // Mutar un byte del payload sin actualizar el hash ⇒ falla. let mut bad = payload.to_vec(); bad[0] ^= 0x01; let bad_b64 = STANDARD.encode(&bad); let err = verify_content_hash(&bad_b64, &h).unwrap_err().to_string(); assert!(err.contains("no coincide"), "{err}"); } #[test] fn yaml_includes_only_inline_field_when_url_is_none() { // Un .swm autocontenido (lo que produce export) no debe acabar emitiendo `content_url`. let swm = mk_swm(vec![Mutation::FileDrop { path: "/var/lib/x".into(), content_hash: "b3:00".into(), content_b64: Some("AA==".into()), content_url: None, }]); let y = swm.to_yaml().unwrap(); assert!(y.contains("content_b64")); assert!(!y.contains("content_url"), "skip_serializing_if=None debe omitir el campo"); }