use alt_controller_image::compare::{Change, SemanticDifference, compare}; use alt_controller_image::archive::NativeTarWriter; use alt_controller_image::manifest::{ ArchiveMemberRecord, ArtifactManifest, FileRecord, InitrdRecord, PackageRecord, ServiceRecord, }; use std::fs::{self, hard_link}; use std::io::Cursor; use std::os::unix::fs::symlink; use tempfile::tempdir; fn baseline() -> ArtifactManifest { ArtifactManifest::new( vec![PackageRecord::new("kernel-image-rt", "1.0")], vec![FileRecord::file("etc/controller.conf", "aaa")], Some(InitrdRecord::new("boot/initrd-rt.img", "old-initrd")), vec![ServiceRecord::new("controller.service", true)], vec![ArchiveMemberRecord::new("etc/controller.conf", "file")], ) .expect("valid baseline manifest") } #[test] fn compares_added_removed_and_changed_semantic_records() { let left = baseline(); let right = ArtifactManifest::new( vec![ PackageRecord::new("kernel-image-rt", "2.0"), PackageRecord::new("controller-agent", "1.0"), ], vec![FileRecord::symlink("etc/controller.conf", "controller.conf.real")], Some(InitrdRecord::new("boot/initrd-rt.img", "new-initrd")), vec![ServiceRecord::new("controller.service", false)], vec![ArchiveMemberRecord::new("usr/bin/controller", "file")], ) .expect("valid comparison manifest"); let report = compare(&left, &right); assert_eq!( report.differences(), &[ SemanticDifference::Package { name: "controller-agent".into(), change: Change::Added }, SemanticDifference::Package { name: "kernel-image-rt".into(), change: Change::Changed }, SemanticDifference::File { path: "etc/controller.conf".into(), change: Change::Changed }, SemanticDifference::Initrd { change: Change::Changed }, SemanticDifference::Service { name: "controller.service".into(), change: Change::Changed }, SemanticDifference::ArchiveMember { path: "etc/controller.conf".into(), change: Change::Removed }, SemanticDifference::ArchiveMember { path: "usr/bin/controller".into(), change: Change::Added }, ] ); assert!(report.render().contains("changed initrd")); } #[test] fn distinguishes_added_and_removed_initrd_records() { let without_initrd = ArtifactManifest::new(vec![], vec![], None, vec![], vec![]) .expect("valid manifest without initrd"); let with_initrd = ArtifactManifest::new( vec![], vec![], Some(InitrdRecord::new("boot/initrd-rt.img", "digest")), vec![], vec![], ) .expect("valid manifest with initrd"); assert_eq!( compare(&without_initrd, &with_initrd).differences(), &[SemanticDifference::Initrd { change: Change::Added }] ); assert_eq!( compare(&with_initrd, &without_initrd).differences(), &[SemanticDifference::Initrd { change: Change::Removed }] ); } #[test] fn archive_collection_records_an_initrd_once_as_a_dedicated_boot_fact() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join("boot")).expect("create boot directory"); fs::write(rootfs.join("boot/initrd-6.12-rt1.img"), "initrd\n").expect("write initrd"); let artifact = fixture.path().join("controller.tar"); NativeTarWriter::new() .write(&rootfs, &artifact) .expect("write native archive"); let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts"); assert_eq!( manifest.initrd, Some(InitrdRecord::new( "boot/initrd-6.12-rt1.img", "8f7ed204b9dfaa20aa484445f54233c4b407cb80ec0f8c07f1f0a59675fb44cf", )) ); assert!(manifest .files .iter() .all(|record| record.path != "boot/initrd-6.12-rt1.img")); } #[test] fn rootfs_collection_records_a_supplied_initrd_once_as_a_dedicated_boot_fact() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join("boot")).expect("create boot directory"); fs::write(rootfs.join("boot/initrd-6.12-rt1.img"), "initrd\n").expect("write initrd"); let artifact = fixture.path().join("controller.tar"); NativeTarWriter::new() .write(&rootfs, &artifact) .expect("write native archive"); let initrd = InitrdRecord::new( "boot/initrd-6.12-rt1.img", "8f7ed204b9dfaa20aa484445f54233c4b407cb80ec0f8c07f1f0a59675fb44cf", ); let manifest = ArtifactManifest::collect(&rootfs, vec![], Some(initrd.clone()), &artifact) .expect("collect rootfs facts"); assert_eq!(manifest.initrd, Some(initrd)); assert!(manifest .files .iter() .all(|record| record.path != "boot/initrd-6.12-rt1.img")); } #[test] fn reads_and_writes_a_toml_manifest_beside_an_artifact() { let fixture = tempdir().expect("temporary directory"); let artifact = fixture.path().join("controller.tar"); let manifest = baseline(); let manifest_path = manifest.write_beside(&artifact).expect("write manifest"); assert_eq!(manifest_path, fixture.path().join("artifact.manifest.toml")); assert_eq!(ArtifactManifest::load(&manifest_path).expect("load manifest"), manifest); } #[test] fn rejects_duplicate_semantic_keys() { let error = ArtifactManifest::new( vec![PackageRecord::new("controller", "1"), PackageRecord::new("controller", "2")], vec![], None, vec![], vec![], ) .expect_err("duplicate package names must fail"); assert!(error.to_string().contains("duplicate package record")); } #[test] fn collects_semantic_facts_from_native_rootfs_and_tar_artifact() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join("etc/systemd/system/multi-user.target.wants")) .expect("create service state directory"); fs::write(rootfs.join("etc/controller.conf"), "controller\n").expect("write file"); symlink("controller.conf", rootfs.join("etc/controller-link")).expect("write symlink"); symlink( "/usr/lib/systemd/system/controller.service", rootfs.join("etc/systemd/system/multi-user.target.wants/controller.service"), ) .expect("enable service"); let artifact = fixture.path().join("controller.tar"); NativeTarWriter::new() .write(&rootfs, &artifact) .expect("write native tar"); let manifest = ArtifactManifest::collect( &rootfs, vec![PackageRecord::new("controller", "1.0")], Some(InitrdRecord::new("boot/initrd-rt.img", "digest")), &artifact, ) .expect("collect semantic facts"); assert_eq!(manifest.packages, vec![PackageRecord::new("controller", "1.0")]); assert!(manifest.files.iter().any(|record| record == &FileRecord::file( "etc/controller.conf", "2d5c759b2b539229e09d362e8dbe0ae410ff8c9ece6038624458724520683f5b", ))); assert!(manifest .files .iter() .any(|record| record == &FileRecord::symlink("etc/controller-link", "controller.conf"))); assert_eq!(manifest.services, vec![ServiceRecord::new("controller.service", true)]); assert!(manifest .archive_members .iter() .any(|record| record == &ArchiveMemberRecord::new("etc/controller.conf", "file"))); assert!(manifest .archive_members .iter() .any(|record| record == &ArchiveMemberRecord::new("etc/controller-link", "symlink"))); } #[test] fn collects_comparable_semantic_facts_directly_from_a_tar_artifact() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join("etc/systemd/system/multi-user.target.wants")) .expect("create service state directory"); fs::create_dir_all(rootfs.join("boot")).expect("create boot directory"); fs::write(rootfs.join("etc/controller.conf"), "controller\n").expect("write file"); fs::write(rootfs.join("boot/initrd-rt.img"), "initrd\n").expect("write initrd"); symlink( "/usr/lib/systemd/system/controller.service", rootfs.join("etc/systemd/system/multi-user.target.wants/controller.service"), ) .expect("enable service"); let artifact = fixture.path().join("controller.tar"); NativeTarWriter::new() .write(&rootfs, &artifact) .expect("write native tar"); let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts"); assert_eq!(manifest.packages, Vec::::new()); assert!(manifest.files.iter().any(|record| record == &FileRecord::file( "etc/controller.conf", "2d5c759b2b539229e09d362e8dbe0ae410ff8c9ece6038624458724520683f5b", ))); assert_eq!( manifest.initrd, Some(InitrdRecord::new( "boot/initrd-rt.img", "8f7ed204b9dfaa20aa484445f54233c4b407cb80ec0f8c07f1f0a59675fb44cf", )) ); assert_eq!(manifest.services, vec![ServiceRecord::new("controller.service", true)]); } #[test] fn normalizes_legacy_dot_prefixed_archive_paths_before_comparison() { let fixture = tempdir().expect("temporary directory"); let artifact = fixture.path().join("legacy.tar"); let file = fs::File::create(&artifact).expect("create legacy archive"); let mut archive = tar::Builder::new(file); let mut header = tar::Header::new_gnu(); header.set_size(11); header.set_mode(0o644); header.set_cksum(); archive .append_data(&mut header, "./etc/controller.conf", Cursor::new(b"controller\n")) .expect("append legacy-style member"); archive.finish().expect("finish legacy archive"); let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect legacy archive facts"); let expected = ArtifactManifest::new( vec![], vec![FileRecord::file( "etc/controller.conf", "2d5c759b2b539229e09d362e8dbe0ae410ff8c9ece6038624458724520683f5b", )], None, vec![], vec![ArchiveMemberRecord::new("etc/controller.conf", "file")], ) .expect("valid normalized manifest"); assert!(compare(&manifest, &expected).is_equivalent()); } #[test] fn collects_hardlink_targets_from_an_archive_for_semantic_comparison() { let fixture = tempdir().expect("temporary directory"); let artifact = fixture.path().join("legacy.tar"); let file = fs::File::create(&artifact).expect("create legacy archive"); let mut archive = tar::Builder::new(file); let mut header = tar::Header::new_gnu(); header.set_size(11); header.set_mode(0o644); header.set_cksum(); archive .append_data(&mut header, "./usr/bin/controller", Cursor::new(b"controller\n")) .expect("append regular member"); let mut link_header = tar::Header::new_gnu(); link_header.set_entry_type(tar::EntryType::Link); link_header.set_size(0); link_header.set_cksum(); archive .append_link( &mut link_header, "./usr/bin/controller-link", "./usr/bin/controller", ) .expect("append hardlink member"); archive.finish().expect("finish legacy archive"); let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts"); assert!( manifest.files.iter().any(|record| { record == &FileRecord::hardlink("usr/bin/controller-link", "usr/bin/controller") }), "collected files: {:?}", manifest.files ); } #[test] fn rootfs_collection_preserves_hardlink_facts_like_archive_collection() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join("usr/bin")).expect("create binary directory"); fs::write(rootfs.join("usr/bin/controller"), "controller\n").expect("write binary"); hard_link( rootfs.join("usr/bin/controller"), rootfs.join("usr/bin/controller-link"), ) .expect("create hardlink"); let artifact = fixture.path().join("controller.tar"); NativeTarWriter::new() .write(&rootfs, &artifact) .expect("write native archive"); let rootfs_manifest = ArtifactManifest::collect(&rootfs, vec![], None, &artifact) .expect("collect rootfs facts"); let archive_manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts"); assert_eq!(rootfs_manifest.files, archive_manifest.files); assert!(rootfs_manifest.files.iter().any(|record| { record == &FileRecord::hardlink("usr/bin/controller-link", "usr/bin/controller") })); } #[test] fn normalizes_dot_prefixed_paths_from_a_legacy_tree_archive() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join("etc")).expect("create rootfs directory"); fs::write(rootfs.join("etc/controller.conf"), "controller\n").expect("write rootfs file"); let artifact = fixture.path().join("legacy.tar"); let mut archive = tar::Builder::new(fs::File::create(&artifact).expect("create legacy archive")); archive.append_dir_all(".", &rootfs).expect("write legacy tree archive"); archive.finish().expect("finish legacy archive"); let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect legacy archive facts"); assert!(manifest.files.iter().all(|record| !record.path.starts_with("./"))); assert!(manifest .archive_members .iter() .all(|record| !record.path.starts_with("./"))); } #[test] fn excludes_legacy_internal_host_and_fakedata_archive_members() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join(".host/private")).expect("create host metadata"); fs::create_dir_all(rootfs.join(".fakedata")).expect("create fakeroot metadata"); fs::write(rootfs.join(".host/private/secret"), "metadata").expect("write host metadata"); fs::write(rootfs.join(".fakedata/owner"), "metadata").expect("write fakeroot metadata"); fs::create_dir_all(rootfs.join("etc")).expect("create visible directory"); fs::write(rootfs.join("etc/controller.conf"), "controller\n").expect("write visible file"); let legacy = fixture.path().join("legacy.tar"); let native = fixture.path().join("native.tar"); let mut legacy_writer = tar::Builder::new(fs::File::create(&legacy).expect("create legacy archive")); legacy_writer .append_dir_all(".", &rootfs) .expect("write legacy archive with internal metadata"); legacy_writer.finish().expect("finish legacy archive"); NativeTarWriter::new().write(&rootfs, &native).expect("write native archive"); let legacy_manifest = ArtifactManifest::collect_archive(&legacy).expect("collect legacy facts"); let native_manifest = ArtifactManifest::collect_archive(&native).expect("collect native facts"); assert!(legacy_manifest .archive_members .iter() .all(|record| !record.path.starts_with(".host/") && !record.path.starts_with(".fakedata/"))); let report = compare(&legacy_manifest, &native_manifest); assert!(report.is_equivalent(), "{}", report.render()); } #[test] fn native_manifest_collection_excludes_internal_members_from_its_archive_facts() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join(".host/private")).expect("create host metadata"); fs::create_dir_all(rootfs.join(".fakedata")).expect("create fakeroot metadata"); fs::create_dir_all(rootfs.join("etc")).expect("create visible directory"); fs::write(rootfs.join(".host/private/secret"), "metadata").expect("write host metadata"); fs::write(rootfs.join(".fakedata/owner"), "metadata").expect("write fakeroot metadata"); fs::write(rootfs.join("etc/controller.conf"), "controller\n").expect("write visible file"); let artifact = fixture.path().join("legacy.tar"); let mut writer = tar::Builder::new(fs::File::create(&artifact).expect("create legacy archive")); writer.append_dir_all(".", &rootfs).expect("write legacy archive"); writer.finish().expect("finish legacy archive"); let manifest = ArtifactManifest::collect(&rootfs, vec![], None, &artifact) .expect("collect native filesystem and archive facts"); assert!(manifest .archive_members .iter() .all(|record| !record.path.starts_with(".host/") && !record.path.starts_with(".fakedata/"))); } #[test] fn archive_collection_excludes_nested_internal_metadata_members() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); fs::create_dir_all(rootfs.join("var/.fakedata")).expect("create fakeroot metadata"); fs::create_dir_all(rootfs.join("etc")).expect("create visible directory"); fs::write(rootfs.join("var/.fakedata/owner"), "metadata").expect("write fakeroot metadata"); fs::write(rootfs.join("etc/controller.conf"), "controller\n").expect("write visible file"); let artifact = fixture.path().join("legacy.tar"); let mut writer = tar::Builder::new(fs::File::create(&artifact).expect("create legacy archive")); writer.append_dir_all(".", &rootfs).expect("write legacy archive"); writer.finish().expect("finish legacy archive"); let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts"); assert!(manifest .archive_members .iter() .all(|record| !record.path.split('/').any(|component| component == ".fakedata"))); assert!(manifest .files .iter() .all(|record| !record.path.split('/').any(|component| component == ".fakedata"))); } #[test] fn rootfs_collection_recognizes_services_enabled_by_non_default_targets() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); let wants = rootfs.join("etc/systemd/system/graphical.target.wants"); fs::create_dir_all(&wants).expect("create service state directory"); symlink( "/usr/lib/systemd/system/controller.service", wants.join("controller.service"), ) .expect("enable service"); let artifact = fixture.path().join("controller.tar"); NativeTarWriter::new() .write(&rootfs, &artifact) .expect("write native archive"); let manifest = ArtifactManifest::collect(&rootfs, vec![], None, &artifact) .expect("collect native filesystem and archive facts"); assert_eq!(manifest.services, vec![ServiceRecord::new("controller.service", true)]); } #[test] fn archive_collection_coalesces_a_service_enabled_by_multiple_targets() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); for target in ["multi-user.target.wants", "graphical.target.wants"] { let wants = rootfs.join("etc/systemd/system").join(target); fs::create_dir_all(&wants).expect("create service state directory"); symlink( "/usr/lib/systemd/system/controller.service", wants.join("controller.service"), ) .expect("enable service"); } let artifact = fixture.path().join("controller.tar"); NativeTarWriter::new() .write(&rootfs, &artifact) .expect("write native archive"); let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts"); assert_eq!(manifest.services, vec![ServiceRecord::new("controller.service", true)]); } #[test] fn archive_collection_only_treats_direct_target_wants_symlinks_as_enabled_services() { let fixture = tempdir().expect("temporary directory"); let artifact = fixture.path().join("malformed-service-state.tar"); let file = fs::File::create(&artifact).expect("create archive"); let mut archive = tar::Builder::new(file); let mut header = tar::Header::new_gnu(); header.set_entry_type(tar::EntryType::Symlink); header.set_size(0); header.set_cksum(); archive .append_link( &mut header, "etc/systemd/system/multi-user.target.wants/nested/controller.service", "/usr/lib/systemd/system/controller.service", ) .expect("append nested symlink"); archive.finish().expect("finish archive"); let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts"); assert!(manifest.services.is_empty()); } #[test] fn rootfs_collection_only_treats_direct_target_wants_symlinks_as_enabled_services() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); let nested_wants = rootfs.join("etc/systemd/system/multi-user.target.wants/nested"); fs::create_dir_all(&nested_wants).expect("create nested service state directory"); symlink( "/usr/lib/systemd/system/controller.service", nested_wants.join("controller.service"), ) .expect("create nested service symlink"); let artifact = fixture.path().join("controller.tar"); NativeTarWriter::new() .write(&rootfs, &artifact) .expect("write native archive"); let manifest = ArtifactManifest::collect(&rootfs, vec![], None, &artifact) .expect("collect native filesystem and archive facts"); assert!(manifest.services.is_empty()); }