Directory artifacts declare executable_members; the seal checks them, the receipt carries them, harden seals exactly those 0555
ArtifactOutput and ArtifactReceipt gain executable_members (the receipt field is skipped when empty, so existing sealed release ids are unchanged). check_artifact_modes is the one seal-time owner of mode declarations and runs from both collect_artifact and the host materializer. harden_installed_release drains the declared set and fails when a declared path was never sealed. Digests stream through one hash_file_streamed helper; the encoding is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d74ac8da5c
commit
022dbece5b
4 changed files with 753 additions and 34 deletions
|
|
@ -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]"))
|
||||
}
|
||||
|
|
|
|||
581
src/drivers.rs
581
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};
|
||||
|
|
@ -2309,6 +2309,7 @@ impl DockerRunnerDriver {
|
|||
"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!(
|
||||
|
|
@ -2322,6 +2323,7 @@ impl DockerRunnerDriver {
|
|||
sha256: format!("sha256-{sha256}"),
|
||||
size_bytes,
|
||||
executable: artifact.executable,
|
||||
executable_members: artifact.executable_members.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -6810,21 +6812,10 @@ 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]);
|
||||
}
|
||||
ensure!(
|
||||
hash_file_streamed(&path, hasher)? == metadata.len(),
|
||||
"frozen source file changed while hashing"
|
||||
);
|
||||
} else if metadata.file_type().is_symlink() {
|
||||
let target = fs::read_link(&path)?;
|
||||
hasher.update(b"link\0");
|
||||
|
|
@ -6846,19 +6837,31 @@ 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, 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>,
|
||||
executable_paths: &mut BTreeSet<PathBuf>,
|
||||
) -> Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
|
|
@ -6871,13 +6874,13 @@ 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(root, &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
|
||||
|
|
@ -8125,11 +8128,81 @@ 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.
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
/// 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<u64> {
|
||||
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);
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
total += read as u64;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
let streamed = hash_file_streamed(path, &mut hasher)?;
|
||||
ensure!(streamed == metadata.len(), "artifact file changed while hashing");
|
||||
return Ok((format!("{:x}", hasher.finalize()), streamed));
|
||||
}
|
||||
ensure!(metadata.is_dir(), "artifact is not a file or directory");
|
||||
let mut hasher = Sha256::new();
|
||||
|
|
@ -8153,13 +8226,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());
|
||||
ensure!(
|
||||
hash_file_streamed(&path, hasher)? == length,
|
||||
"artifact file changed while hashing"
|
||||
);
|
||||
*size = size.saturating_add(length);
|
||||
} else if metadata.file_type().is_symlink() {
|
||||
let target = fs::read_link(&path)?;
|
||||
hasher.update(b"link\0");
|
||||
|
|
@ -13862,6 +13938,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 +13950,448 @@ 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 mode_of(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")?;
|
||||
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!(mode_of(&tree.join("bin/daemon")), 0o555);
|
||||
assert_eq!(mode_of(&tree.join("lib/tool")), 0o555);
|
||||
assert_eq!(mode_of(&tree.join("bin/helper")), 0o444);
|
||||
assert_eq!(mode_of(&tree.join("lib/data.txt")), 0o444);
|
||||
for directory in [&staging, &tree, &tree.join("bin"), &tree.join("lib")] {
|
||||
assert_eq!(mode_of(directory), 0o555);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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 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"))?;
|
||||
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!(mode_of(&staging.join("upstream/bin/python3.12")), 0o555);
|
||||
assert_eq!(digest_artifact(&staging.join("upstream"))?.0, before);
|
||||
|
||||
// A link out of the release, by `..` or by absolute target, still refuses.
|
||||
for (target, rule) in [
|
||||
("../../zzcanary-up", "parent traversal"),
|
||||
("/zzcanary/absolute", "absolute"),
|
||||
] {
|
||||
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]));
|
||||
assert!(text.contains(rule), "{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();
|
||||
assert_eq!(hash_file_streamed(&single, &mut hasher)?, big.len() as u64);
|
||||
assert_eq!(format!("{:x}", hasher.finalize()), raw_sha256(&big));
|
||||
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;
|
||||
|
||||
fn symlinks(path: &Path) -> Result<usize> {
|
||||
let mut count = 0;
|
||||
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())?;
|
||||
} else if metadata.file_type().is_symlink() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
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();
|
||||
copy_artifact(&tree, &tree, &destination)?;
|
||||
println!("copy {:?} {}", 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 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(),
|
||||
}],
|
||||
)?;
|
||||
println!("harden {:?} {}", started.elapsed(), peak_rss());
|
||||
for member in members {
|
||||
assert_eq!(mode_of(&destination.join(member)), 0o555);
|
||||
}
|
||||
let sample = fs::read_dir(&destination)?
|
||||
.map(|entry| entry.map(|entry| entry.path()))
|
||||
.collect::<std::io::Result<Vec<_>>>()?;
|
||||
for path in sample {
|
||||
if fs::symlink_metadata(&path)?.is_file() {
|
||||
assert_eq!(mode_of(&path), 0o444);
|
||||
}
|
||||
}
|
||||
assert_eq!(symlinks(&destination)?, links);
|
||||
println!("bytes {} symlinks {links}", first.1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn runner_cache_is_created_once_for_one_exact_identity() -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -35,8 +35,9 @@ use crate::deployment::{
|
|||
};
|
||||
use crate::deployment_plan::{ArtifactReceipt, 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, check_artifact_modes, copy_artifact,
|
||||
copy_tree, digest_artifact, release_artifact, remove_tree_inside, sha256_id,
|
||||
write_runtime_bundle_records,
|
||||
};
|
||||
use crate::host_actuator::{
|
||||
HOST_ACTUATOR_CHANNEL, HOST_ACTUATOR_CONNECTION_ID, HOST_ACTUATOR_PING_MS,
|
||||
|
|
@ -1064,6 +1065,7 @@ fn materialize(
|
|||
"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!(
|
||||
|
|
@ -1078,6 +1080,7 @@ fn materialize(
|
|||
sha256: format!("sha256-{sha256}"),
|
||||
size_bytes,
|
||||
executable: artifact.executable,
|
||||
executable_members: artifact.executable_members.clone(),
|
||||
});
|
||||
}
|
||||
let release = SealedRelease::new(plan, artifacts, Vec::new(), sealed_at_unix_millis)?;
|
||||
|
|
|
|||
Loading…
Reference in a new issue