From 68d6f96808c40c04637624c9077d42bcfc02c6dd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 21:57:07 +0200 Subject: [PATCH] Seal: one seal_artifact for both targets; link containment decided at seal by a shape rule collect_artifact and host.rs materialize both call seal_artifact. digest_tree judges every link with check_release_link (relative, `..` only first, climbs no higher than the link's depth); validate_release_symlink and harden's link judgement are gone. hash_file_exact owns the streamed-length check. A file directly under a tree's top is pinned 0444. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 6 + src/drivers.rs | 297 ++++++++++++++++++++++++++++++------------------- src/host.rs | 48 ++------ 3 files changed, 201 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 4936cbe..a5c48bc 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,12 @@ member is absolute, climbs with `..`, is missing, is a symlink or sits behind one, or is not a regular file; `executable = true` on a directory output and `executable_members` on a file output are refused too. +A tree may hold relative symlinks whose `..` steps all come first and climb no +higher than the artifact's top (`share/terminfo/4/4025ex -> ../t/tek4025ex` is +kept). An absolute link, a `..` after a name, or a climb out of the artifact +refuses the release when it is sealed. Host targets (Windows) record +`executable` and `executable_members` in the receipt and do not apply them. + ```toml # recipe fragment — the upstream artifact declares the child it launches [[artifacts]] diff --git a/src/drivers.rs b/src/drivers.rs index 2dfeda6..d82e5ba 100644 --- a/src/drivers.rs +++ b/src/drivers.rs @@ -2302,29 +2302,7 @@ impl DockerRunnerDriver { } ArtifactSource::WorktreeTree => (source.root.clone(), source.root.join(&artifact.source)), }; - ensure!(source_path.exists(), "declared artifact output is absent"); - let destination = staging_root.join(&artifact.destination); - ensure!( - destination.starts_with(staging_root), - "artifact destination escaped its release staging root" - ); - copy_artifact(&containment_root, &source_path, &destination)?; - check_artifact_modes(artifact, &destination)?; - let (sha256, size_bytes) = digest_artifact(&destination)?; - if let Some(expected) = &artifact.expected_sha256 { - ensure!( - &sha256 == expected, - "artifact output differs from its recipe-pinned digest" - ); - } - Ok(ArtifactReceipt { - artifact_id: artifact.id.clone(), - destination: artifact.destination.clone(), - sha256: format!("sha256-{sha256}"), - size_bytes, - executable: artifact.executable, - executable_members: artifact.executable_members.clone(), - }) + seal_artifact(artifact, &containment_root, &source_path, staging_root) } } @@ -6812,10 +6790,7 @@ fn hash_frozen_source_tree(root: &Path, current: &Path, hasher: &mut Sha256) -> ); hasher.update(b"\0"); hasher.update(metadata.len().to_le_bytes()); - ensure!( - hash_file_streamed(&path, hasher)? == metadata.len(), - "frozen source file changed while hashing" - ); + hash_file_exact(&path, metadata.len(), hasher)?; } else if metadata.file_type().is_symlink() { let target = fs::read_link(&path)?; hasher.update(b"link\0"); @@ -6847,7 +6822,7 @@ fn harden_installed_release(root: &Path, artifacts: &[ArtifactReceipt]) -> Resul executable_paths.insert(destination); } } - harden_root_tree(root, root, &mut executable_paths)?; + harden_root_tree(root, &mut executable_paths)?; ensure!( executable_paths.is_empty(), "declared executable is missing or not a regular file" @@ -6858,11 +6833,7 @@ fn harden_installed_release(root: &Path, artifacts: &[ArtifactReceipt]) -> Resul /// Each regular file the walk seals 0555 is removed from `executable_paths`; /// whatever remains was declared executable and never sealed. #[cfg(unix)] -fn harden_root_tree( - root: &Path, - current: &Path, - executable_paths: &mut BTreeSet, -) -> Result<()> { +fn harden_root_tree(current: &Path, executable_paths: &mut BTreeSet) -> Result<()> { use std::os::unix::fs::{MetadataExt, PermissionsExt}; let metadata = fs::symlink_metadata(current)?; @@ -6874,7 +6845,7 @@ fn harden_root_tree( let mut entries = fs::read_dir(current)?.collect::>>()?; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { - harden_root_tree(root, &entry.path(), &mut *executable_paths)?; + harden_root_tree(&entry.path(), &mut *executable_paths)?; } fs::set_permissions(current, fs::Permissions::from_mode(0o555))?; } else if metadata.is_file() { @@ -6887,35 +6858,15 @@ fn harden_root_tree( }), )?; } else if metadata.file_type().is_symlink() { - validate_release_symlink(root, current)?; + // Left untouched: containment was decided by check_release_link in + // digest_tree, which install_release and validate_installed_release + // run over every receipt before this walk (map T16). } else { bail!("sealed release contains a special filesystem entry") } Ok(()) } -#[cfg(unix)] -fn validate_release_symlink(root: &Path, path: &Path) -> Result<()> { - let target = fs::read_link(path)?; - ensure!(target.is_relative(), "sealed release symlink is absolute"); - ensure!( - target.components().all(|component| matches!( - component, - std::path::Component::CurDir | std::path::Component::Normal(_) - )), - "sealed release symlink has a parent traversal" - ); - let destination = path - .parent() - .context("sealed release symlink has no parent")? - .join(target); - ensure!( - destination.starts_with(root), - "sealed release symlink escaped its release" - ); - Ok(()) -} - #[cfg(not(unix))] fn harden_installed_release(_root: &Path, _artifacts: &[ArtifactReceipt]) -> Result<()> { bail!("systemd workload installation requires Unix permissions") @@ -8105,7 +8056,7 @@ fn copy_symlink(source: &Path, destination: &Path) -> Result<()> { /// checked to contain the fully resolved `source` with the same /// whole-chain-aware resolver `validate_frozen_source_symlink` uses, before /// anything is read from it. -pub(crate) fn copy_artifact(root: &Path, source: &Path, destination: &Path) -> Result<()> { +fn copy_artifact(root: &Path, source: &Path, destination: &Path) -> Result<()> { let canonical_root = root.canonicalize().context("resolving artifact root")?; let canonical_source = source .canonicalize() @@ -8133,7 +8084,7 @@ pub(crate) fn copy_artifact(root: &Path, source: &Path, destination: &Path) -> R /// a file output never declares members, and every member is a regular file /// reached through real directories, never through a symlink. Errors name the /// rule, never the member path. -pub(crate) fn check_artifact_modes(artifact: &ArtifactOutput, destination: &Path) -> Result<()> { +fn check_artifact_modes(artifact: &ArtifactOutput, destination: &Path) -> Result<()> { let is_dir = fs::symlink_metadata(destination) .context("inspecting a copied artifact")? .is_dir(); @@ -8179,30 +8130,73 @@ pub(crate) fn check_artifact_modes(artifact: &ArtifactOutput, destination: &Path Ok(()) } +/// The one seal step for a declared artifact, for systemd and host targets +/// alike: copy it into the staging root, check its mode declarations, digest it +/// and check the recipe pin. Modes are applied by harden on systemd targets; +/// host targets (Windows, map T15) carry `executable` and `executable_members` +/// in the receipt and do not apply them. +pub(crate) fn seal_artifact( + artifact: &ArtifactOutput, + containment_root: &Path, + source_path: &Path, + staging_root: &Path, +) -> Result { + ensure!( + source_path.exists(), + "declared artifact output {} is absent", + artifact.id + ); + let destination = staging_root.join(&artifact.destination); + ensure!( + destination.starts_with(staging_root), + "artifact {} destination escaped its staging root", + artifact.id + ); + copy_artifact(containment_root, source_path, &destination)?; + check_artifact_modes(artifact, &destination)?; + let (sha256, size_bytes) = digest_artifact(&destination)?; + if let Some(expected) = &artifact.expected_sha256 { + ensure!( + &sha256 == expected, + "artifact {} differs from its recipe-pinned digest", + artifact.id + ); + } + Ok(ArtifactReceipt { + artifact_id: artifact.id.clone(), + destination: artifact.destination.clone(), + sha256: format!("sha256-{sha256}"), + size_bytes, + executable: artifact.executable, + executable_members: artifact.executable_members.clone(), + }) +} + /// Streams `path` into `hasher` in 64 KiB chunks, so peak memory is the buffer -/// and not the largest file in the tree. Returns the bytes read; a caller that -/// hashed a length first must check the two agree. -pub(crate) fn hash_file_streamed(path: &Path, hasher: &mut Sha256) -> Result { +/// and not the largest file in the tree, and refuses if the bytes read differ +/// from `expected_len`. The only owner of the length check. +pub(crate) fn hash_file_exact(path: &Path, expected_len: u64, hasher: &mut Sha256) -> Result<()> { let mut file = fs::File::open(path).context("opening a file to hash it")?; let mut buffer = [0u8; 1 << 16]; let mut total = 0_u64; loop { let read = file.read(&mut buffer).context("reading a file to hash it")?; if read == 0 { - return Ok(total); + break; } hasher.update(&buffer[..read]); total += read as u64; } + ensure!(total == expected_len, "file changed while hashing"); + Ok(()) } pub(crate) fn digest_artifact(path: &Path) -> Result<(String, u64)> { let metadata = fs::symlink_metadata(path)?; if metadata.is_file() { let mut hasher = Sha256::new(); - let streamed = hash_file_streamed(path, &mut hasher)?; - ensure!(streamed == metadata.len(), "artifact file changed while hashing"); - return Ok((format!("{:x}", hasher.finalize()), streamed)); + hash_file_exact(path, metadata.len(), &mut hasher)?; + return Ok((format!("{:x}", hasher.finalize()), metadata.len())); } ensure!(metadata.is_dir(), "artifact is not a file or directory"); let mut hasher = Sha256::new(); @@ -8231,13 +8225,11 @@ fn digest_tree(root: &Path, current: &Path, hasher: &mut Sha256, size: &mut u64) hasher.update(relative.as_bytes()); hasher.update(b"\0"); hasher.update(length.to_le_bytes()); - ensure!( - hash_file_streamed(&path, hasher)? == length, - "artifact file changed while hashing" - ); + hash_file_exact(&path, length, hasher)?; *size = size.saturating_add(length); } else if metadata.file_type().is_symlink() { let target = fs::read_link(&path)?; + check_release_link(path.strip_prefix(root)?, &target)?; hasher.update(b"link\0"); hasher.update(relative.as_bytes()); hasher.update(b"\0"); @@ -8250,6 +8242,35 @@ fn digest_tree(root: &Path, current: &Path, hasher: &mut Sha256, size: &mut u64) Ok(()) } +/// The one judge of link containment in a release tree, pure and static: +/// `link` is the link's path relative to its artifact's top and `target` what +/// it reads. A target is relative, its `..` steps all come first, and they +/// climb no higher than the link's own directory depth, so the link can only +/// land inside the artifact. Lexical normalisation of parent and target is not +/// enough: with `s -> ..` it admits `s/../x` and escapes (map T14). +fn check_release_link(link: &Path, target: &Path) -> Result<()> { + use std::path::Component; + + let depth = link.components().count().saturating_sub(1); + let mut climbs = 0_usize; + let mut named = false; + for component in target.components() { + match component { + Component::CurDir => {} + Component::RootDir | Component::Prefix(_) => { + bail!("sealed release symlink is absolute") + } + Component::ParentDir => { + ensure!(!named, "sealed release symlink climbs after a name"); + climbs += 1; + } + Component::Normal(_) => named = true, + } + } + ensure!(climbs <= depth, "sealed release symlink escapes its artifact"); + Ok(()) +} + pub(crate) fn raw_sha256(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } @@ -13996,6 +14017,7 @@ processes 9 write_file(&tree.join("bin/helper"), b"helper")?; write_file(&tree.join("lib/tool"), b"tool")?; write_file(&tree.join("lib/data.txt"), b"data")?; + write_file(&tree.join("NOTICE"), b"notice")?; Ok((source, staging)) } @@ -14061,12 +14083,44 @@ processes 9 assert_eq!(sealed_mode(&tree.join("lib/tool")), 0o555); assert_eq!(sealed_mode(&tree.join("bin/helper")), 0o444); assert_eq!(sealed_mode(&tree.join("lib/data.txt")), 0o444); + assert_eq!(sealed_mode(&tree.join("NOTICE")), 0o444); for directory in [&staging, &tree, &tree.join("bin"), &tree.join("lib")] { assert_eq!(sealed_mode(directory), 0o555); } Ok(()) } + #[test] + fn release_link_rule_is_containment() { + for (link, target) in [ + ("a/b/l", "../t/x"), + ("a/l", ".."), + ("a/l", "./../b"), + ("a/l", "b/./c"), + ("l", "python3.12"), + ("a/b/c/l", "../../x"), + ] { + check_release_link(Path::new(link), Path::new(target)) + .unwrap_or_else(|error| panic!("({link}, {target}) refused: {error:#}")); + } + for (link, target, rule) in [ + ("l", "/x", "absolute"), + ("a/l", "s/../x", "climbs after a name"), + ("a/l", "D/D/../../../etc/passwd", "climbs after a name"), + ("a/l", "../../x", "escapes its artifact"), + ("l", "..", "escapes its artifact"), + ("a/b/l", "../../../x", "escapes its artifact"), + ("a/l", "./s/../x", "climbs after a name"), + ] { + let text = format!( + "{:#}", + check_release_link(Path::new(link), Path::new(target)) + .expect_err(&format!("({link}, {target}) was admitted")) + ); + assert!(text.contains(rule), "({link}, {target}): {text}"); + } + } + #[cfg(unix)] #[test] fn missing_member_refuses_the_release() -> Result<()> { @@ -14250,6 +14304,9 @@ processes 9 let tree = source.join("upstream"); write_file(&tree.join("bin/python3.12"), b"python")?; std::os::unix::fs::symlink("python3.12", tree.join("bin/python3"))?; + write_file(&tree.join("lib/terminfo/b/real"), b"terminfo")?; + fs::create_dir_all(tree.join("lib/terminfo/a"))?; + std::os::unix::fs::symlink("../b/real", tree.join("lib/terminfo/a/alias"))?; let receipt = collect_tree(&source, &staging, tree_output(false, &["bin/python3.12"]))?; let (before, _) = digest_artifact(&staging.join("upstream"))?; harden_installed_release(&staging, &[receipt])?; @@ -14257,18 +14314,22 @@ processes 9 assert!(fs::symlink_metadata(&link)?.file_type().is_symlink()); assert_eq!(fs::read_link(&link)?, PathBuf::from("python3.12")); assert_eq!(sealed_mode(&staging.join("upstream/bin/python3.12")), 0o555); + let alias = staging.join("upstream/lib/terminfo/a/alias"); + assert!(fs::symlink_metadata(&alias)?.file_type().is_symlink()); + assert_eq!(fs::read_link(&alias)?, PathBuf::from("../b/real")); assert_eq!(digest_artifact(&staging.join("upstream"))?.0, before); - // A link out of the release, by `..` or by absolute target, still refuses. + // A link that escapes, is absolute or climbs after a name is refused at + // the seal, before any receipt exists. for (target, rule) in [ - ("../../zzcanary-up", "parent traversal"), + ("../../zzcanary-up", "escapes its artifact"), ("/zzcanary/absolute", "absolute"), + ("zzcanary-s/../x", "climbs after a name"), ] { let temp = tempfile::tempdir()?; let (source, staging) = sample_tree(temp.path())?; std::os::unix::fs::symlink(target, source.join("upstream/lib/zzcanary-link"))?; - let receipt = collect_tree(&source, &staging, tree_output(false, &["bin/daemon"]))?; - let text = refusal_text(harden_installed_release(&staging, &[receipt])); + let text = refusal_text(collect_tree(&source, &staging, tree_output(false, &["bin/daemon"]))); assert!(text.contains(rule), "{text}"); } Ok(()) @@ -14318,8 +14379,12 @@ processes 9 (raw_sha256(&big), big.len() as u64) ); let mut hasher = Sha256::new(); - assert_eq!(hash_file_streamed(&single, &mut hasher)?, big.len() as u64); + hash_file_exact(&single, big.len() as u64, &mut hasher)?; assert_eq!(format!("{:x}", hasher.finalize()), raw_sha256(&big)); + for wrong in [big.len() as u64 + 1, big.len() as u64 - 1] { + let text = refusal_text(hash_file_exact(&single, wrong, &mut Sha256::new())); + assert!(text.contains("changed while hashing"), "{text}"); + } Ok(()) } @@ -14331,18 +14396,40 @@ processes 9 fn big_tree_seals_from_env() -> Result<()> { use std::time::Instant; - fn symlinks(path: &Path) -> Result { - let mut count = 0; + /// (links, links with a `..` step, regular files, files at 0555), never + /// following a link; every regular file must be 0444 or a member's 0555. + fn census( + root: &Path, + path: &Path, + members: &BTreeSet, + modes: bool, + totals: &mut [usize; 4], + ) -> Result<()> { for entry in fs::read_dir(path)? { let entry = entry?; let metadata = fs::symlink_metadata(entry.path())?; if metadata.is_dir() { - count += symlinks(&entry.path())?; + census(root, &entry.path(), members, modes, totals)?; } else if metadata.file_type().is_symlink() { - count += 1; + totals[0] += 1; + let target = fs::read_link(entry.path())?; + if target + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + totals[1] += 1; + } + } else { + totals[2] += 1; + let member = members.contains(entry.path().strip_prefix(root)?); + let mode = sealed_mode(&entry.path()); + if modes { + assert_eq!(mode, if member { 0o555 } else { 0o444 }); + totals[3] += usize::from(mode == 0o555); + } } } - Ok(count) + Ok(()) } fn peak_rss() -> String { fs::read_to_string("/proc/self/status") @@ -14370,41 +14457,27 @@ processes 9 }; let started = Instant::now(); - copy_artifact(&tree, &tree, &destination)?; - println!("copy {:?} {}", started.elapsed(), peak_rss()); + let receipt = seal_artifact(&output, &tree, &tree, &staging)?; + println!("seal {:?} {}", started.elapsed(), peak_rss()); let started = Instant::now(); - let first = digest_artifact(&destination)?; - let second = digest_artifact(&destination)?; - assert_eq!(first, second); - println!("digest x2 {:?} {}", started.elapsed(), peak_rss()); - check_artifact_modes(&output, &destination)?; - let links = symlinks(&destination)?; + let again = digest_artifact(&destination)?; + assert_eq!(format!("sha256-{}", again.0), receipt.sha256); + assert_eq!(again.1, receipt.size_bytes); + println!("digest {:?} {}", started.elapsed(), peak_rss()); + let mut before = [0; 4]; + census(&destination, &destination, &output.executable_members, false, &mut before)?; let started = Instant::now(); - harden_installed_release( - &staging, - &[ArtifactReceipt { - artifact_id: "tree".into(), - destination: PathBuf::from("tree"), - sha256: format!("sha256-{}", first.0), - size_bytes: first.1, - executable: false, - executable_members: output.executable_members.clone(), - }], - )?; + harden_installed_release(&staging, &[receipt.clone()])?; println!("harden {:?} {}", started.elapsed(), peak_rss()); - for member in members { - assert_eq!(sealed_mode(&destination.join(member)), 0o555); - } - let sample = fs::read_dir(&destination)? - .map(|entry| entry.map(|entry| entry.path())) - .collect::>>()?; - for path in sample { - if fs::symlink_metadata(&path)?.is_file() { - assert_eq!(sealed_mode(&path), 0o444); - } - } - assert_eq!(symlinks(&destination)?, links); - println!("bytes {} symlinks {links}", first.1); + let mut totals = [0; 4]; + census(&destination, &destination, &output.executable_members, true, &mut totals)?; + assert_eq!(totals[0], before[0], "links changed across harden"); + assert_eq!(totals[3], members.len()); + println!( + "dotdot links {} links before {} after {} files {} exec {}", + totals[1], before[0], totals[0], totals[2], totals[3] + ); + println!("bytes {}", receipt.size_bytes); Ok(()) } diff --git a/src/host.rs b/src/host.rs index 6d05478..4c6cbec 100644 --- a/src/host.rs +++ b/src/host.rs @@ -30,14 +30,13 @@ use serde::Deserialize; use crate::control_plane::read_trust_anchor; use crate::deployment::{ - ArtifactSource, HostWorkloadBinding, IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + HostWorkloadBinding, IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, LaunchArgument, OperatorBinding, TargetDeclaration, }; -use crate::deployment_plan::{ArtifactReceipt, CompiledDeploymentPlan, SealedRelease}; +use crate::deployment_plan::{CompiledDeploymentPlan, SealedRelease}; use crate::drivers::{ - HostWorkloadObservation, InstalledReleaseObservation, check_artifact_modes, copy_artifact, - copy_tree, digest_artifact, release_artifact, remove_tree_inside, sha256_id, - write_runtime_bundle_records, + HostWorkloadObservation, InstalledReleaseObservation, copy_tree, digest_artifact, + release_artifact, remove_tree_inside, seal_artifact, sha256_id, write_runtime_bundle_records, }; use crate::host_actuator::{ HOST_ACTUATOR_CHANNEL, HOST_ACTUATOR_CONNECTION_ID, HOST_ACTUATOR_PING_MS, @@ -1049,39 +1048,12 @@ fn materialize( fs::create_dir_all(&staging)?; let mut artifacts = Vec::new(); for artifact in &declaration.artifacts { - let source_path = match artifact.source_kind { - ArtifactSource::RunnerOutput | ArtifactSource::WorktreeTree => { - source.join(&artifact.source) - } - }; - ensure!( - source_path.exists(), - "declared artifact output {} is absent", - artifact.id - ); - let destination = staging.join(&artifact.destination); - ensure!( - destination.starts_with(&staging), - "artifact destination escaped its staging root" - ); - copy_artifact(&source, &source_path, &destination)?; - check_artifact_modes(artifact, &destination)?; - let (sha256, size_bytes) = digest_artifact(&destination)?; - if let Some(expected) = &artifact.expected_sha256 { - ensure!( - &sha256 == expected, - "artifact {} differs from its recipe-pinned digest", - artifact.id - ); - } - artifacts.push(ArtifactReceipt { - artifact_id: artifact.id.clone(), - destination: artifact.destination.clone(), - sha256: format!("sha256-{sha256}"), - size_bytes, - executable: artifact.executable, - executable_members: artifact.executable_members.clone(), - }); + artifacts.push(seal_artifact( + artifact, + &source, + &source.join(&artifact.source), + &staging, + )?); } let release = SealedRelease::new(plan, artifacts, Vec::new(), sealed_at_unix_millis)?; Ok((release, staging))