Merge idunn/tree-executable-members: tree artifacts declare executable members
Cut idunn-deploy-provenance:cut_spec:cut-tree-executable-members.r2, reports h1-h3, Soul s1-s3. Members sealed 0555, the rest 0444; link containment decided once in the digest walk (leading dotdot within depth, no absolute); one seal step for collect and host. Real PBS CPython+torch and Blender trees seal with all 1128 links kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d3222e5724
5 changed files with 960 additions and 116 deletions
26
README.md
26
README.md
|
|
@ -181,6 +181,32 @@ required_environment = [
|
|||
]
|
||||
```
|
||||
|
||||
A directory artifact is sealed read-only: its files are 0444 and its
|
||||
directories 0555, whatever mode the build or git gave them. A file the tree
|
||||
must run, such as the launcher's `upstream/bin/daemon` child, is declared in
|
||||
`executable_members` and sealed 0555. The members are part of the sealed release
|
||||
address, so changing the list makes a new release. Idunn refuses the release if a
|
||||
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]]
|
||||
id = "upstream"
|
||||
source_kind = "runner-output"
|
||||
runner = "build"
|
||||
source = "dist/upstream"
|
||||
destination = "upstream"
|
||||
executable_members = ["bin/daemon"]
|
||||
```
|
||||
|
||||
```toml
|
||||
# binding fragment — members connect to the member endpoint, which Idunn
|
||||
# forwards to the private port derived from the candidate: member
|
||||
|
|
|
|||
|
|
@ -113,6 +113,10 @@ pub struct ArtifactOutput {
|
|||
pub expected_sha256: Option<String>,
|
||||
#[serde(default)]
|
||||
pub executable: bool,
|
||||
/// Regular files inside a directory artifact that are sealed executable
|
||||
/// (0555); every other file in the tree is sealed 0444.
|
||||
#[serde(default)]
|
||||
pub executable_members: BTreeSet<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -912,6 +916,14 @@ impl TargetDeclaration {
|
|||
if let Some(expected_sha256) = &artifact.expected_sha256 {
|
||||
require_sha256(expected_sha256, "expected artifact sha256")?;
|
||||
}
|
||||
ensure!(
|
||||
!(artifact.executable && !artifact.executable_members.is_empty()),
|
||||
"artifact {} declares both executable and executable_members",
|
||||
artifact.id
|
||||
);
|
||||
for member in &artifact.executable_members {
|
||||
require_relative_path(member, "executable member", false)?;
|
||||
}
|
||||
ensure!(
|
||||
destinations.insert(artifact.destination.as_path()),
|
||||
"artifact destination {} is declared twice",
|
||||
|
|
@ -2211,6 +2223,76 @@ nodes = ["yggdrasil"]
|
|||
assert!(TargetDeclaration::parse(&input).is_err());
|
||||
}
|
||||
|
||||
const MEMBER_CANARY: &str = "zzcanary";
|
||||
|
||||
fn recipe_with_members(members: &str) -> String {
|
||||
let input = RECIPE.replace("executable = true", &format!("executable_members = [{members}]"));
|
||||
assert_ne!(input, RECIPE, "the fixture lost its executable line");
|
||||
input
|
||||
}
|
||||
|
||||
/// A refused member list is refused by name of the field, never by echoing
|
||||
/// the member.
|
||||
fn assert_members_refused(members: &[&str]) {
|
||||
let control = TargetDeclaration::parse(&recipe_with_members("\"bin/daemon\"")).unwrap();
|
||||
assert!(control.artifacts[0].executable_members.contains(Path::new("bin/daemon")));
|
||||
for member in members {
|
||||
let error = TargetDeclaration::parse(&recipe_with_members(&format!("\"{member}\"")))
|
||||
.expect_err(member);
|
||||
assert!(
|
||||
!format!("{error:#}").contains(MEMBER_CANARY),
|
||||
"the refusal echoed a member path"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_executable_member_refuses_parent_traversal() {
|
||||
assert_members_refused(&[
|
||||
"zzcanary/../b",
|
||||
"../zzcanary",
|
||||
"bin/zzcanary/..",
|
||||
"bin/../../zzcanary",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_executable_member_refuses_absolute_path() {
|
||||
assert_members_refused(&["/zzcanary/sh", "/zzcanary"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_executable_member_refuses_empty_and_current_dir() {
|
||||
assert_members_refused(&["", ".", "./", "zzcanary/.."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_refuses_executable_flag_with_members() {
|
||||
let input = RECIPE.replace(
|
||||
"executable = true",
|
||||
"executable = true\nexecutable_members = [\"bin/daemon\"]",
|
||||
);
|
||||
let error = TargetDeclaration::parse(&input).unwrap_err();
|
||||
assert!(format!("{error:#}").contains("executable_members"));
|
||||
assert!(!format!("{error:#}").contains("bin/daemon"));
|
||||
// executable = true alone, and members alone, both stay admitted.
|
||||
assert!(TargetDeclaration::parse(RECIPE).is_ok());
|
||||
assert!(TargetDeclaration::parse(&recipe_with_members("\"bin/daemon\"")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_admits_members_on_both_source_kinds() {
|
||||
let runner_output = TargetDeclaration::parse(&recipe_with_members("\"a/b\", \"c\"")).unwrap();
|
||||
assert_eq!(runner_output.artifacts[0].source_kind, ArtifactSource::RunnerOutput);
|
||||
assert_eq!(runner_output.artifacts[0].executable_members.len(), 2);
|
||||
let worktree = recipe_with_members("\"a/b\", \"c\"")
|
||||
.replace("source_kind = \"runner-output\"\nrunner = \"rust\"\n", "source_kind = \"worktree-tree\"\n")
|
||||
.replace("expected_sha256 = \"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"\n", "");
|
||||
let worktree = TargetDeclaration::parse(&worktree).unwrap();
|
||||
assert_eq!(worktree.artifacts[0].source_kind, ArtifactSource::WorktreeTree);
|
||||
assert_eq!(worktree.artifacts[0].executable_members.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_cannot_omit_an_affordance_derived_from_the_recipe() {
|
||||
let recipe = TargetDeclaration::parse(RECIPE).unwrap();
|
||||
|
|
|
|||
|
|
@ -734,6 +734,11 @@ pub struct ArtifactReceipt {
|
|||
pub sha256: String,
|
||||
pub size_bytes: u64,
|
||||
pub executable: bool,
|
||||
/// Files sealed executable inside a directory artifact. Skipped when empty
|
||||
/// so a receipt with no members keeps its original encoding, and so its
|
||||
/// sealed release id.
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub executable_members: BTreeSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl ArtifactReceipt {
|
||||
|
|
@ -747,7 +752,9 @@ impl ArtifactReceipt {
|
|||
.find(|artifact| artifact.id == self.artifact_id)
|
||||
.with_context(|| format!("plan declares no artifact {}", self.artifact_id))?;
|
||||
ensure!(
|
||||
self.destination == declared.destination && self.executable == declared.executable,
|
||||
self.destination == declared.destination
|
||||
&& self.executable == declared.executable
|
||||
&& self.executable_members == declared.executable_members,
|
||||
"artifact receipt differs from declared output"
|
||||
);
|
||||
if let Some(expected_sha256) = &declared.expected_sha256 {
|
||||
|
|
@ -1425,6 +1432,7 @@ nodes = ["yggdrasil"]
|
|||
sha256: digest(b'c'),
|
||||
size_bytes: 42,
|
||||
executable: true,
|
||||
executable_members: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1791,6 +1799,113 @@ nodes = ["yggdrasil"]
|
|||
assert!(changed.validate_against(&plan).is_err());
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_without_members_encodes_as_before() {
|
||||
// Both values were computed outside this code, from the five-field
|
||||
// msgpack array the receipt encoded to at base d74ac8d.
|
||||
const BASE_RECEIPT: &str = "95a66461656d6f6ea773657276696365d9477368613235362d636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363632ac3";
|
||||
const BASE_RELEASE_ID: &str =
|
||||
"sha256-b9143d8f546ad71c493c70037813547aaec10fd87994a6f3bdc77ac3a8b3495e";
|
||||
let receipt = artifact_receipt();
|
||||
let encoded = rmp_serde::to_vec(&receipt).unwrap();
|
||||
assert_eq!(hex(&encoded), BASE_RECEIPT);
|
||||
let bytes = (0..BASE_RECEIPT.len())
|
||||
.step_by(2)
|
||||
.map(|at| u8::from_str_radix(&BASE_RECEIPT[at..at + 2], 16).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(rmp_serde::from_slice::<ArtifactReceipt>(&bytes).unwrap(), receipt);
|
||||
let release = SealedRelease {
|
||||
schema: SEALED_RELEASE_SCHEMA.into(),
|
||||
sealed_release_id: String::new(),
|
||||
plan_id: digest(b'a'),
|
||||
artifacts: vec![receipt],
|
||||
external_inputs: vec![],
|
||||
sealed_at_unix_millis: 0,
|
||||
};
|
||||
assert_eq!(release.recomputed_release_id().unwrap(), BASE_RELEASE_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_release_address_covers_executable_members() {
|
||||
let release_with = |members: &[&str]| SealedRelease {
|
||||
schema: SEALED_RELEASE_SCHEMA.into(),
|
||||
sealed_release_id: String::new(),
|
||||
plan_id: digest(b'a'),
|
||||
artifacts: vec![ArtifactReceipt {
|
||||
executable: false,
|
||||
executable_members: members.iter().map(PathBuf::from).collect(),
|
||||
..artifact_receipt()
|
||||
}],
|
||||
external_inputs: vec![],
|
||||
sealed_at_unix_millis: 0,
|
||||
};
|
||||
let none = release_with(&[]).recomputed_release_id().unwrap();
|
||||
let one = release_with(&["bin/daemon"]).recomputed_release_id().unwrap();
|
||||
let other = release_with(&["bin/other"]).recomputed_release_id().unwrap();
|
||||
let two = release_with(&["bin/daemon", "bin/other"])
|
||||
.recomputed_release_id()
|
||||
.unwrap();
|
||||
let all = [&none, &one, &other, &two];
|
||||
for (index, left) in all.iter().enumerate() {
|
||||
for right in &all[index + 1..] {
|
||||
assert_ne!(left, right);
|
||||
}
|
||||
}
|
||||
|
||||
// The declaration is the other half: a receipt must carry exactly the
|
||||
// declared list, in a plan that declares one and in one that does not.
|
||||
let plain = plan();
|
||||
let mut with_members = artifact_receipt();
|
||||
with_members.executable_members = BTreeSet::from([PathBuf::from("bin/daemon")]);
|
||||
assert!(
|
||||
SealedRelease::new(&plain, vec![with_members], vec![external_input_receipt()], 120)
|
||||
.is_err()
|
||||
);
|
||||
let recipe = RECIPE.replace("executable = true", "executable_members = [\"bin/daemon\", \"lib/tool\"]");
|
||||
let declaring = compile_deployment_plan(
|
||||
recipe.as_bytes(),
|
||||
BINDING.as_bytes(),
|
||||
source(&recipe),
|
||||
"service-incarnation-1",
|
||||
Some(18001),
|
||||
110,
|
||||
&[ready_odin_provider("odin", "odin-yggdrasil", 1)],
|
||||
)
|
||||
.unwrap();
|
||||
let honest = ArtifactReceipt {
|
||||
executable: false,
|
||||
executable_members: BTreeSet::from([PathBuf::from("bin/daemon"), PathBuf::from("lib/tool")]),
|
||||
..artifact_receipt()
|
||||
};
|
||||
let release = SealedRelease::new(
|
||||
&declaring,
|
||||
vec![honest.clone()],
|
||||
vec![external_input_receipt()],
|
||||
120,
|
||||
)
|
||||
.unwrap();
|
||||
release.validate_against(&declaring).unwrap();
|
||||
for drifted in [
|
||||
BTreeSet::from([PathBuf::from("bin/daemon")]),
|
||||
BTreeSet::from([PathBuf::from("bin/daemon"), PathBuf::from("lib/other")]),
|
||||
BTreeSet::new(),
|
||||
] {
|
||||
let mut receipt = honest.clone();
|
||||
receipt.executable_members = drifted;
|
||||
assert!(
|
||||
SealedRelease::new(&declaring, vec![receipt], vec![external_input_receipt()], 120)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut changed = release.clone();
|
||||
changed.artifacts[0].executable_members.insert(PathBuf::from("lib/extra"));
|
||||
assert!(changed.validate_against(&declaring).is_err());
|
||||
}
|
||||
|
||||
fn binding_with_deadlines(table: &str) -> String {
|
||||
BINDING.replace("[placement]", &format!("{table}\n[placement]"))
|
||||
}
|
||||
|
|
|
|||
806
src/drivers.rs
806
src/drivers.rs
|
|
@ -1,4 +1,4 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
|
|
@ -2302,27 +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)?;
|
||||
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,
|
||||
})
|
||||
seal_artifact(artifact, &containment_root, &source_path, staging_root)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6810,21 +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());
|
||||
// S9: streamed in fixed-size chunks rather than `fs::read`ing the
|
||||
// whole file, so peak RSS is bounded by the buffer, not by the
|
||||
// largest file in the tree.
|
||||
let mut file = fs::File::open(&path)
|
||||
.with_context(|| format!("opening {} to hash it", path.display()))?;
|
||||
let mut buffer = [0u8; 1 << 16];
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.with_context(|| format!("reading {} to hash it", path.display()))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
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");
|
||||
|
|
@ -6846,20 +6812,28 @@ fn frozen_source_sha256(_root: &Path) -> Result<String> {
|
|||
|
||||
#[cfg(unix)]
|
||||
fn harden_installed_release(root: &Path, artifacts: &[ArtifactReceipt]) -> Result<()> {
|
||||
let executable_paths = artifacts
|
||||
.iter()
|
||||
.filter(|artifact| artifact.executable)
|
||||
.map(|artifact| root.join(&artifact.destination))
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
harden_root_tree(root, root, &executable_paths)
|
||||
let mut executable_paths = BTreeSet::new();
|
||||
for artifact in artifacts {
|
||||
let destination = root.join(&artifact.destination);
|
||||
for member in &artifact.executable_members {
|
||||
executable_paths.insert(destination.join(member));
|
||||
}
|
||||
if artifact.executable {
|
||||
executable_paths.insert(destination);
|
||||
}
|
||||
}
|
||||
harden_root_tree(root, &mut executable_paths)?;
|
||||
ensure!(
|
||||
executable_paths.is_empty(),
|
||||
"declared executable is missing or not a regular file"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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: &std::collections::BTreeSet<PathBuf>,
|
||||
) -> Result<()> {
|
||||
fn harden_root_tree(current: &Path, executable_paths: &mut BTreeSet<PathBuf>) -> Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let metadata = fs::symlink_metadata(current)?;
|
||||
|
|
@ -6871,48 +6845,28 @@ fn harden_root_tree(
|
|||
let mut entries = fs::read_dir(current)?.collect::<std::io::Result<Vec<_>>>()?;
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
harden_root_tree(root, &entry.path(), executable_paths)?;
|
||||
harden_root_tree(&entry.path(), &mut *executable_paths)?;
|
||||
}
|
||||
fs::set_permissions(current, fs::Permissions::from_mode(0o555))?;
|
||||
} else if metadata.is_file() {
|
||||
fs::set_permissions(
|
||||
current,
|
||||
fs::Permissions::from_mode(if executable_paths.contains(current) {
|
||||
fs::Permissions::from_mode(if executable_paths.remove(current) {
|
||||
0o555
|
||||
} else {
|
||||
0o444
|
||||
}),
|
||||
)?;
|
||||
} 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")
|
||||
|
|
@ -8102,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()
|
||||
|
|
@ -8125,11 +8079,124 @@ pub(crate) fn copy_artifact(root: &Path, source: &Path, destination: &Path) -> R
|
|||
}
|
||||
}
|
||||
|
||||
/// The one seal-time owner of mode declarations for a copied artifact: a
|
||||
/// directory output is never `executable` (it declares `executable_members`),
|
||||
/// 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.
|
||||
fn check_artifact_modes(artifact: &ArtifactOutput, destination: &Path) -> Result<()> {
|
||||
let is_dir = fs::symlink_metadata(destination)
|
||||
.context("inspecting a copied artifact")?
|
||||
.is_dir();
|
||||
ensure!(
|
||||
!(is_dir && artifact.executable),
|
||||
"directory artifact {} cannot be executable; declare executable_members",
|
||||
artifact.id
|
||||
);
|
||||
if !is_dir {
|
||||
ensure!(
|
||||
artifact.executable_members.is_empty(),
|
||||
"file artifact {} cannot declare executable_members",
|
||||
artifact.id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
for member in &artifact.executable_members {
|
||||
ensure!(!member.as_os_str().is_empty(), "executable member is empty");
|
||||
let mut current = destination.to_path_buf();
|
||||
let mut components = member.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
let std::path::Component::Normal(name) = component else {
|
||||
bail!("executable member has a forbidden path component");
|
||||
};
|
||||
current.push(name);
|
||||
let metadata = match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
bail!("executable member is missing")
|
||||
}
|
||||
Err(error) => return Err(error).context("inspecting an executable member"),
|
||||
};
|
||||
if components.peek().is_some() {
|
||||
ensure!(
|
||||
metadata.is_dir(),
|
||||
"executable member passes through a symlink or non-directory"
|
||||
);
|
||||
} else {
|
||||
ensure!(metadata.is_file(), "executable member is not a regular file");
|
||||
}
|
||||
}
|
||||
}
|
||||
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<ArtifactReceipt> {
|
||||
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, 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 {
|
||||
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 bytes = fs::read(path)?;
|
||||
return Ok((raw_sha256(&bytes), bytes.len().try_into()?));
|
||||
let mut hasher = Sha256::new();
|
||||
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();
|
||||
|
|
@ -8153,15 +8220,16 @@ fn digest_tree(root: &Path, current: &Path, hasher: &mut Sha256, size: &mut u64)
|
|||
hasher.update(b"\0");
|
||||
digest_tree(root, &path, hasher, size)?;
|
||||
} else if metadata.is_file() {
|
||||
let bytes = fs::read(&path)?;
|
||||
let length = metadata.len();
|
||||
hasher.update(b"file\0");
|
||||
hasher.update(relative.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update((bytes.len() as u64).to_le_bytes());
|
||||
hasher.update(&bytes);
|
||||
*size = size.saturating_add(bytes.len().try_into()?);
|
||||
hasher.update(length.to_le_bytes());
|
||||
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");
|
||||
|
|
@ -8174,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))
|
||||
}
|
||||
|
|
@ -13862,6 +13959,7 @@ processes 9
|
|||
sha256: sha256_id(b"sealed"),
|
||||
size_bytes: 6,
|
||||
executable: true,
|
||||
executable_members: BTreeSet::new(),
|
||||
}],
|
||||
)?;
|
||||
let root_metadata = fs::symlink_metadata(&root)?;
|
||||
|
|
@ -13873,6 +13971,554 @@ processes 9
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const MEMBER_CANARY: &str = "zzcanary";
|
||||
|
||||
#[cfg(unix)]
|
||||
fn tree_output(executable: bool, members: &[&str]) -> ArtifactOutput {
|
||||
ArtifactOutput {
|
||||
id: "upstream".into(),
|
||||
source_kind: ArtifactSource::WorktreeTree,
|
||||
runner: None,
|
||||
source: PathBuf::from("upstream"),
|
||||
destination: PathBuf::from("upstream"),
|
||||
expected_sha256: None,
|
||||
executable,
|
||||
executable_members: members.iter().map(PathBuf::from).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_file(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fs::create_dir_all(path.parent().context("file has no parent")?)?;
|
||||
fs::write(path, bytes)?;
|
||||
// Builds and git leave arbitrary exec bits; the seal must not inherit them.
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o777))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sealed_mode(path: &Path) -> u32 {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// `<root>/src/upstream` is the worktree tree; `<root>/staging` the release.
|
||||
#[cfg(unix)]
|
||||
fn sample_tree(root: &Path) -> Result<(PathBuf, PathBuf)> {
|
||||
let source = root.join("src");
|
||||
let staging = root.join("staging");
|
||||
fs::create_dir_all(&staging)?;
|
||||
let tree = source.join("upstream");
|
||||
write_file(&tree.join("bin/daemon"), b"daemon")?;
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn collect_tree(
|
||||
source: &Path,
|
||||
staging: &Path,
|
||||
output: ArtifactOutput,
|
||||
) -> Result<ArtifactReceipt> {
|
||||
let mut declaration =
|
||||
TargetDeclaration::parse(crate::deployment::tests::RECIPE)?;
|
||||
let id = output.id.clone();
|
||||
declaration.artifacts = vec![output];
|
||||
let frozen = FrozenSource {
|
||||
receipt: FrozenSourceReceipt {
|
||||
transaction_id: "tx".into(),
|
||||
plan_id: "plan".into(),
|
||||
snapshot_sha256: "snapshot".into(),
|
||||
},
|
||||
facts: crate::deployment_plan::tests::source(crate::deployment::tests::RECIPE),
|
||||
recipe_bytes: Vec::new(),
|
||||
root: source.to_path_buf(),
|
||||
};
|
||||
DockerRunnerDriver::default().collect_artifact(
|
||||
&declaration,
|
||||
&frozen,
|
||||
&BTreeMap::new(),
|
||||
staging,
|
||||
&id,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn refusal_text<T>(result: Result<T>) -> String {
|
||||
let Err(error) = result else {
|
||||
panic!("the release was not refused");
|
||||
};
|
||||
let text = format!("{error:#}");
|
||||
assert!(
|
||||
!text.contains(MEMBER_CANARY),
|
||||
"the refusal echoed a member path: {text}"
|
||||
);
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn tree_member_sealed_0555_and_siblings_0444() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, staging) = sample_tree(temp.path())?;
|
||||
let receipt = collect_tree(
|
||||
&source,
|
||||
&staging,
|
||||
tree_output(false, &["bin/daemon", "lib/tool"]),
|
||||
)?;
|
||||
assert_eq!(
|
||||
receipt.executable_members,
|
||||
BTreeSet::from([PathBuf::from("bin/daemon"), PathBuf::from("lib/tool")])
|
||||
);
|
||||
harden_installed_release(&staging, &[receipt])?;
|
||||
let tree = staging.join("upstream");
|
||||
assert_eq!(sealed_mode(&tree.join("bin/daemon")), 0o555);
|
||||
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<()> {
|
||||
// The bad member sorts after the good one in one case and before it in
|
||||
// the other, so a check that stops at the first (or last) member fails.
|
||||
for members in [
|
||||
&["bin/daemon", "lib/zzcanary-missing"][..],
|
||||
&["aaa-zzcanary-missing", "bin/daemon"][..],
|
||||
&["zzcanary-dir/tool"][..],
|
||||
] {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, staging) = sample_tree(temp.path())?;
|
||||
let text = refusal_text(collect_tree(&source, &staging, tree_output(false, members)));
|
||||
assert!(text.contains("executable member is missing"), "{text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_member_refuses_the_release() -> Result<()> {
|
||||
for link_target in ["daemon", "../zzcanary-nowhere"] {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, staging) = sample_tree(temp.path())?;
|
||||
std::os::unix::fs::symlink(
|
||||
link_target,
|
||||
source.join("upstream/bin/zzcanary-link"),
|
||||
)?;
|
||||
let text = refusal_text(collect_tree(
|
||||
&source,
|
||||
&staging,
|
||||
tree_output(false, &["bin/daemon", "bin/zzcanary-link"]),
|
||||
));
|
||||
assert!(text.contains("not a regular file"), "{text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn member_through_symlinked_directory_refuses_the_release() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, staging) = sample_tree(temp.path())?;
|
||||
let tree = source.join("upstream");
|
||||
write_file(&tree.join("real-bin/zzcanary-tool"), b"tool")?;
|
||||
std::os::unix::fs::symlink("real-bin", tree.join("linked-bin"))?;
|
||||
let text = refusal_text(collect_tree(
|
||||
&source,
|
||||
&staging,
|
||||
tree_output(false, &["linked-bin/zzcanary-tool"]),
|
||||
));
|
||||
assert!(text.contains("symlink or non-directory"), "{text}");
|
||||
// A member below a regular file is refused the same way.
|
||||
let second_staging = temp.path().join("second-staging");
|
||||
fs::create_dir_all(&second_staging)?;
|
||||
let text = refusal_text(collect_tree(
|
||||
&source,
|
||||
&second_staging,
|
||||
tree_output(false, &["bin/daemon/zzcanary-below-file"]),
|
||||
));
|
||||
assert!(text.contains("symlink or non-directory"), "{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn directory_member_refuses_the_release() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, staging) = sample_tree(temp.path())?;
|
||||
let text = refusal_text(collect_tree(
|
||||
&source,
|
||||
&staging,
|
||||
tree_output(false, &["bin/daemon", "lib"]),
|
||||
));
|
||||
assert!(text.contains("not a regular file"), "{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn executable_flag_on_tree_output_refuses() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, staging) = sample_tree(temp.path())?;
|
||||
let text = refusal_text(collect_tree(&source, &staging, tree_output(true, &[])));
|
||||
assert!(text.contains("cannot be executable"), "{text}");
|
||||
// The same flag on a file output stays admitted.
|
||||
let file = temp.path().join("file-artifact");
|
||||
fs::write(&file, b"x")?;
|
||||
let mut output = tree_output(true, &[]);
|
||||
output.id = "file".into();
|
||||
check_artifact_modes(&output, &file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn members_on_file_output_refuse() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let file = temp.path().join("file-artifact");
|
||||
fs::write(&file, b"x")?;
|
||||
check_artifact_modes(&tree_output(false, &[]), &file)?;
|
||||
let text = refusal_text(check_artifact_modes(
|
||||
&tree_output(false, &["zzcanary"]),
|
||||
&file,
|
||||
));
|
||||
assert!(text.contains("cannot declare executable_members"), "{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn check_artifact_modes_refuses_a_member_that_is_not_a_clean_relative_path() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, _) = sample_tree(temp.path())?;
|
||||
let tree = source.join("upstream");
|
||||
write_file(&temp.path().join("zzcanary-outside"), b"x")?;
|
||||
for member in ["../../zzcanary-outside", "bin/../bin/daemon", "/zzcanary", ""] {
|
||||
refusal_text(check_artifact_modes(&tree_output(false, &[member]), &tree));
|
||||
}
|
||||
check_artifact_modes(&tree_output(false, &["bin/daemon"]), &tree)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unreadable_member_is_not_reported_as_missing() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, _) = sample_tree(temp.path())?;
|
||||
// A component past NAME_MAX fails with an io error that is not NotFound.
|
||||
let long = "z".repeat(300);
|
||||
let text = refusal_text(check_artifact_modes(
|
||||
&tree_output(false, &[long.as_str()]),
|
||||
&source.join("upstream"),
|
||||
));
|
||||
assert!(!text.contains("missing"), "{text}");
|
||||
assert!(text.contains("inspecting an executable member"), "{text}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn harden_fails_when_a_declared_member_is_absent() -> Result<()> {
|
||||
let receipt = |members: &[&str]| ArtifactReceipt {
|
||||
artifact_id: "upstream".into(),
|
||||
destination: PathBuf::from("upstream"),
|
||||
sha256: sha256_id(b"x"),
|
||||
size_bytes: 1,
|
||||
executable: false,
|
||||
executable_members: members.iter().map(PathBuf::from).collect(),
|
||||
};
|
||||
let installed = |root: &Path| -> Result<()> {
|
||||
write_file(&root.join("upstream/bin/daemon"), b"daemon")?;
|
||||
write_file(&root.join("upstream/lib/tool"), b"tool")?;
|
||||
fs::create_dir_all(root.join("upstream/zzcanary-dir"))?;
|
||||
std::os::unix::fs::symlink("daemon", root.join("upstream/bin/zzcanary-link"))?;
|
||||
Ok(())
|
||||
};
|
||||
let temp = tempfile::tempdir()?;
|
||||
installed(temp.path())?;
|
||||
harden_installed_release(temp.path(), &[receipt(&["bin/daemon", "lib/tool"])])?;
|
||||
for members in [
|
||||
&["bin/daemon", "lib/zzcanary-gone"][..],
|
||||
&["zzcanary-gone", "bin/daemon"][..],
|
||||
&["zzcanary-dir"][..],
|
||||
&["bin/zzcanary-link"][..],
|
||||
&["bin/daemon/zzcanary-below-file"][..],
|
||||
] {
|
||||
let temp = tempfile::tempdir()?;
|
||||
installed(temp.path())?;
|
||||
let text = refusal_text(harden_installed_release(temp.path(), &[receipt(members)]));
|
||||
assert!(text.contains("declared executable is missing"), "{text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn relative_in_tree_symlink_survives_the_seal() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, staging) = sample_tree(temp.path())?;
|
||||
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])?;
|
||||
let link = staging.join("upstream/bin/python3");
|
||||
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 that escapes, is absolute or climbs after a name is refused at
|
||||
// the seal, before any receipt exists.
|
||||
for (target, rule) in [
|
||||
("../../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 text = refusal_text(collect_tree(&source, &staging, tree_output(false, &["bin/daemon"])));
|
||||
assert!(text.contains(rule), "{text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_to_existing_targets_are_refused_at_the_seal() -> Result<()> {
|
||||
// Every refused shape, aimed at a target that EXISTS (file and
|
||||
// directory): a judge guarded by `exists()`, `is_dir()` or a file-type
|
||||
// test passes the dangling fixtures and fails these.
|
||||
for kind in ["file", "dir"] {
|
||||
for (shape, rule) in [
|
||||
("absolute", "absolute"),
|
||||
("escape", "escapes its artifact"),
|
||||
("after-name", "climbs after a name"),
|
||||
] {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (source, staging) = sample_tree(temp.path())?;
|
||||
let target = match (shape, kind) {
|
||||
("absolute", "file") => source.join("upstream/bin/daemon"),
|
||||
("absolute", _) => source.join("upstream/lib"),
|
||||
("escape", "file") => {
|
||||
write_file(&source.join("outside-file"), b"outside")?;
|
||||
PathBuf::from("../../outside-file")
|
||||
}
|
||||
("escape", _) => {
|
||||
fs::create_dir_all(source.join("outside-dir"))?;
|
||||
PathBuf::from("../../outside-dir")
|
||||
}
|
||||
(_, "file") => PathBuf::from("../bin/../NOTICE"),
|
||||
_ => PathBuf::from("../bin/../lib"),
|
||||
};
|
||||
let link = source.join("upstream/lib/zzcanary-link");
|
||||
std::os::unix::fs::symlink(&target, &link)?;
|
||||
assert!(fs::metadata(&link).is_ok(), "{shape} {kind} link must resolve");
|
||||
let text = refusal_text(collect_tree(&source, &staging, tree_output(false, &["bin/daemon"])));
|
||||
assert!(text.contains(rule), "{shape} {kind}: {text}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn streamed_digest_is_byte_compatible() -> Result<()> {
|
||||
// Not a multiple of the 64 KiB read buffer, so a short last chunk
|
||||
// matters; the aligned and empty files are the other boundaries.
|
||||
let big = (0..3 * 65536 + 17).map(|i| (i % 251) as u8).collect::<Vec<u8>>();
|
||||
let aligned = vec![7_u8; 65536];
|
||||
let temp = tempfile::tempdir()?;
|
||||
let tree = temp.path().join("tree");
|
||||
write_file(&tree.join("a.txt"), b"alpha")?;
|
||||
write_file(&tree.join("empty"), b"")?;
|
||||
write_file(&tree.join("sub/aligned.bin"), &aligned)?;
|
||||
write_file(&tree.join("sub/big.bin"), &big)?;
|
||||
std::os::unix::fs::symlink("a.txt", tree.join("zlink"))?;
|
||||
|
||||
// The encoding digest_tree had before it streamed, written out by hand.
|
||||
fn add_file(hasher: &mut Sha256, name: &str, bytes: &[u8]) {
|
||||
hasher.update(b"file\0");
|
||||
hasher.update(name.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update((bytes.len() as u64).to_le_bytes());
|
||||
hasher.update(bytes);
|
||||
}
|
||||
let mut expected = Sha256::new();
|
||||
add_file(&mut expected, "a.txt", b"alpha");
|
||||
add_file(&mut expected, "empty", b"");
|
||||
let mut expected_size = 5_u64;
|
||||
expected.update(b"dir\0sub\0");
|
||||
add_file(&mut expected, "sub/aligned.bin", &aligned);
|
||||
add_file(&mut expected, "sub/big.bin", &big);
|
||||
expected_size += (aligned.len() + big.len()) as u64;
|
||||
expected.update(b"link\0zlink\0a.txt");
|
||||
expected_size += "a.txt".len() as u64;
|
||||
assert_eq!(
|
||||
digest_artifact(&tree)?,
|
||||
(format!("{:x}", expected.finalize()), expected_size)
|
||||
);
|
||||
|
||||
let single = tree.join("sub/big.bin");
|
||||
assert_eq!(
|
||||
digest_artifact(&single)?,
|
||||
(raw_sha256(&big), big.len() as u64)
|
||||
);
|
||||
let mut hasher = Sha256::new();
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Acceptance on the real trees: `IDUNN_BIG_TREE` holds `python/` and
|
||||
/// `blender/`. Run on Yggdrasil as its own job (see the cut spec).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[ignore = "needs IDUNN_BIG_TREE: a CPython+torch tree and a Blender tree"]
|
||||
fn big_tree_seals_from_env() -> Result<()> {
|
||||
use std::time::Instant;
|
||||
|
||||
/// (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<PathBuf>,
|
||||
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() {
|
||||
census(root, &entry.path(), members, modes, totals)?;
|
||||
} else if metadata.file_type().is_symlink() {
|
||||
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(())
|
||||
}
|
||||
fn peak_rss() -> String {
|
||||
fs::read_to_string("/proc/self/status")
|
||||
.ok()
|
||||
.and_then(|status| {
|
||||
status
|
||||
.lines()
|
||||
.find(|line| line.starts_with("VmHWM"))
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
let tree = PathBuf::from(
|
||||
std::env::var_os("IDUNN_BIG_TREE").context("IDUNN_BIG_TREE is not set")?,
|
||||
);
|
||||
let members = ["python/bin/python3.12", "blender/blender"];
|
||||
let temp = tempfile::tempdir()?;
|
||||
let staging = temp.path().join("staging");
|
||||
fs::create_dir_all(&staging)?;
|
||||
let destination = staging.join("tree");
|
||||
let output = ArtifactOutput {
|
||||
destination: PathBuf::from("tree"),
|
||||
..tree_output(false, &members)
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let receipt = seal_artifact(&output, &tree, &tree, &staging)?;
|
||||
println!("seal {:?} {}", started.elapsed(), peak_rss());
|
||||
let started = Instant::now();
|
||||
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, &[receipt.clone()])?;
|
||||
println!("harden {:?} {}", started.elapsed(), peak_rss());
|
||||
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(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn runner_cache_is_created_once_for_one_exact_identity() -> Result<()> {
|
||||
|
|
|
|||
45
src/host.rs
45
src/host.rs
|
|
@ -30,13 +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, 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,
|
||||
|
|
@ -1048,37 +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)?;
|
||||
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,
|
||||
});
|
||||
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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue