mirror of
https://github.com/CloudNebulaProject/refraction-forger.git
synced 2026-04-10 13:20:40 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
48f8db1236
commit
4290439e00
11 changed files with 781 additions and 4 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -563,6 +563,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Box<dyn std::future::Future<Output = Result<crate::tools::ToolOutput, ForgeError>> + 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Entrypoint>, env: Option<Environment>) -> 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,3 +19,6 @@ bytes = { workspace = true }
|
|||
tar = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,3 +124,95 @@ pub fn create_layer(staging_dir: &Path) -> Result<LayerBlob, TarLayerError> {
|
|||
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<String> = 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<knuffel::Error> for ParseError {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue