From 4290439e006712defbb57a017b5a316a5ab341be Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Sun, 15 Feb 2026 15:40:16 +0100 Subject: [PATCH] Add comprehensive tests and fix compiler warnings - Fix false-positive unused_assignments warnings from thiserror/miette derive macros in Rust 2024 edition with crate-level #![allow] - Add 5 tests for tar_layer (empty dir, files, nested dirs, symlinks, deterministic digest) - Add 5 tests for manifest (default options, entrypoint/env, multiple layers, config digest verification, no entrypoint) - Add 6 tests for layout (structure creation, oci-layout content, index.json references, layer blobs, config digest, multiple layers) - Add 11 tests for overlays (file copy, empty file, missing source, ensure dir, symlink, remove file, remove dir contents, shadow create/update, multiple overlays) - Add 4 tests for customizations (single user, multiple users, append to existing, no users noop) - Add 3 tests for phase2/oci (layout output, entrypoint/env, empty staging) - Add tempfile dev-dependency to forge-oci for test support 42 tests passing, 0 warnings. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/forge-engine/src/lib.rs | 3 + .../forge-engine/src/phase1/customizations.rs | 90 ++++++ crates/forge-engine/src/phase1/overlays.rs | 271 ++++++++++++++++++ crates/forge-engine/src/phase2/oci.rs | 83 ++++++ crates/forge-oci/Cargo.toml | 3 + crates/forge-oci/src/layout.rs | 121 ++++++++ crates/forge-oci/src/lib.rs | 3 + crates/forge-oci/src/manifest.rs | 109 +++++++ crates/forge-oci/src/tar_layer.rs | 92 ++++++ crates/spec-parser/src/lib.rs | 9 +- 11 files changed, 781 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 755734a..4bc584b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,6 +563,7 @@ dependencies = [ "serde_json", "sha2", "tar", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/crates/forge-engine/src/lib.rs b/crates/forge-engine/src/lib.rs index fb008a7..db7b9da 100644 --- a/crates/forge-engine/src/lib.rs +++ b/crates/forge-engine/src/lib.rs @@ -1,3 +1,6 @@ +// thiserror/miette derive macros generate code that triggers false-positive unused_assignments +#![allow(unused_assignments)] + pub mod error; pub mod phase1; pub mod phase2; diff --git a/crates/forge-engine/src/phase1/customizations.rs b/crates/forge-engine/src/phase1/customizations.rs index 151ad06..332a0f1 100644 --- a/crates/forge-engine/src/phase1/customizations.rs +++ b/crates/forge-engine/src/phase1/customizations.rs @@ -61,3 +61,93 @@ fn append_or_create(path: &Path, content: &str) -> Result<(), std::io::Error> { file.write_all(content.as_bytes())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use spec_parser::schema::User; + use tempfile::TempDir; + + #[test] + fn test_create_single_user() { + let staging = TempDir::new().unwrap(); + + let customization = Customization { + r#if: None, + users: vec![User { + name: "testuser".to_string(), + }], + }; + + apply(&customization, staging.path()).unwrap(); + + let passwd = std::fs::read_to_string(staging.path().join("etc/passwd")).unwrap(); + assert!(passwd.contains("testuser:x:1000:1000::/home/testuser:/bin/sh")); + + let shadow = std::fs::read_to_string(staging.path().join("etc/shadow")).unwrap(); + assert!(shadow.contains("testuser:*LK*:::::::")); + + let group = std::fs::read_to_string(staging.path().join("etc/group")).unwrap(); + assert!(group.contains("testuser::1000:")); + } + + #[test] + fn test_create_multiple_users() { + let staging = TempDir::new().unwrap(); + + let customization = Customization { + r#if: None, + users: vec![ + User { name: "alice".to_string() }, + User { name: "bob".to_string() }, + ], + }; + + apply(&customization, staging.path()).unwrap(); + + let passwd = std::fs::read_to_string(staging.path().join("etc/passwd")).unwrap(); + assert!(passwd.contains("alice")); + assert!(passwd.contains("bob")); + } + + #[test] + fn test_create_user_appends_to_existing() { + let staging = TempDir::new().unwrap(); + + // Create pre-existing /etc/passwd + std::fs::create_dir_all(staging.path().join("etc")).unwrap(); + std::fs::write( + staging.path().join("etc/passwd"), + "root:x:0:0:root:/root:/bin/sh\n", + ) + .unwrap(); + + let customization = Customization { + r#if: None, + users: vec![User { + name: "admin".to_string(), + }], + }; + + apply(&customization, staging.path()).unwrap(); + + let passwd = std::fs::read_to_string(staging.path().join("etc/passwd")).unwrap(); + assert!(passwd.contains("root:x:0:0:root:/root:/bin/sh")); + assert!(passwd.contains("admin:x:1000:1000::/home/admin:/bin/sh")); + } + + #[test] + fn test_no_users_is_noop() { + let staging = TempDir::new().unwrap(); + + let customization = Customization { + r#if: None, + users: vec![], + }; + + apply(&customization, staging.path()).unwrap(); + + // etc directory should not have been created + assert!(!staging.path().join("etc").exists()); + } +} diff --git a/crates/forge-engine/src/phase1/overlays.rs b/crates/forge-engine/src/phase1/overlays.rs index 63a9ebd..531bec8 100644 --- a/crates/forge-engine/src/phase1/overlays.rs +++ b/crates/forge-engine/src/phase1/overlays.rs @@ -321,3 +321,274 @@ async fn apply_action( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use spec_parser::schema::{EnsureDir, EnsureSymlink, FileOverlay, RemoveFiles, ShadowOverlay}; + use std::pin::Pin; + use tempfile::TempDir; + + struct MockToolRunner; + + impl crate::tools::ToolRunner for MockToolRunner { + fn run<'a>( + &'a self, + _program: &'a str, + _args: &'a [&'a str], + ) -> Pin> + Send + 'a>> + { + Box::pin(async { + Ok(crate::tools::ToolOutput { + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + }) + }) + } + } + + #[test] + fn test_file_overlay_copy() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + std::fs::write(files.path().join("config.txt"), "hello world").unwrap(); + + let action = OverlayAction::File(FileOverlay { + destination: "/etc/config.txt".to_string(), + source: Some("config.txt".to_string()), + owner: None, + group: None, + mode: None, + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)) + .unwrap(); + + let dest = staging.path().join("etc/config.txt"); + assert!(dest.exists()); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello world"); + } + + #[test] + fn test_file_overlay_empty() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + let action = OverlayAction::File(FileOverlay { + destination: "/var/log/app.log".to_string(), + source: None, + owner: None, + group: None, + mode: None, + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)) + .unwrap(); + + let dest = staging.path().join("var/log/app.log"); + assert!(dest.exists()); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), ""); + } + + #[test] + fn test_file_overlay_missing_source() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + let action = OverlayAction::File(FileOverlay { + destination: "/etc/config.txt".to_string(), + source: Some("nonexistent.txt".to_string()), + owner: None, + group: None, + mode: None, + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + let result = + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ForgeError::OverlaySourceMissing { .. } + )); + } + + #[test] + fn test_ensure_dir() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + let action = OverlayAction::EnsureDir(EnsureDir { + path: "/var/run/myapp".to_string(), + owner: None, + group: None, + mode: Some("755".to_string()), + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)) + .unwrap(); + + assert!(staging.path().join("var/run/myapp").is_dir()); + } + + #[test] + fn test_ensure_symlink() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + let action = OverlayAction::EnsureSymlink(EnsureSymlink { + path: "/usr/bin/python".to_string(), + target: "/usr/bin/python3".to_string(), + owner: None, + group: None, + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)) + .unwrap(); + + let link = staging.path().join("usr/bin/python"); + assert!(link.symlink_metadata().is_ok()); + assert_eq!( + std::fs::read_link(&link).unwrap().to_str().unwrap(), + "/usr/bin/python3" + ); + } + + #[test] + fn test_remove_file() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + std::fs::create_dir_all(staging.path().join("etc")).unwrap(); + std::fs::write(staging.path().join("etc/unwanted.conf"), "remove me").unwrap(); + + let action = OverlayAction::RemoveFiles(RemoveFiles { + file: Some("/etc/unwanted.conf".to_string()), + dir: None, + pattern: None, + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)) + .unwrap(); + + assert!(!staging.path().join("etc/unwanted.conf").exists()); + } + + #[test] + fn test_remove_dir_contents() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + std::fs::create_dir_all(staging.path().join("var/cache/subdir")).unwrap(); + std::fs::write(staging.path().join("var/cache/file1.txt"), "a").unwrap(); + std::fs::write(staging.path().join("var/cache/file2.txt"), "b").unwrap(); + std::fs::write(staging.path().join("var/cache/subdir/file3.txt"), "c").unwrap(); + + let action = OverlayAction::RemoveFiles(RemoveFiles { + file: None, + dir: Some("/var/cache".to_string()), + pattern: None, + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)) + .unwrap(); + + // Directory itself should still exist + assert!(staging.path().join("var/cache").is_dir()); + // But contents should be gone + assert!(!staging.path().join("var/cache/file1.txt").exists()); + assert!(!staging.path().join("var/cache/subdir").exists()); + } + + #[test] + fn test_shadow_create_new() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + let action = OverlayAction::Shadow(ShadowOverlay { + username: "admin".to_string(), + password: "$6$rounds=5000$salt$hash".to_string(), + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)) + .unwrap(); + + let shadow = std::fs::read_to_string(staging.path().join("etc/shadow")).unwrap(); + assert!(shadow.contains("admin:$6$rounds=5000$salt$hash:::::::")); + } + + #[test] + fn test_shadow_update_existing() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + + std::fs::create_dir_all(staging.path().join("etc")).unwrap(); + std::fs::write( + staging.path().join("etc/shadow"), + "root:*LK*:::::::\nadmin:*LK*:::::::\n", + ) + .unwrap(); + + let action = OverlayAction::Shadow(ShadowOverlay { + username: "admin".to_string(), + password: "$6$newhash".to_string(), + }); + + let rt = tokio::runtime::Runtime::new().unwrap(); + let runner = MockToolRunner; + rt.block_on(apply_action(&action, staging.path(), files.path(), &runner)) + .unwrap(); + + let shadow = std::fs::read_to_string(staging.path().join("etc/shadow")).unwrap(); + assert!(shadow.contains("admin:$6$newhash:::::::")); + assert!(shadow.contains("root:*LK*:::::::")); + assert!(!shadow.contains("admin:*LK*:::::::")); + } + + #[tokio::test] + async fn test_apply_overlays_multiple() { + let staging = TempDir::new().unwrap(); + let files = TempDir::new().unwrap(); + let runner = MockToolRunner; + + let actions = vec![ + OverlayAction::EnsureDir(EnsureDir { + path: "/opt/myapp".to_string(), + owner: None, + group: None, + mode: None, + }), + OverlayAction::File(FileOverlay { + destination: "/opt/myapp/empty.conf".to_string(), + source: None, + owner: None, + group: None, + mode: None, + }), + ]; + + apply_overlays(&actions, staging.path(), files.path(), &runner) + .await + .unwrap(); + + assert!(staging.path().join("opt/myapp").is_dir()); + assert!(staging.path().join("opt/myapp/empty.conf").exists()); + } +} diff --git a/crates/forge-engine/src/phase2/oci.rs b/crates/forge-engine/src/phase2/oci.rs index 0f8f5c7..858be8e 100644 --- a/crates/forge-engine/src/phase2/oci.rs +++ b/crates/forge-engine/src/phase2/oci.rs @@ -50,3 +50,86 @@ pub fn build_oci( info!(path = %oci_output.display(), "OCI Image Layout written"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use spec_parser::schema::{Entrypoint, Environment, EnvVar, TargetKind}; + use tempfile::TempDir; + + fn make_target(name: &str, entrypoint: Option, env: Option) -> Target { + Target { + name: name.to_string(), + kind: TargetKind::Oci, + disk_size: None, + bootloader: None, + entrypoint, + environment: env, + pool: None, + } + } + + #[test] + fn test_build_oci_produces_layout() { + let staging = TempDir::new().unwrap(); + let output = TempDir::new().unwrap(); + + // Create some files in staging + std::fs::create_dir_all(staging.path().join("etc")).unwrap(); + std::fs::write(staging.path().join("etc/hostname"), "test-vm\n").unwrap(); + std::fs::write(staging.path().join("etc/motd"), "Welcome\n").unwrap(); + + let target = make_target("container", None, None); + build_oci(&target, staging.path(), output.path()).unwrap(); + + let oci_dir = output.path().join("container-oci"); + assert!(oci_dir.exists()); + assert!(oci_dir.join("oci-layout").exists()); + assert!(oci_dir.join("index.json").exists()); + assert!(oci_dir.join("blobs/sha256").is_dir()); + } + + #[test] + fn test_build_oci_with_entrypoint_and_env() { + let staging = TempDir::new().unwrap(); + let output = TempDir::new().unwrap(); + + std::fs::write(staging.path().join("hello.txt"), "hi").unwrap(); + + let target = make_target( + "app", + Some(Entrypoint { + command: "/bin/sh".to_string(), + }), + Some(Environment { + vars: vec![EnvVar { + key: "PATH".to_string(), + value: "/bin:/usr/bin".to_string(), + }], + }), + ); + + build_oci(&target, staging.path(), output.path()).unwrap(); + + let oci_dir = output.path().join("app-oci"); + assert!(oci_dir.join("oci-layout").exists()); + + // Verify index.json is valid + let index: serde_json::Value = serde_json::from_slice( + &std::fs::read(oci_dir.join("index.json")).unwrap(), + ) + .unwrap(); + assert_eq!(index["schemaVersion"], 2); + } + + #[test] + fn test_build_oci_empty_staging() { + let staging = TempDir::new().unwrap(); + let output = TempDir::new().unwrap(); + + let target = make_target("minimal", None, None); + build_oci(&target, staging.path(), output.path()).unwrap(); + + assert!(output.path().join("minimal-oci/oci-layout").exists()); + } +} diff --git a/crates/forge-oci/Cargo.toml b/crates/forge-oci/Cargo.toml index 2efae69..004f3c7 100644 --- a/crates/forge-oci/Cargo.toml +++ b/crates/forge-oci/Cargo.toml @@ -19,3 +19,6 @@ bytes = { workspace = true } tar = { workspace = true } flate2 = { workspace = true } walkdir = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/forge-oci/src/layout.rs b/crates/forge-oci/src/layout.rs index 84e4d74..2f13440 100644 --- a/crates/forge-oci/src/layout.rs +++ b/crates/forge-oci/src/layout.rs @@ -111,3 +111,124 @@ fn write_file(path: &Path, data: &[u8]) -> Result<(), LayoutError> { source: e, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tar_layer; + use tempfile::TempDir; + + fn make_test_layer() -> LayerBlob { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("test.txt"), "test content").unwrap(); + tar_layer::create_layer(tmp.path()).unwrap() + } + + #[test] + fn test_write_oci_layout_creates_structure() { + let output = TempDir::new().unwrap(); + let layer = make_test_layer(); + + let config_json = b"{\"test\": \"config\"}"; + let manifest_json = b"{\"test\": \"manifest\"}"; + + write_oci_layout(output.path(), &[layer], config_json, manifest_json).unwrap(); + + // Verify directory structure + assert!(output.path().join("oci-layout").exists()); + assert!(output.path().join("index.json").exists()); + assert!(output.path().join("blobs/sha256").is_dir()); + } + + #[test] + fn test_oci_layout_file_content() { + let output = TempDir::new().unwrap(); + let layer = make_test_layer(); + let config_json = b"{}"; + let manifest_json = b"{}"; + + write_oci_layout(output.path(), &[layer], config_json, manifest_json).unwrap(); + + let oci_layout: serde_json::Value = + serde_json::from_slice(&std::fs::read(output.path().join("oci-layout")).unwrap()) + .unwrap(); + assert_eq!(oci_layout["imageLayoutVersion"], "1.0.0"); + } + + #[test] + fn test_index_json_references_manifest() { + let output = TempDir::new().unwrap(); + let layer = make_test_layer(); + let config_json = b"{\"config\": true}"; + let manifest_json = b"{\"manifest\": true}"; + + write_oci_layout(output.path(), &[layer], config_json, manifest_json).unwrap(); + + let index: serde_json::Value = + serde_json::from_slice(&std::fs::read(output.path().join("index.json")).unwrap()) + .unwrap(); + + assert_eq!(index["schemaVersion"], 2); + let manifests = index["manifests"].as_array().unwrap(); + assert_eq!(manifests.len(), 1); + assert_eq!( + manifests[0]["mediaType"], + "application/vnd.oci.image.manifest.v1+json" + ); + let digest = manifests[0]["digest"].as_str().unwrap(); + assert!(digest.starts_with("sha256:")); + assert_eq!(manifests[0]["size"], manifest_json.len()); + } + + #[test] + fn test_layer_blob_written_to_blobs_dir() { + let output = TempDir::new().unwrap(); + let layer = make_test_layer(); + let digest_hex = layer.digest.strip_prefix("sha256:").unwrap().to_string(); + let expected_data = layer.data.clone(); + + write_oci_layout(output.path(), &[layer], b"{}", b"{}").unwrap(); + + let blob_path = output.path().join("blobs/sha256").join(&digest_hex); + assert!(blob_path.exists(), "Layer blob not found at {}", blob_path.display()); + assert_eq!(std::fs::read(&blob_path).unwrap(), expected_data); + } + + #[test] + fn test_config_blob_written_with_correct_digest() { + let output = TempDir::new().unwrap(); + let layer = make_test_layer(); + let config_json = b"{\"architecture\":\"amd64\"}"; + + let mut hasher = Sha256::new(); + hasher.update(config_json); + let config_digest_hex = hex::encode(hasher.finalize()); + + write_oci_layout(output.path(), &[layer], config_json, b"{}").unwrap(); + + let config_blob_path = output.path().join("blobs/sha256").join(&config_digest_hex); + assert!(config_blob_path.exists()); + assert_eq!(std::fs::read(&config_blob_path).unwrap(), config_json); + } + + #[test] + fn test_multiple_layers() { + let output = TempDir::new().unwrap(); + + let tmp1 = TempDir::new().unwrap(); + std::fs::write(tmp1.path().join("a.txt"), "aaa").unwrap(); + let layer1 = tar_layer::create_layer(tmp1.path()).unwrap(); + + let tmp2 = TempDir::new().unwrap(); + std::fs::write(tmp2.path().join("b.txt"), "bbb").unwrap(); + let layer2 = tar_layer::create_layer(tmp2.path()).unwrap(); + + let digest1 = layer1.digest.strip_prefix("sha256:").unwrap().to_string(); + let digest2 = layer2.digest.strip_prefix("sha256:").unwrap().to_string(); + + write_oci_layout(output.path(), &[layer1, layer2], b"{}", b"{}").unwrap(); + + assert!(output.path().join("blobs/sha256").join(&digest1).exists()); + assert!(output.path().join("blobs/sha256").join(&digest2).exists()); + } +} diff --git a/crates/forge-oci/src/lib.rs b/crates/forge-oci/src/lib.rs index 149dc12..612bf1c 100644 --- a/crates/forge-oci/src/lib.rs +++ b/crates/forge-oci/src/lib.rs @@ -1,3 +1,6 @@ +// thiserror/miette derive macros generate code that triggers false-positive unused_assignments +#![allow(unused_assignments)] + pub mod layout; pub mod manifest; pub mod registry; diff --git a/crates/forge-oci/src/manifest.rs b/crates/forge-oci/src/manifest.rs index c420ea7..ba802cf 100644 --- a/crates/forge-oci/src/manifest.rs +++ b/crates/forge-oci/src/manifest.rs @@ -134,3 +134,112 @@ pub fn build_manifest( Ok((config_json, manifest_json)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tar_layer; + use tempfile::TempDir; + + fn make_test_layer() -> LayerBlob { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("hello.txt"), "hello").unwrap(); + tar_layer::create_layer(tmp.path()).unwrap() + } + + #[test] + fn test_build_manifest_default_options() { + let layer = make_test_layer(); + let (config_json, manifest_json) = + build_manifest(&[layer], &ImageOptions::default()).unwrap(); + + let config: serde_json::Value = serde_json::from_slice(&config_json).unwrap(); + assert_eq!(config["os"], "solaris"); + assert_eq!(config["architecture"], "amd64"); + + let manifest: serde_json::Value = serde_json::from_slice(&manifest_json).unwrap(); + assert_eq!(manifest["schemaVersion"], 2); + assert_eq!(manifest["layers"].as_array().unwrap().len(), 1); + assert_eq!( + manifest["layers"][0]["mediaType"], + "application/vnd.oci.image.layer.v1.tar+gzip" + ); + } + + #[test] + fn test_build_manifest_with_entrypoint_and_env() { + let layer = make_test_layer(); + let options = ImageOptions { + os: "linux".to_string(), + architecture: "arm64".to_string(), + entrypoint: Some(vec!["/bin/sh".to_string(), "-c".to_string()]), + env: vec!["PATH=/bin:/usr/bin".to_string(), "HOME=/root".to_string()], + }; + + let (config_json, _manifest_json) = build_manifest(&[layer], &options).unwrap(); + + let config: serde_json::Value = serde_json::from_slice(&config_json).unwrap(); + assert_eq!(config["os"], "linux"); + assert_eq!(config["architecture"], "arm64"); + let ep = config["config"]["Entrypoint"].as_array().unwrap(); + assert_eq!(ep.len(), 2); + assert_eq!(ep[0], "/bin/sh"); + let env = config["config"]["Env"].as_array().unwrap(); + assert_eq!(env.len(), 2); + assert_eq!(env[0], "PATH=/bin:/usr/bin"); + } + + #[test] + fn test_build_manifest_multiple_layers() { + let tmp1 = TempDir::new().unwrap(); + std::fs::write(tmp1.path().join("a.txt"), "aaa").unwrap(); + let layer1 = tar_layer::create_layer(tmp1.path()).unwrap(); + + let tmp2 = TempDir::new().unwrap(); + std::fs::write(tmp2.path().join("b.txt"), "bbb").unwrap(); + let layer2 = tar_layer::create_layer(tmp2.path()).unwrap(); + + let (config_json, manifest_json) = + build_manifest(&[layer1, layer2], &ImageOptions::default()).unwrap(); + + let config: serde_json::Value = serde_json::from_slice(&config_json).unwrap(); + let diff_ids = config["rootfs"]["diff_ids"].as_array().unwrap(); + assert_eq!(diff_ids.len(), 2); + + let manifest: serde_json::Value = serde_json::from_slice(&manifest_json).unwrap(); + assert_eq!(manifest["layers"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_build_manifest_config_has_valid_digest_in_manifest() { + let layer = make_test_layer(); + let (config_json, manifest_json) = + build_manifest(&[layer], &ImageOptions::default()).unwrap(); + + let manifest: serde_json::Value = serde_json::from_slice(&manifest_json).unwrap(); + let config_digest = manifest["config"]["digest"].as_str().unwrap(); + assert!(config_digest.starts_with("sha256:")); + + // Verify digest matches actual config content + let mut hasher = Sha256::new(); + hasher.update(&config_json); + let expected = format!("sha256:{}", hex::encode(hasher.finalize())); + assert_eq!(config_digest, expected); + } + + #[test] + fn test_build_manifest_no_entrypoint() { + let layer = make_test_layer(); + let options = ImageOptions { + entrypoint: None, + env: Vec::new(), + ..Default::default() + }; + + let (config_json, _) = build_manifest(&[layer], &options).unwrap(); + let config: serde_json::Value = serde_json::from_slice(&config_json).unwrap(); + + // Config block should still exist but without entrypoint + assert!(config["config"]["Entrypoint"].is_null()); + } +} diff --git a/crates/forge-oci/src/tar_layer.rs b/crates/forge-oci/src/tar_layer.rs index 1515a3e..7553ed5 100644 --- a/crates/forge-oci/src/tar_layer.rs +++ b/crates/forge-oci/src/tar_layer.rs @@ -124,3 +124,95 @@ pub fn create_layer(staging_dir: &Path) -> Result { uncompressed_size, }) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_create_layer_empty_dir() { + let tmp = TempDir::new().unwrap(); + let layer = create_layer(tmp.path()).unwrap(); + + assert!(layer.data.len() > 0); + assert!(layer.digest.starts_with("sha256:")); + assert_eq!(layer.uncompressed_size, 0); + } + + #[test] + fn test_create_layer_with_file() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("hello.txt"), "Hello, world!").unwrap(); + + let layer = create_layer(tmp.path()).unwrap(); + + assert!(layer.digest.starts_with("sha256:")); + assert_eq!(layer.uncompressed_size, 13); // "Hello, world!" = 13 bytes + assert!(layer.data.len() > 0); + + // Verify we can decompress the gzip layer + let decoder = flate2::read::GzDecoder::new(layer.data.as_slice()); + let mut archive = tar::Archive::new(decoder); + let entries: Vec<_> = archive.entries().unwrap().collect(); + assert_eq!(entries.len(), 1); + } + + #[test] + fn test_create_layer_with_nested_dirs() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join("etc/ssh")).unwrap(); + fs::write(tmp.path().join("etc/ssh/sshd_config"), "Port 22\n").unwrap(); + fs::write(tmp.path().join("etc/hostname"), "test\n").unwrap(); + + let layer = create_layer(tmp.path()).unwrap(); + + // Decompress and verify structure + let decoder = flate2::read::GzDecoder::new(layer.data.as_slice()); + let mut archive = tar::Archive::new(decoder); + let paths: Vec = archive + .entries() + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path().unwrap().display().to_string()) + .collect(); + + assert!(paths.contains(&"etc".to_string())); + assert!(paths.contains(&"etc/ssh".to_string())); + assert!(paths.contains(&"etc/ssh/sshd_config".to_string())); + assert!(paths.contains(&"etc/hostname".to_string())); + } + + #[test] + fn test_create_layer_with_symlink() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("target.txt"), "data").unwrap(); + std::os::unix::fs::symlink("target.txt", tmp.path().join("link.txt")).unwrap(); + + let layer = create_layer(tmp.path()).unwrap(); + + let decoder = flate2::read::GzDecoder::new(layer.data.as_slice()); + let mut archive = tar::Archive::new(decoder); + let mut found_symlink = false; + for entry in archive.entries().unwrap() { + let entry = entry.unwrap(); + if entry.path().unwrap().display().to_string() == "link.txt" { + assert_eq!(entry.header().entry_type(), tar::EntryType::Symlink); + found_symlink = true; + } + } + assert!(found_symlink, "symlink entry not found in tar"); + } + + #[test] + fn test_digest_is_deterministic() { + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("file.txt"), "deterministic content").unwrap(); + + let layer1 = create_layer(tmp.path()).unwrap(); + let layer2 = create_layer(tmp.path()).unwrap(); + + assert_eq!(layer1.digest, layer2.digest); + } +} diff --git a/crates/spec-parser/src/lib.rs b/crates/spec-parser/src/lib.rs index 76de7e3..5c5cfca 100644 --- a/crates/spec-parser/src/lib.rs +++ b/crates/spec-parser/src/lib.rs @@ -1,3 +1,6 @@ +// thiserror/miette derive macros generate code that triggers false-positive unused_assignments +#![allow(unused_assignments)] + pub mod profile; pub mod resolve; pub mod schema; @@ -7,14 +10,12 @@ use thiserror::Error; #[derive(Debug, Error, Diagnostic)] pub enum ParseError { - #[error("Failed to parse KDL spec")] + #[error("Failed to parse KDL spec: {detail}")] #[diagnostic( help("Check the KDL syntax in your spec file"), code(spec_parser::kdl_parse) )] - KdlError { - detail: String, - }, + KdlError { detail: String }, } impl From for ParseError {