commit 85825e2e89cc1dafe12cf146fd55c828d2329ec2
parent a47a923ebe8a589f5ca996646776e3508bcc94bd
Author: triesap <tyson@radroots.org>
Date: Sat, 5 Sep 2026 03:44:18 +0000
xtask: harden artifact IO and preflight
Diffstat:
7 files changed, 3868 insertions(+), 173 deletions(-)
diff --git a/contracts/architecture/decisions/services_hardening_safe_artifact_io.v1.json b/contracts/architecture/decisions/services_hardening_safe_artifact_io.v1.json
@@ -0,0 +1,211 @@
+{
+ "schema": "radroots.services-hardening.safe-artifact-io-decisions.v1",
+ "contract_version": 1,
+ "decision_state": "active",
+ "owner": "tools/xtask",
+ "self_test_command": "cargo xtask safe-artifact-io-self-test",
+ "platform_scope": {
+ "implementation": [
+ "macos_aarch64",
+ "linux_x86_64"
+ ],
+ "source_gate": [
+ "macos_aarch64"
+ ],
+ "cross_platform_promotion_owner": "step-297"
+ },
+ "scope": {
+ "claims": [
+ "descriptor_bound_regular_file_io",
+ "bounded_exact_directory_snapshots",
+ "generic_parse_only_tar_gzip_admission",
+ "service_release_artifacts_adoption",
+ "result_level_release_preflight_aggregation"
+ ],
+ "nonclaims": [
+ "semantic_oci_or_binary_archive_validation_before_step_304",
+ "transitive_safe_io_inside_every_preflight_lane",
+ "panic_signal_hang_or_abort_containment_without_step_292_process_isolation",
+ "cross_platform_runtime_promotion_before_step_297"
+ ]
+ },
+ "filesystem_model": {
+ "absolute_root_walk": "open_from_filesystem_root_one_component_at_a_time",
+ "open_flags": [
+ "O_CLOEXEC",
+ "O_NOFOLLOW",
+ "O_NONBLOCK"
+ ],
+ "intermediate_type": "directory",
+ "leaf_type": "regular_file",
+ "regular_link_count": "exactly_one",
+ "absolute_ancestor_identity": "device_inode_and_directory_type",
+ "regular_identity": "device_inode_type_mode_owner_link_count_length_mtime_ctime",
+ "permission_mode": "mode_bits_0o7777_including_special_bits",
+ "read_completion": "observed_bytes_equal_admitted_length",
+ "post_operation_binding": "reopen_complete_path_and_compare_ancestor_and_leaf_identities",
+ "hardlink_compatibility": "callers_must_privately_materialize_single_link_inputs_before_admission",
+ "trusted_root_normalization": "caller_may_canonicalize_a_trusted_root_before_descriptor_admission",
+ "diagnostic_paths": "redacted"
+ },
+ "create_new_materialization": {
+ "output_parent_binding": "descriptor_bound_complete_absolute_chain",
+ "output_chain_security": "every_directory_root_or_effective_uid_owned_and_no_group_or_world_write_mode_bits",
+ "output_chain_acl_precondition": "caller_guarantees_no_write_granting_acl_for_other_credentials_on_every_chain_directory",
+ "output_name": "direct_openat_create_exclusive_nofollow_cloexec",
+ "output_mode": "retained_descriptor_fchmod_0600",
+ "data": "fixed_buffer_stream_and_simultaneous_sha256",
+ "durability": "sync_retained_output_then_sync_parent",
+ "finalization": "capture_full_output_identity_after_write_and_sync_before_post_sync_hook",
+ "post_operation": "compare_retained_and_reopened_output_to_finalized_identity_and_revalidate_parent_chain_and_security",
+ "success_linearization": "final_rebound_and_complete_chain_security_check",
+ "caller_use": "final_revalidate_before_use",
+ "failure": "never_unlink_rename_or_rollback_after_successful_create",
+ "failed_output_trust": "untrusted_caller_owned_residue_requires_quarantine_and_fresh_retry_path",
+ "threat_boundary": "same_effective_credential_or_acl_authorized_mutation_after_final_revalidation_is_outside_strict_pathname_persistence"
+ },
+ "hard_maximums": {
+ "buffered_read_bytes": 67108864,
+ "streaming_file_bytes": 17179869184,
+ "traversal_entries": 65536,
+ "traversal_files": 65536,
+ "traversal_total_bytes": 68719476736,
+ "traversal_depth": 64,
+ "path_bytes": 4096,
+ "archive_compressed_bytes": 2147483648,
+ "archive_expanded_bytes": 17179869184,
+ "archive_members": 65536,
+ "archive_member_bytes": 17179869184,
+ "archive_payload_bytes": 17179869184
+ },
+ "traversal": {
+ "entry_count": "every_non_dot_member_encountered_including_excluded_directories",
+ "file_count": "admitted_regular_files",
+ "total_bytes": "checked_sum_of_admitted_regular_lengths",
+ "depth": "root_is_zero_each_descended_directory_adds_one",
+ "path_bytes": "raw_platform_path_bytes_relative_to_root",
+ "ordering": "raw_member_name_bytes_ascending",
+ "special_members": "reject",
+ "excluded_directories": "retain_name_type_and_device_inode_binding_but_do_not_descend",
+ "snapshot_binding": "exact_sorted_membership_and_member_identity_for_root_and_every_descended_directory",
+ "snapshot_consumption": "read_hash_and_copy_require_the_traversed_file_identity",
+ "final_revalidation": "reopen_complete_bindings_and_reenumerate_exact_memberships"
+ },
+ "archive_admission": {
+ "format": "one_gzip_member_containing_one_nonempty_tar_stream",
+ "gzip_header": "minimal_ten_byte_header_with_zero_flags_optional_and_reserved_flags_rejected",
+ "operation": "parse_only_never_unpack",
+ "compressed_hash": "sha256_of_every_compressed_input_byte",
+ "tar_iteration": "raw_entries",
+ "tar_termination": "two_or_more_512_byte_zero_blocks",
+ "allowed_members": [
+ "regular_file",
+ "zero_length_directory"
+ ],
+ "rejected_members": [
+ "symlink",
+ "hardlink",
+ "fifo",
+ "character_device",
+ "block_device",
+ "sparse",
+ "pax_extension",
+ "gnu_extension",
+ "unknown_type"
+ ],
+ "paths": "utf8_relative_nonempty_components_no_dot_no_dotdot_no_backslash_no_nul",
+ "regular_trailing_slash": "reject",
+ "duplicates": "reject",
+ "file_directory_prefix_conflicts": "reject",
+ "gzip_trailing_or_concatenated_data": "reject",
+ "member_body": "drain_to_eof_and_require_actual_equals_declared",
+ "semantic_archive_qualification": "deferred_to_step_304"
+ },
+ "aggregate_preflight": {
+ "required_lanes": [
+ "catalog",
+ "service_source_lock_contract",
+ "service_build_qualification_contract",
+ "service_release_artifacts_contract",
+ "public_native_group",
+ "preview_group",
+ "tools_group",
+ "dto_roots",
+ "protocol_freshness",
+ "artifact_contracts",
+ "release_contracts"
+ ],
+ "execution": "invoke_each_required_lane_exactly_once_in_required_order_after_returned_results",
+ "pass": "exact_order_exact_presence_unique_no_unexpected_and_every_state_pass",
+ "nonpass_states": [
+ "failed",
+ "interrupted",
+ "skipped",
+ "unavailable"
+ ],
+ "missing_duplicate_unexpected_or_reordered": "fail_closed",
+ "lane_internal_safety": "owned_by_later_migration_steps_and_step_297",
+ "panic_signal_hang_abort": "not_a_returned_lane_state_requires_step_292_process_isolation"
+ },
+ "adoption": {
+ "service_release_artifacts_inputs": "one_exact_flat_snapshot_retained_through_all_snapshot_bound_copies_and_final_revalidation",
+ "service_release_artifacts_output_inventory": "one_exact_flat_snapshot_for_all_hashes_and_final_revalidation",
+ "service_release_artifacts_archives": "generated_binary_and_supplied_oci_tar_gzip_are_generically_admitted",
+ "service_release_artifacts_copy": "descriptor_bound_create_new_no_rollback_copy",
+ "other_preflight_lane_internals": "not_claimed_by_step_293"
+ },
+ "diagnostic_safety": {
+ "paths_and_member_names": "redacted",
+ "archive_bytes": "redacted",
+ "operating_system_error_text": "redacted",
+ "source_error_chain": "absent"
+ },
+ "required_vectors": [
+ "buffered_read_cap",
+ "streaming_file_cap",
+ "traversal_entry_cap",
+ "traversal_file_cap",
+ "traversal_total_byte_cap",
+ "traversal_file_byte_cap",
+ "traversal_depth_cap",
+ "traversal_path_cap",
+ "symlink",
+ "fifo",
+ "hardlink",
+ "leaf_replacement",
+ "in_place_mutation",
+ "parent_replacement",
+ "late_member_insertion",
+ "excluded_directory_replacement",
+ "artifact_special_mode",
+ "create_new_success_0600",
+ "create_new_untrusted_error_residue",
+ "output_name_swap_preserves_replacement",
+ "output_post_sync_mutation",
+ "output_chain_security",
+ "archive_compressed_cap",
+ "archive_expanded_cap",
+ "archive_member_count_cap",
+ "archive_member_byte_cap",
+ "archive_payload_cap",
+ "archive_depth_cap",
+ "archive_path_cap",
+ "archive_special_member",
+ "archive_escape",
+ "archive_duplicate",
+ "archive_prefix_conflict",
+ "archive_truncated",
+ "archive_concatenated",
+ "archive_malformed",
+ "gzip_optional_or_reserved_header",
+ "aggregate_missing",
+ "aggregate_failed",
+ "aggregate_interrupted",
+ "aggregate_skipped",
+ "aggregate_unavailable",
+ "aggregate_duplicate",
+ "aggregate_unexpected",
+ "aggregate_reordered",
+ "redaction"
+ ]
+}
diff --git a/tools/xtask/README b/tools/xtask/README
@@ -26,6 +26,10 @@ tasks for the `radroots` core libraries.
target and standalone command inventories, fixture Cargo/release metadata,
source lock, and release-artifact evidence while keeping Nix outputs
explicitly deferred;
+ * descriptor-bound, resource-bounded regular-file traversal, streaming
+ hashing, descriptor-bound create-new copying, generic parse-only tar-gzip
+ admission, and exact non-short-circuit aggregation of the release-preflight
+ lane inventory;
* command-dispatch code used for contract, coverage, hygiene, and release
paths inside the workspace;
* a non-published binary crate used as tooling rather than as a library
diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs
@@ -34,8 +34,10 @@ mod hygiene;
mod portable_qualification;
#[cfg_attr(coverage_nightly, coverage(off))]
mod release_graph;
+mod release_preflight;
#[cfg_attr(coverage_nightly, coverage(off))]
mod release_qualification;
+mod safe_artifact_io;
#[cfg_attr(coverage_nightly, coverage(off))]
mod safety_qualification;
#[cfg_attr(coverage_nightly, coverage(off))]
@@ -67,6 +69,7 @@ enum XtaskCommand {
ArchitectureCi,
ArchitectureSourceExportCi,
BoundedProcessSelfTest,
+ SafeArtifactIoSelfTest,
CheckApiBoundaries,
CheckDependencyBoundaries,
Check {
@@ -302,6 +305,7 @@ fn usage() {
eprintln!(" cargo xtask architecture-ci");
eprintln!(" cargo xtask architecture-source-export-ci");
eprintln!(" cargo xtask bounded-process-self-test");
+ eprintln!(" cargo xtask safe-artifact-io-self-test");
eprintln!(" cargo xtask check-api-boundaries");
eprintln!(" cargo xtask check-dependency-boundaries");
eprintln!(" cargo xtask check --group <group> [--operation check|test|clippy] [--execute]");
@@ -413,17 +417,46 @@ fn release_preflight() -> Result<(), String> {
}
fn release_preflight_at(root: &Path) -> Result<(), String> {
- catalog::check(root)?;
- service_source_lock::validate_contract(root)?;
- service_build_qualification::validate_contract(root)?;
- service_release_artifacts::validate_contract(root)?;
- for group in ["public_native", "preview", "tools"] {
- build_control::group_plan(root, group, build_control::Operation::Check, false)?;
- }
- dto_roots::check(root)?;
- generate::protocol::check(root)?;
- contract::validate_artifact_contracts(root)?;
- contract::validate_release_preflight(root)
+ use release_preflight::{LaneId, LaneState};
+
+ release_preflight::execute_all(|lane| {
+ let result = match lane {
+ LaneId::Catalog => catalog::check(root),
+ LaneId::ServiceSourceLockContract => service_source_lock::validate_contract(root),
+ LaneId::ServiceBuildQualificationContract => {
+ service_build_qualification::validate_contract(root)
+ }
+ LaneId::ServiceReleaseArtifactsContract => {
+ service_release_artifacts::validate_contract(root)
+ }
+ LaneId::PublicNativeGroup => build_control::group_plan(
+ root,
+ "public_native",
+ build_control::Operation::Check,
+ false,
+ )
+ .map(|_| ()),
+ LaneId::PreviewGroup => {
+ build_control::group_plan(root, "preview", build_control::Operation::Check, false)
+ .map(|_| ())
+ }
+ LaneId::ToolsGroup => {
+ build_control::group_plan(root, "tools", build_control::Operation::Check, false)
+ .map(|_| ())
+ }
+ LaneId::DtoRoots => dto_roots::check(root),
+ LaneId::ProtocolFreshness => generate::protocol::check(root),
+ LaneId::ArtifactContracts => contract::validate_artifact_contracts(root),
+ LaneId::ReleaseContracts => contract::validate_release_preflight(root),
+ };
+ if result.is_ok() {
+ LaneState::Pass
+ } else {
+ LaneState::Failed
+ }
+ })
+ .map(|_| ())
+ .map_err(|error| error.to_string())
}
#[cfg_attr(coverage_nightly, coverage(off))]
@@ -482,6 +515,12 @@ fn run(args: &[String]) -> Result<(), String> {
validate_contract()
}
XtaskCommand::BoundedProcessSelfTest => bounded_process::self_test(),
+ XtaskCommand::SafeArtifactIoSelfTest => {
+ safe_artifact_io::self_test()?;
+ release_preflight::self_test()?;
+ println!("safe artifact I/O self-test: ok");
+ Ok(())
+ }
XtaskCommand::CheckApiBoundaries => {
architecture::validate_api_boundaries(&workspace_root())
}
@@ -691,6 +730,7 @@ mod tests {
#[test]
fn typed_build_control_cli_requires_explicit_modes_and_known_values() {
assert!(Cli::try_parse_from(["xtask", "bounded-process-self-test"]).is_ok());
+ assert!(Cli::try_parse_from(["xtask", "safe-artifact-io-self-test"]).is_ok());
let source_args = [
"xtask",
@@ -867,11 +907,11 @@ mod tests {
}
#[test]
- fn release_preflight_checks_catalog_authority_first() {
+ fn release_preflight_exhausts_the_closed_lane_inventory() {
let workspace = tempfile::TempDir::new().expect("create empty workspace");
let error = release_preflight_at(workspace.path())
- .expect_err("missing catalog authority must fail first");
- assert!(error.contains("inspect artifact path") && error.contains("contracts"));
+ .expect_err("missing required inputs must fail closed");
+ assert_eq!(error, "release preflight required lanes did not all pass");
}
#[test]
diff --git a/tools/xtask/src/release_preflight.rs b/tools/xtask/src/release_preflight.rs
@@ -0,0 +1,328 @@
+//! Closed, non-short-circuit aggregation for the release preflight.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt;
+
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub(crate) enum LaneId {
+ Catalog,
+ ServiceSourceLockContract,
+ ServiceBuildQualificationContract,
+ ServiceReleaseArtifactsContract,
+ PublicNativeGroup,
+ PreviewGroup,
+ ToolsGroup,
+ DtoRoots,
+ ProtocolFreshness,
+ ArtifactContracts,
+ ReleaseContracts,
+}
+
+impl LaneId {
+ pub(crate) const fn as_str(self) -> &'static str {
+ match self {
+ Self::Catalog => "catalog",
+ Self::ServiceSourceLockContract => "service_source_lock_contract",
+ Self::ServiceBuildQualificationContract => "service_build_qualification_contract",
+ Self::ServiceReleaseArtifactsContract => "service_release_artifacts_contract",
+ Self::PublicNativeGroup => "public_native_group",
+ Self::PreviewGroup => "preview_group",
+ Self::ToolsGroup => "tools_group",
+ Self::DtoRoots => "dto_roots",
+ Self::ProtocolFreshness => "protocol_freshness",
+ Self::ArtifactContracts => "artifact_contracts",
+ Self::ReleaseContracts => "release_contracts",
+ }
+ }
+}
+
+pub(crate) const REQUIRED_LANES: [LaneId; 11] = [
+ LaneId::Catalog,
+ LaneId::ServiceSourceLockContract,
+ LaneId::ServiceBuildQualificationContract,
+ LaneId::ServiceReleaseArtifactsContract,
+ LaneId::PublicNativeGroup,
+ LaneId::PreviewGroup,
+ LaneId::ToolsGroup,
+ LaneId::DtoRoots,
+ LaneId::ProtocolFreshness,
+ LaneId::ArtifactContracts,
+ LaneId::ReleaseContracts,
+];
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum LaneState {
+ Failed,
+ Interrupted,
+ Pass,
+ Skipped,
+ Unavailable,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct LaneOutcome {
+ id: String,
+ state: LaneState,
+}
+
+impl LaneOutcome {
+ pub(crate) fn required(id: LaneId, state: LaneState) -> Self {
+ Self {
+ id: id.as_str().to_owned(),
+ state,
+ }
+ }
+
+ fn named(id: &str, state: LaneState) -> Self {
+ Self {
+ id: id.to_owned(),
+ state,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct PreflightReport {
+ outcomes: Vec<LaneOutcome>,
+ missing: Vec<&'static str>,
+ duplicate: Vec<String>,
+ unexpected: Vec<String>,
+ ordered: bool,
+}
+
+impl PreflightReport {
+ pub(crate) fn outcomes(&self) -> &[LaneOutcome] {
+ &self.outcomes
+ }
+
+ pub(crate) fn is_pass(&self) -> bool {
+ self.missing.is_empty()
+ && self.duplicate.is_empty()
+ && self.unexpected.is_empty()
+ && self.ordered
+ && self.outcomes.len() == REQUIRED_LANES.len()
+ && self
+ .outcomes
+ .iter()
+ .all(|outcome| outcome.state == LaneState::Pass)
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct PreflightError {
+ report: PreflightReport,
+}
+
+impl PreflightError {
+ pub(crate) fn report(&self) -> &PreflightReport {
+ &self.report
+ }
+}
+
+impl fmt::Display for PreflightError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("release preflight required lanes did not all pass")
+ }
+}
+
+impl std::error::Error for PreflightError {}
+
+pub(crate) fn execute_all<F>(mut execute: F) -> Result<PreflightReport, PreflightError>
+where
+ F: FnMut(LaneId) -> LaneState,
+{
+ let outcomes = REQUIRED_LANES
+ .into_iter()
+ .map(|lane| LaneOutcome::required(lane, execute(lane)))
+ .collect();
+ close(outcomes)
+}
+
+pub(crate) fn close(outcomes: Vec<LaneOutcome>) -> Result<PreflightReport, PreflightError> {
+ let required = REQUIRED_LANES
+ .into_iter()
+ .map(LaneId::as_str)
+ .collect::<BTreeSet<_>>();
+ let mut counts = BTreeMap::<String, usize>::new();
+ for outcome in &outcomes {
+ *counts.entry(outcome.id.clone()).or_default() += 1;
+ }
+ let missing = required
+ .iter()
+ .copied()
+ .filter(|lane| !counts.contains_key(*lane))
+ .collect::<Vec<_>>();
+ let duplicate = counts
+ .iter()
+ .filter(|(_, count)| **count > 1)
+ .map(|(lane, _)| lane.clone())
+ .collect::<Vec<_>>();
+ let unexpected = counts
+ .keys()
+ .filter(|lane| !required.contains(lane.as_str()))
+ .cloned()
+ .collect::<Vec<_>>();
+ let ordered = outcomes
+ .iter()
+ .map(|outcome| outcome.id.as_str())
+ .eq(REQUIRED_LANES.into_iter().map(LaneId::as_str));
+ let report = PreflightReport {
+ outcomes,
+ missing,
+ duplicate,
+ unexpected,
+ ordered,
+ };
+ if report.is_pass() {
+ Ok(report)
+ } else {
+ Err(PreflightError { report })
+ }
+}
+
+pub(crate) fn self_test() -> Result<(), String> {
+ let mut attempted = Vec::new();
+ execute_all(|lane| {
+ attempted.push(lane);
+ LaneState::Pass
+ })
+ .map_err(|error| error.to_string())?;
+ if attempted != REQUIRED_LANES {
+ return Err("release preflight lane inventory self-test failed".to_owned());
+ }
+
+ for state in [
+ LaneState::Failed,
+ LaneState::Interrupted,
+ LaneState::Skipped,
+ LaneState::Unavailable,
+ ] {
+ let mut attempted = Vec::new();
+ let result = execute_all(|lane| {
+ attempted.push(lane);
+ if lane == LaneId::Catalog {
+ state
+ } else {
+ LaneState::Pass
+ }
+ });
+ let error = result
+ .err()
+ .ok_or_else(|| "release preflight exhaustion self-test failed".to_owned())?;
+ if attempted != REQUIRED_LANES || error.report().outcomes().len() != REQUIRED_LANES.len() {
+ return Err("release preflight exhaustion self-test failed".to_owned());
+ }
+ }
+
+ let all_pass = || {
+ REQUIRED_LANES
+ .into_iter()
+ .map(|lane| LaneOutcome::required(lane, LaneState::Pass))
+ .collect::<Vec<_>>()
+ };
+ let mut missing = all_pass();
+ missing.pop();
+ let mut duplicate = all_pass();
+ duplicate.push(LaneOutcome::required(LaneId::Catalog, LaneState::Pass));
+ let mut unexpected = all_pass();
+ unexpected.push(LaneOutcome::named("not_governed", LaneState::Pass));
+ let mut reordered = all_pass();
+ reordered.swap(0, 1);
+ if [missing, duplicate, unexpected, reordered]
+ .into_iter()
+ .any(|outcomes| close(outcomes).is_ok())
+ {
+ return Err("release preflight closure self-test failed".to_owned());
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn all_pass() -> Vec<LaneOutcome> {
+ REQUIRED_LANES
+ .into_iter()
+ .map(|lane| LaneOutcome::required(lane, LaneState::Pass))
+ .collect()
+ }
+
+ #[test]
+ fn exact_inventory_and_order_are_closed() {
+ assert_eq!(
+ REQUIRED_LANES.map(LaneId::as_str),
+ [
+ "catalog",
+ "service_source_lock_contract",
+ "service_build_qualification_contract",
+ "service_release_artifacts_contract",
+ "public_native_group",
+ "preview_group",
+ "tools_group",
+ "dto_roots",
+ "protocol_freshness",
+ "artifact_contracts",
+ "release_contracts",
+ ]
+ );
+ assert!(close(all_pass()).expect("all pass").is_pass());
+ }
+
+ #[test]
+ fn every_nonpass_state_fails_closed_without_short_circuiting() {
+ for state in [
+ LaneState::Failed,
+ LaneState::Interrupted,
+ LaneState::Skipped,
+ LaneState::Unavailable,
+ ] {
+ let mut attempted = Vec::new();
+ let error = execute_all(|lane| {
+ attempted.push(lane);
+ if lane == LaneId::Catalog {
+ state
+ } else {
+ LaneState::Pass
+ }
+ })
+ .expect_err("nonpass lane");
+ assert_eq!(attempted, REQUIRED_LANES);
+ assert_eq!(error.report().outcomes().len(), REQUIRED_LANES.len());
+ }
+ }
+
+ #[test]
+ fn missing_duplicate_and_unexpected_lanes_fail_closed() {
+ let mut missing = all_pass();
+ missing.pop();
+ assert!(close(missing).is_err());
+
+ let mut duplicate = all_pass();
+ duplicate.push(LaneOutcome::required(LaneId::Catalog, LaneState::Pass));
+ assert!(close(duplicate).is_err());
+
+ let mut unexpected = all_pass();
+ unexpected.push(LaneOutcome::named("not_governed", LaneState::Pass));
+ assert!(close(unexpected).is_err());
+
+ let mut reordered = all_pass();
+ reordered.swap(0, 1);
+ assert!(close(reordered).is_err());
+ }
+
+ #[test]
+ fn aggregate_diagnostics_are_static() {
+ let error = close(vec![LaneOutcome::named(
+ "secret-path-or-command",
+ LaneState::Failed,
+ )])
+ .expect_err("invalid aggregate");
+ let diagnostic = error.to_string();
+ assert_eq!(
+ diagnostic,
+ "release preflight required lanes did not all pass"
+ );
+ assert!(!diagnostic.contains("secret"));
+ }
+}
diff --git a/tools/xtask/src/safe_artifact_io.rs b/tools/xtask/src/safe_artifact_io.rs
@@ -0,0 +1,2669 @@
+//! Descriptor-bound, resource-bounded input handling for release tooling.
+
+use std::collections::BTreeMap;
+use std::ffi::{OsStr, OsString};
+use std::fmt;
+use std::fs::{self, File};
+use std::io::{self, BufRead as _, BufReader, Read, Write};
+use std::path::{Component, Path, PathBuf};
+
+use flate2::{Compression, GzBuilder, bufread::GzDecoder};
+use sha2::{Digest as _, Sha256};
+use tar::{Builder as TarBuilder, Header as TarHeader};
+
+const STREAM_BUFFER_BYTES: usize = 64 * 1024;
+const HARD_MAX_BUFFERED_READ_BYTES: u64 = 67_108_864;
+const HARD_MAX_STREAM_FILE_BYTES: u64 = 17_179_869_184;
+const HARD_MAX_TRAVERSAL_ENTRIES: u64 = 65_536;
+const HARD_MAX_TRAVERSAL_FILES: u64 = 65_536;
+const HARD_MAX_TRAVERSAL_TOTAL_BYTES: u64 = 68_719_476_736;
+const HARD_MAX_TRAVERSAL_DEPTH: usize = 64;
+const HARD_MAX_PATH_BYTES: usize = 4_096;
+const HARD_MAX_ARCHIVE_COMPRESSED_BYTES: u64 = 2_147_483_648;
+const HARD_MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184;
+const HARD_MAX_ARCHIVE_MEMBERS: u64 = 65_536;
+const HARD_MAX_ARCHIVE_MEMBER_BYTES: u64 = 17_179_869_184;
+const HARD_MAX_ARCHIVE_PAYLOAD_BYTES: u64 = 17_179_869_184;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum LimitKind {
+ ArchiveCompressedBytes,
+ ArchiveDepth,
+ ArchiveExpandedBytes,
+ ArchiveMemberBytes,
+ ArchiveMembers,
+ ArchivePathBytes,
+ ArchivePayloadBytes,
+ FileBytes,
+ TraversalDepth,
+ TraversalEntries,
+ TraversalFiles,
+ TraversalPathBytes,
+ TraversalTotalBytes,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum ArtifactIoFailureKind {
+ ChangedDuringRead,
+ InvalidObject,
+ InvalidRequest,
+ IoFailure,
+ LimitExceeded(LimitKind),
+ MalformedArchive,
+ #[cfg_attr(unix, allow(dead_code))]
+ UnsupportedPlatform,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) struct ArtifactIoError {
+ kind: ArtifactIoFailureKind,
+}
+
+impl ArtifactIoError {
+ const fn new(kind: ArtifactIoFailureKind) -> Self {
+ Self { kind }
+ }
+
+ pub(crate) const fn kind(self) -> ArtifactIoFailureKind {
+ self.kind
+ }
+}
+
+impl fmt::Display for ArtifactIoError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ ArtifactIoFailureKind::ChangedDuringRead => "artifact binding changed during admission",
+ ArtifactIoFailureKind::InvalidObject => "artifact object type is not admitted",
+ ArtifactIoFailureKind::InvalidRequest => "artifact I/O request is invalid",
+ ArtifactIoFailureKind::IoFailure => "artifact I/O operation failed",
+ ArtifactIoFailureKind::LimitExceeded(_) => "artifact I/O limit was exceeded",
+ ArtifactIoFailureKind::MalformedArchive => "artifact archive is malformed",
+ ArtifactIoFailureKind::UnsupportedPlatform => {
+ "safe artifact I/O is unsupported on this platform"
+ }
+ })
+ }
+}
+
+impl std::error::Error for ArtifactIoError {}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) struct TraversalLimits {
+ pub(crate) max_entries: u64,
+ pub(crate) max_files: u64,
+ pub(crate) max_total_bytes: u64,
+ pub(crate) max_file_bytes: u64,
+ pub(crate) max_depth: usize,
+ pub(crate) max_path_bytes: usize,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) struct TarGzipLimits {
+ pub(crate) max_compressed_bytes: u64,
+ pub(crate) max_expanded_bytes: u64,
+ pub(crate) max_members: u64,
+ pub(crate) max_member_bytes: u64,
+ pub(crate) max_payload_bytes: u64,
+ pub(crate) max_depth: usize,
+ pub(crate) max_path_bytes: usize,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct FileEvidence {
+ pub(crate) byte_length: u64,
+ pub(crate) sha256: String,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct ArchiveEvidence {
+ pub(crate) compressed: FileEvidence,
+ pub(crate) expanded_bytes: u64,
+ pub(crate) member_count: u64,
+ pub(crate) payload_bytes: u64,
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct TraversedFile {
+ relative: PathBuf,
+ identity: FileIdentity,
+}
+
+impl TraversedFile {
+ pub(crate) fn relative_path(&self) -> &Path {
+ &self.relative
+ }
+
+ pub(crate) const fn permission_mode(&self) -> u32 {
+ permission_mode(self.identity)
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct TraversalSnapshot {
+ root: PathBuf,
+ root_chain: Vec<DirectoryIdentity>,
+ directories: Vec<TraversedDirectory>,
+ files: Vec<TraversedFile>,
+ entry_count: u64,
+ total_bytes: u64,
+}
+
+impl TraversalSnapshot {
+ pub(crate) fn files(&self) -> &[TraversedFile] {
+ &self.files
+ }
+
+ pub(crate) const fn entry_count(&self) -> u64 {
+ self.entry_count
+ }
+
+ pub(crate) const fn total_bytes(&self) -> u64 {
+ self.total_bytes
+ }
+
+ pub(crate) fn root_permission_mode(&self) -> u32 {
+ self.directories
+ .iter()
+ .find(|directory| directory.relative.as_os_str().is_empty())
+ .map_or(0, |directory| directory.permission_mode)
+ }
+
+ pub(crate) fn read(
+ &self,
+ file: &TraversedFile,
+ maximum: u64,
+ ) -> Result<Vec<u8>, ArtifactIoError> {
+ read_regular_impl(
+ &self.root,
+ &file.relative,
+ maximum,
+ Some(&file.identity),
+ || {},
+ )
+ }
+
+ pub(crate) fn read_evidenced(
+ &self,
+ file: &TraversedFile,
+ maximum: u64,
+ ) -> Result<(Vec<u8>, FileEvidence), ArtifactIoError> {
+ read_regular_evidenced_impl(
+ &self.root,
+ &file.relative,
+ maximum,
+ Some(&file.identity),
+ || {},
+ )
+ }
+
+ pub(crate) fn hash(
+ &self,
+ file: &TraversedFile,
+ maximum: u64,
+ ) -> Result<FileEvidence, ArtifactIoError> {
+ with_regular(
+ &self.root,
+ &file.relative,
+ maximum,
+ Some(&file.identity),
+ || {},
+ |input, admitted_length| {
+ let evidence = stream_regular(input, maximum, None)?;
+ require_observed_length(evidence.byte_length, admitted_length)?;
+ Ok(evidence)
+ },
+ )
+ }
+
+ pub(crate) fn copy_to_new_path(
+ &self,
+ file: &TraversedFile,
+ output: &Path,
+ maximum: u64,
+ ) -> Result<FileEvidence, ArtifactIoError> {
+ copy_regular_to_new_path_impl(
+ &self.root.join(&file.relative),
+ output,
+ maximum,
+ Some(&file.identity),
+ || {},
+ || {},
+ )
+ }
+
+ pub(crate) fn revalidate(&self) -> Result<(), ArtifactIoError> {
+ let (_, root_chain) = open_absolute_directory(&self.root)?;
+ if root_chain != self.root_chain {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ for directory in &self.directories {
+ let opened = if directory.relative.as_os_str().is_empty() {
+ let (file, chain) = open_absolute_directory(&self.root)?;
+ OpenedObject {
+ identity: identity(&file)?,
+ file,
+ chain,
+ }
+ } else {
+ open_relative(&self.root, &directory.relative, ObjectKind::Directory)?
+ };
+ if directory_identity(opened.identity) != directory.identity
+ || permission_mode(opened.identity) != directory.permission_mode
+ {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ revalidate_directory_members(&opened.file, &directory.members)?;
+ }
+ for file in &self.files {
+ let opened = open_relative(&self.root, &file.relative, ObjectKind::Regular)?;
+ if opened.identity != file.identity {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ }
+ Ok(())
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct FileIdentity {
+ device: u64,
+ inode: u64,
+ mode: u32,
+ owner: u32,
+ links: u64,
+ length: u64,
+ modified_seconds: i64,
+ modified_nanoseconds: i64,
+ changed_seconds: i64,
+ changed_nanoseconds: i64,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct DirectoryIdentity {
+ device: u64,
+ inode: u64,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct OutputDirectoryIdentity {
+ directory: DirectoryIdentity,
+ mode: u32,
+ owner: u32,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct TraversedDirectory {
+ relative: PathBuf,
+ identity: DirectoryIdentity,
+ permission_mode: u32,
+ members: Vec<DirectoryMember>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct DirectoryMember {
+ name: OsString,
+ identity: MemberIdentity,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum MemberIdentity {
+ Directory(DirectoryIdentity),
+ Regular(FileIdentity),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum ObjectKind {
+ Directory,
+ Regular,
+}
+
+struct OpenedObject {
+ file: File,
+ identity: FileIdentity,
+ chain: Vec<DirectoryIdentity>,
+}
+
+pub(crate) fn read_regular_path(path: &Path, maximum: u64) -> Result<Vec<u8>, ArtifactIoError> {
+ let (root, relative) = split_absolute_file(path)?;
+ read_regular(&root, &relative, maximum)
+}
+
+pub(crate) fn read_regular(
+ root: &Path,
+ relative: &Path,
+ maximum: u64,
+) -> Result<Vec<u8>, ArtifactIoError> {
+ read_regular_impl(root, relative, maximum, None, || {})
+}
+
+fn read_regular_impl<F>(
+ root: &Path,
+ relative: &Path,
+ maximum: u64,
+ expected: Option<&FileIdentity>,
+ after_open: F,
+) -> Result<Vec<u8>, ArtifactIoError>
+where
+ F: FnOnce(),
+{
+ read_regular_evidenced_impl(root, relative, maximum, expected, after_open)
+ .map(|(bytes, _)| bytes)
+}
+
+fn read_regular_evidenced_impl<F>(
+ root: &Path,
+ relative: &Path,
+ maximum: u64,
+ expected: Option<&FileIdentity>,
+ after_open: F,
+) -> Result<(Vec<u8>, FileEvidence), ArtifactIoError>
+where
+ F: FnOnce(),
+{
+ if maximum == 0 || maximum > HARD_MAX_BUFFERED_READ_BYTES {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ with_regular(
+ root,
+ relative,
+ maximum,
+ expected,
+ after_open,
+ |file, admitted_length| {
+ let initial_capacity = usize::try_from(maximum.min(STREAM_BUFFER_BYTES as u64))
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?;
+ let mut bytes = Vec::with_capacity(initial_capacity);
+ let mut hasher = Sha256::new();
+ let mut buffer = [0_u8; STREAM_BUFFER_BYTES];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ if read == 0 {
+ break;
+ }
+ let next = (bytes.len() as u64)
+ .checked_add(read as u64)
+ .ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::FileBytes,
+ ))
+ })?;
+ if next > maximum {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::FileBytes,
+ )));
+ }
+ hasher.update(&buffer[..read]);
+ bytes.extend_from_slice(&buffer[..read]);
+ }
+ let byte_length = bytes.len() as u64;
+ require_observed_length(byte_length, admitted_length)?;
+ Ok((
+ bytes,
+ FileEvidence {
+ byte_length,
+ sha256: hex::encode(hasher.finalize()),
+ },
+ ))
+ },
+ )
+}
+
+pub(crate) fn hash_regular_path(
+ path: &Path,
+ maximum: u64,
+) -> Result<FileEvidence, ArtifactIoError> {
+ let (root, relative) = split_absolute_file(path)?;
+ hash_regular(&root, &relative, maximum)
+}
+
+pub(crate) fn hash_regular(
+ root: &Path,
+ relative: &Path,
+ maximum: u64,
+) -> Result<FileEvidence, ArtifactIoError> {
+ with_regular(
+ root,
+ relative,
+ maximum,
+ None,
+ || {},
+ |file, admitted_length| {
+ let evidence = stream_regular(file, maximum, None)?;
+ require_observed_length(evidence.byte_length, admitted_length)?;
+ Ok(evidence)
+ },
+ )
+}
+
+pub(crate) fn copy_regular_to_new_path(
+ source: &Path,
+ output: &Path,
+ maximum: u64,
+) -> Result<FileEvidence, ArtifactIoError> {
+ copy_regular_to_new_path_impl(source, output, maximum, None, || {}, || {})
+}
+
+fn copy_regular_to_new_path_impl<H, J>(
+ source: &Path,
+ output: &Path,
+ maximum: u64,
+ expected: Option<&FileIdentity>,
+ after_open: H,
+ after_install: J,
+) -> Result<FileEvidence, ArtifactIoError>
+where
+ H: FnOnce(),
+ J: FnOnce(),
+{
+ if !output.is_absolute() || output.file_name().is_none() {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ let output_parent = output
+ .parent()
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?;
+ let (root, relative) = split_absolute_file(source)?;
+ copy_regular_descriptor_relative(
+ &root,
+ &relative,
+ output_parent,
+ output
+ .file_name()
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?,
+ maximum,
+ expected,
+ after_open,
+ after_install,
+ )
+}
+
+#[cfg(unix)]
+#[allow(clippy::too_many_arguments)]
+fn copy_regular_descriptor_relative<H, J>(
+ input_root: &Path,
+ input_relative: &Path,
+ output_parent: &Path,
+ output_name: &OsStr,
+ maximum: u64,
+ expected: Option<&FileIdentity>,
+ after_open: H,
+ after_install: J,
+) -> Result<FileEvidence, ArtifactIoError>
+where
+ H: FnOnce(),
+ J: FnOnce(),
+{
+ use rustix::fs::{Mode, OFlags, fchmod, openat};
+
+ validate_single_component(output_name)?;
+ let (parent, parent_chain) = open_trusted_output_directory(output_parent)?;
+ let parent_identity = *parent_chain
+ .last()
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?;
+ let output = openat(
+ &parent,
+ output_name,
+ OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
+ Mode::RUSR | Mode::WUSR,
+ )
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?;
+ let mut output = File::from(output);
+ fchmod(&output, Mode::RUSR | Mode::WUSR)
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ let initial_output = identity(&output)?;
+ if initial_output.links != 1 || permission_mode(initial_output) != 0o600 {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+
+ let evidence = with_regular(
+ input_root,
+ input_relative,
+ maximum,
+ expected,
+ after_open,
+ |input, admitted_length| {
+ let evidence = stream_regular(input, maximum, Some(&mut output))?;
+ require_observed_length(evidence.byte_length, admitted_length)?;
+ output
+ .sync_all()
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ Ok(evidence)
+ },
+ )?;
+ let finalized_identity = identity(&output)?;
+ if finalized_identity.links != 1
+ || finalized_identity.length != evidence.byte_length
+ || permission_mode(finalized_identity) != 0o600
+ {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ if parent.sync_all().is_err() {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::IoFailure));
+ }
+ after_install();
+ let installed_identity = identity(&output)?;
+ if installed_identity != finalized_identity {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ let rebound = open_at(&parent, output_name);
+ if rebound.as_ref().ok().and_then(|file| identity(file).ok()) != Some(finalized_identity) {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ let retained_parent_identity = output_directory_identity(identity(&parent)?);
+ let current_parent_chain = open_trusted_output_directory(output_parent)
+ .map(|(_, chain)| chain)
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::ChangedDuringRead))?;
+ if retained_parent_identity != parent_identity || current_parent_chain != parent_chain {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ Ok(evidence)
+}
+
+#[cfg(not(unix))]
+#[allow(clippy::too_many_arguments)]
+fn copy_regular_descriptor_relative<H, J>(
+ _input_root: &Path,
+ _input_relative: &Path,
+ _output_parent: &Path,
+ _output_name: &OsStr,
+ _maximum: u64,
+ _expected: Option<&FileIdentity>,
+ _after_open: H,
+ _after_install: J,
+) -> Result<FileEvidence, ArtifactIoError>
+where
+ H: FnOnce(),
+ J: FnOnce(),
+{
+ Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::UnsupportedPlatform,
+ ))
+}
+
+fn require_observed_length(observed: u64, admitted: u64) -> Result<(), ArtifactIoError> {
+ if observed == admitted {
+ Ok(())
+ } else {
+ Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ))
+ }
+}
+
+const fn output_directory_identity(identity: FileIdentity) -> OutputDirectoryIdentity {
+ OutputDirectoryIdentity {
+ directory: directory_identity(identity),
+ mode: permission_mode(identity),
+ owner: identity.owner,
+ }
+}
+
+#[cfg(unix)]
+fn output_directory_is_trusted(identity: OutputDirectoryIdentity) -> bool {
+ (identity.owner == 0 || identity.owner == rustix::process::geteuid().as_raw())
+ && identity.mode & 0o022 == 0
+}
+
+fn stream_regular(
+ file: &mut File,
+ maximum: u64,
+ mut output: Option<&mut File>,
+) -> Result<FileEvidence, ArtifactIoError> {
+ let mut hasher = Sha256::new();
+ let mut total = 0_u64;
+ let mut buffer = [0_u8; STREAM_BUFFER_BYTES];
+ loop {
+ let read = file
+ .read(&mut buffer)
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ if read == 0 {
+ break;
+ }
+ total = total
+ .checked_add(read as u64)
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?;
+ if total > maximum {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::FileBytes,
+ )));
+ }
+ hasher.update(&buffer[..read]);
+ if let Some(destination) = output.as_deref_mut() {
+ destination
+ .write_all(&buffer[..read])
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ }
+ }
+ Ok(FileEvidence {
+ byte_length: total,
+ sha256: hex::encode(hasher.finalize()),
+ })
+}
+
+fn with_regular<T, F, H>(
+ root: &Path,
+ relative: &Path,
+ maximum: u64,
+ expected: Option<&FileIdentity>,
+ after_open: H,
+ operation: F,
+) -> Result<T, ArtifactIoError>
+where
+ F: FnOnce(&mut File, u64) -> Result<T, ArtifactIoError>,
+ H: FnOnce(),
+{
+ if maximum == 0 || maximum > HARD_MAX_STREAM_FILE_BYTES {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ let mut opened = open_relative(root, relative, ObjectKind::Regular)?;
+ if opened.identity.length > maximum {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::FileBytes,
+ )));
+ }
+ if opened.identity.links != 1 {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ if expected.is_some_and(|identity| *identity != opened.identity) {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ let before = opened.identity;
+ let chain = opened.chain.clone();
+ after_open();
+ let result = operation(&mut opened.file, before.length)?;
+ let after = identity(&opened.file)?;
+ let rebound = open_relative(root, relative, ObjectKind::Regular)?;
+ if after != before || rebound.identity != before || rebound.chain != chain {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::ChangedDuringRead,
+ ));
+ }
+ Ok(result)
+}
+
+pub(crate) fn traverse_regular_files(
+ root: &Path,
+ limits: TraversalLimits,
+ excluded_directory_names: &[&str],
+) -> Result<TraversalSnapshot, ArtifactIoError> {
+ traverse_regular_files_impl(root, limits, excluded_directory_names, || {})
+}
+
+fn traverse_regular_files_impl<F>(
+ root: &Path,
+ limits: TraversalLimits,
+ excluded_directory_names: &[&str],
+ after_walk: F,
+) -> Result<TraversalSnapshot, ArtifactIoError>
+where
+ F: FnOnce(),
+{
+ validate_traversal_limits(limits)?;
+ if excluded_directory_names.iter().any(|name| {
+ name.is_empty()
+ || matches!(*name, "." | "..")
+ || name.as_bytes().contains(&b'/')
+ || name.as_bytes().contains(&b'\\')
+ || name.as_bytes().contains(&0)
+ }) {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ let (root_file, root_chain) = open_absolute_directory(root)?;
+ let mut state = TraversalState {
+ limits,
+ exclusions: excluded_directory_names,
+ entries: 0,
+ files: Vec::new(),
+ directories: Vec::new(),
+ total_bytes: 0,
+ };
+ let root_identity = identity(&root_file)?;
+ walk_directory(&root_file, Path::new(""), 0, root_identity, &mut state)?;
+ after_walk();
+ let snapshot = TraversalSnapshot {
+ root: root.to_path_buf(),
+ root_chain,
+ directories: state.directories,
+ files: state.files,
+ entry_count: state.entries,
+ total_bytes: state.total_bytes,
+ };
+ snapshot.revalidate()?;
+ Ok(snapshot)
+}
+
+struct TraversalState<'a> {
+ limits: TraversalLimits,
+ exclusions: &'a [&'a str],
+ entries: u64,
+ files: Vec<TraversedFile>,
+ directories: Vec<TraversedDirectory>,
+ total_bytes: u64,
+}
+
+fn validate_traversal_limits(limits: TraversalLimits) -> Result<(), ArtifactIoError> {
+ if limits.max_entries == 0
+ || limits.max_entries > HARD_MAX_TRAVERSAL_ENTRIES
+ || limits.max_files == 0
+ || limits.max_files > HARD_MAX_TRAVERSAL_FILES
+ || limits.max_total_bytes == 0
+ || limits.max_total_bytes > HARD_MAX_TRAVERSAL_TOTAL_BYTES
+ || limits.max_file_bytes == 0
+ || limits.max_file_bytes > HARD_MAX_STREAM_FILE_BYTES
+ || limits.max_depth == 0
+ || limits.max_depth > HARD_MAX_TRAVERSAL_DEPTH
+ || limits.max_path_bytes == 0
+ || limits.max_path_bytes > HARD_MAX_PATH_BYTES
+ {
+ Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))
+ } else {
+ Ok(())
+ }
+}
+
+#[cfg(unix)]
+fn walk_directory(
+ directory: &File,
+ relative: &Path,
+ depth: usize,
+ current_identity: FileIdentity,
+ state: &mut TraversalState<'_>,
+) -> Result<(), ArtifactIoError> {
+ use std::ffi::OsStr;
+ use std::os::unix::ffi::OsStrExt as _;
+
+ let mut reader = rustix::fs::Dir::read_from(directory)
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ let mut names = Vec::new();
+ while let Some(entry) = reader.read() {
+ let entry = entry.map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ let name = entry.file_name().to_bytes();
+ if matches!(name, b"." | b"..") {
+ continue;
+ }
+ state.entries = state.entries.checked_add(1).ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalEntries,
+ ))
+ })?;
+ if state.entries > state.limits.max_entries {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalEntries,
+ )));
+ }
+ names.push(name.to_vec());
+ }
+ names.sort();
+
+ let mut members = Vec::with_capacity(names.len());
+ for name in names {
+ if name.is_empty() || name.contains(&0) || name.contains(&b'/') {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ let component = OsStr::from_bytes(&name);
+ let child_relative = relative.join(component);
+ if path_byte_len(&child_relative) > state.limits.max_path_bytes {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalPathBytes,
+ )));
+ }
+ let child = open_at(directory, component)?;
+ let metadata = child
+ .metadata()
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ let child_identity = identity_from_metadata(&metadata);
+ if metadata.is_dir() {
+ let child_directory_identity = directory_identity(child_identity);
+ members.push(DirectoryMember {
+ name: component.to_os_string(),
+ identity: MemberIdentity::Directory(child_directory_identity),
+ });
+ let next_depth = depth.checked_add(1).ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalDepth,
+ ))
+ })?;
+ if next_depth > state.limits.max_depth {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalDepth,
+ )));
+ }
+ if state
+ .exclusions
+ .iter()
+ .any(|excluded| name == excluded.as_bytes())
+ {
+ continue;
+ }
+ walk_directory(&child, &child_relative, next_depth, child_identity, state)?;
+ } else if metadata.is_file() {
+ if child_identity.links != 1 {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ if child_identity.length > state.limits.max_file_bytes {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::FileBytes,
+ )));
+ }
+ let file_count = state.files.len().checked_add(1).ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalFiles,
+ ))
+ })?;
+ if file_count as u64 > state.limits.max_files {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalFiles,
+ )));
+ }
+ state.total_bytes = state
+ .total_bytes
+ .checked_add(child_identity.length)
+ .ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalTotalBytes,
+ ))
+ })?;
+ if state.total_bytes > state.limits.max_total_bytes {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::TraversalTotalBytes,
+ )));
+ }
+ state.files.push(TraversedFile {
+ relative: child_relative,
+ identity: child_identity,
+ });
+ members.push(DirectoryMember {
+ name: component.to_os_string(),
+ identity: MemberIdentity::Regular(child_identity),
+ });
+ } else {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ }
+ state.directories.push(TraversedDirectory {
+ relative: relative.to_path_buf(),
+ identity: directory_identity(current_identity),
+ permission_mode: permission_mode(current_identity),
+ members,
+ });
+ Ok(())
+}
+
+#[cfg(unix)]
+fn revalidate_directory_members(
+ directory: &File,
+ expected: &[DirectoryMember],
+) -> Result<(), ArtifactIoError> {
+ use std::os::unix::ffi::OsStrExt as _;
+
+ let changed = || ArtifactIoError::new(ArtifactIoFailureKind::ChangedDuringRead);
+ let mut reader = rustix::fs::Dir::read_from(directory).map_err(|_| changed())?;
+ let mut names = Vec::with_capacity(expected.len());
+ while let Some(entry) = reader.read() {
+ let entry = entry.map_err(|_| changed())?;
+ let name = entry.file_name().to_bytes();
+ if matches!(name, b"." | b"..") {
+ continue;
+ }
+ if names.len() == expected.len() {
+ return Err(changed());
+ }
+ names.push(OsString::from(OsStr::from_bytes(name)));
+ }
+ names.sort();
+ if names.len() != expected.len()
+ || names
+ .iter()
+ .zip(expected)
+ .any(|(name, member)| name != &member.name)
+ {
+ return Err(changed());
+ }
+ for member in expected {
+ let child = open_at(directory, &member.name).map_err(|_| changed())?;
+ let metadata = child.metadata().map_err(|_| changed())?;
+ let identity = identity_from_metadata(&metadata);
+ let observed = if metadata.is_dir() {
+ MemberIdentity::Directory(directory_identity(identity))
+ } else if metadata.is_file() && identity.links == 1 {
+ MemberIdentity::Regular(identity)
+ } else {
+ return Err(changed());
+ };
+ if observed != member.identity {
+ return Err(changed());
+ }
+ }
+ Ok(())
+}
+
+#[cfg(not(unix))]
+fn revalidate_directory_members(
+ _directory: &File,
+ _expected: &[DirectoryMember],
+) -> Result<(), ArtifactIoError> {
+ Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::UnsupportedPlatform,
+ ))
+}
+
+#[cfg(not(unix))]
+fn walk_directory(
+ _directory: &File,
+ _relative: &Path,
+ _depth: usize,
+ _current_identity: FileIdentity,
+ _state: &mut TraversalState<'_>,
+) -> Result<(), ArtifactIoError> {
+ Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::UnsupportedPlatform,
+ ))
+}
+
+pub(crate) fn admit_tar_gzip_path(
+ path: &Path,
+ limits: TarGzipLimits,
+) -> Result<ArchiveEvidence, ArtifactIoError> {
+ validate_archive_limits(limits)?;
+ let (root, relative) = split_absolute_file(path)?;
+ with_regular(
+ &root,
+ &relative,
+ limits.max_compressed_bytes,
+ None,
+ || {},
+ |file, admitted_length| {
+ let evidence = admit_tar_gzip_reader(file, limits)?;
+ require_observed_length(evidence.compressed.byte_length, admitted_length)?;
+ Ok(evidence)
+ },
+ )
+ .map_err(|error| match error.kind() {
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes) => ArtifactIoError::new(
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveCompressedBytes),
+ ),
+ _ => error,
+ })
+}
+
+fn validate_archive_limits(limits: TarGzipLimits) -> Result<(), ArtifactIoError> {
+ if limits.max_compressed_bytes == 0
+ || limits.max_compressed_bytes > HARD_MAX_ARCHIVE_COMPRESSED_BYTES
+ || limits.max_expanded_bytes == 0
+ || limits.max_expanded_bytes > HARD_MAX_ARCHIVE_EXPANDED_BYTES
+ || limits.max_members == 0
+ || limits.max_members > HARD_MAX_ARCHIVE_MEMBERS
+ || limits.max_member_bytes == 0
+ || limits.max_member_bytes > HARD_MAX_ARCHIVE_MEMBER_BYTES
+ || limits.max_payload_bytes == 0
+ || limits.max_payload_bytes > HARD_MAX_ARCHIVE_PAYLOAD_BYTES
+ || limits.max_depth == 0
+ || limits.max_depth > HARD_MAX_TRAVERSAL_DEPTH
+ || limits.max_path_bytes == 0
+ || limits.max_path_bytes > HARD_MAX_PATH_BYTES
+ {
+ Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))
+ } else {
+ Ok(())
+ }
+}
+
+fn admit_tar_gzip_reader(
+ file: &mut File,
+ limits: TarGzipLimits,
+) -> Result<ArchiveEvidence, ArtifactIoError> {
+ let expected_compressed_bytes = file
+ .metadata()
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?
+ .len();
+ let compressed = HashingLimitReader::new(file, limits.max_compressed_bytes);
+ let mut buffered = BufReader::new(compressed);
+ validate_minimal_gzip_header(&mut buffered)?;
+ let decoder = GzDecoder::new(buffered);
+ let expanded = LimitReader::new(decoder, limits.max_expanded_bytes);
+ let mut archive = tar::Archive::new(expanded);
+ let mut names = BTreeMap::<Vec<u8>, ArchiveMemberKind>::new();
+ let mut members = 0_u64;
+ let mut payload = 0_u64;
+
+ let parse_result = (|| {
+ let entries = archive
+ .entries()
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))?
+ .raw(true);
+ for entry in entries {
+ let mut entry =
+ entry.map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))?;
+ members = members.checked_add(1).ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveMembers,
+ ))
+ })?;
+ if members > limits.max_members {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveMembers,
+ )));
+ }
+ let declared = entry
+ .header()
+ .size()
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))?;
+ if declared > limits.max_member_bytes {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveMemberBytes,
+ )));
+ }
+ let member_kind = match entry.header().entry_type().as_byte() {
+ 0 | b'0' => ArchiveMemberKind::File,
+ b'5' if declared == 0 => ArchiveMemberKind::Directory,
+ _ => {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ };
+ let name = entry.path_bytes().into_owned();
+ validate_archive_path(&name, limits.max_depth, limits.max_path_bytes)?;
+ if member_kind == ArchiveMemberKind::File && name.ends_with(b"/") {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ admit_archive_name(&mut names, name, member_kind)?;
+ payload = payload.checked_add(declared).ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchivePayloadBytes,
+ ))
+ })?;
+ if payload > limits.max_payload_bytes {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchivePayloadBytes,
+ )));
+ }
+ let mut actual = 0_u64;
+ let mut buffer = [0_u8; STREAM_BUFFER_BYTES];
+ loop {
+ let read = entry
+ .read(&mut buffer)
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))?;
+ if read == 0 {
+ break;
+ }
+ actual = actual.checked_add(read as u64).ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveMemberBytes,
+ ))
+ })?;
+ if actual > limits.max_member_bytes {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveMemberBytes,
+ )));
+ }
+ }
+ if actual != declared {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::MalformedArchive,
+ ));
+ }
+ }
+ Ok(())
+ })();
+
+ let mut expanded = archive.into_inner();
+ if expanded.exceeded() {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveExpandedBytes,
+ )));
+ }
+ if compressed_limit_exceeded(&expanded) {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveCompressedBytes,
+ )));
+ }
+ parse_result?;
+
+ let mut trailing = [0_u8; STREAM_BUFFER_BYTES];
+ let mut trailing_zero_bytes = 0_u64;
+ loop {
+ let read = expanded
+ .read(&mut trailing)
+ .map_err(|_| classify_expanded_error(&expanded))?;
+ if read == 0 {
+ break;
+ }
+ if trailing[..read].iter().any(|byte| *byte != 0) {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::MalformedArchive,
+ ));
+ }
+ trailing_zero_bytes = trailing_zero_bytes
+ .checked_add(read as u64)
+ .ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveExpandedBytes,
+ ))
+ })?;
+ }
+ if expanded.exceeded() {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveExpandedBytes,
+ )));
+ }
+ if compressed_limit_exceeded(&expanded) {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveCompressedBytes,
+ )));
+ }
+ if members == 0 || trailing_zero_bytes < 512 {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::MalformedArchive,
+ ));
+ }
+ let expanded_bytes = expanded.total();
+ let decoder = expanded.into_inner();
+ let mut buffered = decoder.into_inner();
+ if !buffered
+ .fill_buf()
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive))?
+ .is_empty()
+ {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::MalformedArchive,
+ ));
+ }
+ let compressed = buffered.into_inner();
+ if compressed.exceeded() {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveCompressedBytes,
+ )));
+ }
+ let compressed_bytes = compressed.total();
+ if compressed_bytes != expected_compressed_bytes {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::MalformedArchive,
+ ));
+ }
+ let compressed_hash = compressed.finalize();
+ Ok(ArchiveEvidence {
+ compressed: FileEvidence {
+ byte_length: compressed_bytes,
+ sha256: compressed_hash,
+ },
+ expanded_bytes,
+ member_count: members,
+ payload_bytes: payload,
+ })
+}
+
+fn validate_minimal_gzip_header(
+ reader: &mut BufReader<HashingLimitReader<'_>>,
+) -> Result<(), ArtifactIoError> {
+ if reader.fill_buf().is_err() {
+ return Err(if reader.get_ref().exceeded() {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveCompressedBytes,
+ ))
+ } else {
+ ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive)
+ });
+ }
+ let header = reader.buffer();
+ if header.len() < 10 || header[..3] != [0x1f, 0x8b, 8] {
+ return Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::MalformedArchive,
+ ));
+ }
+ if header[3] != 0 {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ Ok(())
+}
+
+fn classify_expanded_error(
+ reader: &LimitReader<GzDecoder<BufReader<HashingLimitReader<'_>>>>,
+) -> ArtifactIoError {
+ if reader.exceeded() {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveExpandedBytes,
+ ))
+ } else if compressed_limit_exceeded(reader) {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveCompressedBytes,
+ ))
+ } else {
+ ArtifactIoError::new(ArtifactIoFailureKind::MalformedArchive)
+ }
+}
+
+fn compressed_limit_exceeded(
+ reader: &LimitReader<GzDecoder<BufReader<HashingLimitReader<'_>>>>,
+) -> bool {
+ reader.get_ref().get_ref().get_ref().exceeded()
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum ArchiveMemberKind {
+ Directory,
+ File,
+}
+
+fn validate_archive_path(
+ path: &[u8],
+ maximum_depth: usize,
+ maximum_bytes: usize,
+) -> Result<(), ArtifactIoError> {
+ if path.len() > maximum_bytes {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchivePathBytes,
+ )));
+ }
+ if path.is_empty()
+ || path.starts_with(b"/")
+ || path.contains(&0)
+ || path.contains(&b'\\')
+ || std::str::from_utf8(path).is_err()
+ {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ let trimmed = path.strip_suffix(b"/").unwrap_or(path);
+ let mut depth = 0_usize;
+ for component in trimmed.split(|byte| *byte == b'/') {
+ if component.is_empty() || matches!(component, b"." | b"..") {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ depth = depth.checked_add(1).ok_or_else(|| {
+ ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveDepth,
+ ))
+ })?;
+ }
+ if depth > maximum_depth {
+ Err(ArtifactIoError::new(ArtifactIoFailureKind::LimitExceeded(
+ LimitKind::ArchiveDepth,
+ )))
+ } else {
+ Ok(())
+ }
+}
+
+fn admit_archive_name(
+ names: &mut BTreeMap<Vec<u8>, ArchiveMemberKind>,
+ mut path: Vec<u8>,
+ kind: ArchiveMemberKind,
+) -> Result<(), ArtifactIoError> {
+ if kind == ArchiveMemberKind::Directory && path.ends_with(b"/") {
+ path.pop();
+ }
+ if names.contains_key(&path) {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ let mut offset = 0_usize;
+ while let Some(index) = path[offset..].iter().position(|byte| *byte == b'/') {
+ offset = offset
+ .checked_add(index)
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?;
+ if names
+ .get(&path[..offset])
+ .is_some_and(|existing| *existing == ArchiveMemberKind::File)
+ {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ offset = offset
+ .checked_add(1)
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?;
+ }
+ if kind == ArchiveMemberKind::File {
+ let mut prefix = path.clone();
+ prefix.push(b'/');
+ if names
+ .range(prefix.clone()..)
+ .next()
+ .is_some_and(|(candidate, _)| candidate.starts_with(&prefix))
+ {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ }
+ names.insert(path, kind);
+ Ok(())
+}
+
+struct LimitReader<R> {
+ inner: R,
+ maximum: u64,
+ total: u64,
+ exceeded: bool,
+}
+
+impl<R> LimitReader<R> {
+ const fn new(inner: R, maximum: u64) -> Self {
+ Self {
+ inner,
+ maximum,
+ total: 0,
+ exceeded: false,
+ }
+ }
+
+ const fn total(&self) -> u64 {
+ self.total
+ }
+
+ const fn exceeded(&self) -> bool {
+ self.exceeded
+ }
+
+ fn into_inner(self) -> R {
+ self.inner
+ }
+
+ const fn get_ref(&self) -> &R {
+ &self.inner
+ }
+}
+
+impl<R: Read> Read for LimitReader<R> {
+ fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
+ if output.is_empty() {
+ return Ok(0);
+ }
+ let remaining = self.maximum.saturating_sub(self.total);
+ let allowed = usize::try_from(remaining.saturating_add(1))
+ .unwrap_or(usize::MAX)
+ .min(output.len());
+ let read = self.inner.read(&mut output[..allowed])?;
+ self.total = self
+ .total
+ .checked_add(read as u64)
+ .ok_or_else(|| io::Error::other("bounded stream length overflow"))?;
+ if self.total > self.maximum {
+ self.exceeded = true;
+ Err(io::Error::other("bounded stream limit exceeded"))
+ } else {
+ Ok(read)
+ }
+ }
+}
+
+struct HashingLimitReader<'a> {
+ inner: &'a mut File,
+ maximum: u64,
+ total: u64,
+ exceeded: bool,
+ hasher: Sha256,
+}
+
+impl<'a> HashingLimitReader<'a> {
+ fn new(inner: &'a mut File, maximum: u64) -> Self {
+ Self {
+ inner,
+ maximum,
+ total: 0,
+ exceeded: false,
+ hasher: Sha256::new(),
+ }
+ }
+
+ const fn total(&self) -> u64 {
+ self.total
+ }
+
+ const fn exceeded(&self) -> bool {
+ self.exceeded
+ }
+
+ fn finalize(self) -> String {
+ hex::encode(self.hasher.finalize())
+ }
+}
+
+impl Read for HashingLimitReader<'_> {
+ fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
+ if output.is_empty() {
+ return Ok(0);
+ }
+ let remaining = self.maximum.saturating_sub(self.total);
+ let allowed = usize::try_from(remaining.saturating_add(1))
+ .unwrap_or(usize::MAX)
+ .min(output.len());
+ let read = self.inner.read(&mut output[..allowed])?;
+ self.total = self
+ .total
+ .checked_add(read as u64)
+ .ok_or_else(|| io::Error::other("bounded stream length overflow"))?;
+ if self.total > self.maximum {
+ self.exceeded = true;
+ return Err(io::Error::other("bounded stream limit exceeded"));
+ }
+ self.hasher.update(&output[..read]);
+ Ok(read)
+ }
+}
+
+fn split_absolute_file(path: &Path) -> Result<(PathBuf, PathBuf), ArtifactIoError> {
+ if !path.is_absolute() {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ let parent = path
+ .parent()
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?;
+ let name = path
+ .file_name()
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?;
+ Ok((parent.to_path_buf(), PathBuf::from(name)))
+}
+
+#[cfg(unix)]
+fn open_absolute_directory(path: &Path) -> Result<(File, Vec<DirectoryIdentity>), ArtifactIoError> {
+ use rustix::fs::{Mode, OFlags, open, openat};
+
+ if !path.is_absolute() {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ let flags =
+ OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::DIRECTORY;
+ let root = open("/", flags, Mode::empty())
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ let mut current = File::from(root);
+ let mut chain = vec![directory_identity(identity(¤t)?)];
+ for component in path.components() {
+ match component {
+ Component::RootDir => {}
+ Component::Normal(name) => {
+ let next = openat(¤t, name, flags, Mode::empty())
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?;
+ current = File::from(next);
+ chain.push(directory_identity(identity(¤t)?));
+ }
+ _ => {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ }
+ }
+ Ok((current, chain))
+}
+
+#[cfg(unix)]
+fn open_trusted_output_directory(
+ path: &Path,
+) -> Result<(File, Vec<OutputDirectoryIdentity>), ArtifactIoError> {
+ use rustix::fs::{Mode, OFlags, open, openat};
+
+ if !path.is_absolute() {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ let flags =
+ OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::DIRECTORY;
+ let root = open("/", flags, Mode::empty())
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ let mut current = File::from(root);
+ let mut chain = Vec::new();
+ let root_identity = output_directory_identity(identity(¤t)?);
+ if !output_directory_is_trusted(root_identity) {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ chain.push(root_identity);
+ for component in path.components() {
+ match component {
+ Component::RootDir => {}
+ Component::Normal(name) => {
+ let next = openat(¤t, name, flags, Mode::empty())
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?;
+ current = File::from(next);
+ let current_identity = output_directory_identity(identity(¤t)?);
+ if !output_directory_is_trusted(current_identity) {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ chain.push(current_identity);
+ }
+ _ => {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ }
+ }
+ }
+ Ok((current, chain))
+}
+
+#[cfg(not(unix))]
+fn open_absolute_directory(
+ _path: &Path,
+) -> Result<(File, Vec<DirectoryIdentity>), ArtifactIoError> {
+ Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::UnsupportedPlatform,
+ ))
+}
+
+#[cfg(not(unix))]
+fn open_trusted_output_directory(
+ _path: &Path,
+) -> Result<(File, Vec<OutputDirectoryIdentity>), ArtifactIoError> {
+ Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::UnsupportedPlatform,
+ ))
+}
+
+fn open_relative(
+ root: &Path,
+ relative: &Path,
+ expected: ObjectKind,
+) -> Result<OpenedObject, ArtifactIoError> {
+ validate_relative(relative)?;
+ let (mut current, mut chain) = open_absolute_directory(root)?;
+ let components = relative.components().collect::<Vec<_>>();
+ let mut object_identity = None;
+ for (index, component) in components.iter().enumerate() {
+ let Component::Normal(name) = component else {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest));
+ };
+ let object = open_at(¤t, name)?;
+ let metadata = object
+ .metadata()
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ let final_component = index + 1 == components.len();
+ if (!final_component && !metadata.is_dir())
+ || (final_component
+ && match expected {
+ ObjectKind::Directory => !metadata.is_dir(),
+ ObjectKind::Regular => !metadata.is_file(),
+ })
+ {
+ return Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject));
+ }
+ let current_identity = identity_from_metadata(&metadata);
+ current = object;
+ if metadata.is_dir() {
+ chain.push(directory_identity(current_identity));
+ }
+ object_identity = Some(current_identity);
+ }
+ let object_identity = object_identity
+ .ok_or_else(|| ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))?;
+ Ok(OpenedObject {
+ file: current,
+ identity: object_identity,
+ chain,
+ })
+}
+
+#[cfg(unix)]
+fn open_at(directory: &File, name: &std::ffi::OsStr) -> Result<File, ArtifactIoError> {
+ use rustix::fs::{Mode, OFlags, openat};
+
+ let flags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
+ let descriptor = openat(directory, name, flags, Mode::empty())
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?;
+ Ok(File::from(descriptor))
+}
+
+#[cfg(not(unix))]
+fn open_at(_directory: &File, _name: &std::ffi::OsStr) -> Result<File, ArtifactIoError> {
+ Err(ArtifactIoError::new(
+ ArtifactIoFailureKind::UnsupportedPlatform,
+ ))
+}
+
+fn validate_relative(path: &Path) -> Result<(), ArtifactIoError> {
+ if path.as_os_str().is_empty()
+ || path.is_absolute()
+ || path
+ .components()
+ .any(|component| !matches!(component, Component::Normal(_)))
+ {
+ Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))
+ } else {
+ Ok(())
+ }
+}
+
+fn validate_single_component(name: &OsStr) -> Result<(), ArtifactIoError> {
+ let path = Path::new(name);
+ let mut components = path.components();
+ if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() {
+ Ok(())
+ } else {
+ Err(ArtifactIoError::new(ArtifactIoFailureKind::InvalidRequest))
+ }
+}
+
+#[cfg(unix)]
+fn path_byte_len(path: &Path) -> usize {
+ use std::os::unix::ffi::OsStrExt as _;
+ path.as_os_str().as_bytes().len()
+}
+
+#[cfg(not(unix))]
+fn path_byte_len(path: &Path) -> usize {
+ path.as_os_str().to_string_lossy().len()
+}
+
+fn identity(file: &File) -> Result<FileIdentity, ArtifactIoError> {
+ let metadata = file
+ .metadata()
+ .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::IoFailure))?;
+ Ok(identity_from_metadata(&metadata))
+}
+
+const fn directory_identity(identity: FileIdentity) -> DirectoryIdentity {
+ DirectoryIdentity {
+ device: identity.device,
+ inode: identity.inode,
+ }
+}
+
+const fn permission_mode(identity: FileIdentity) -> u32 {
+ identity.mode & 0o7777
+}
+
+#[cfg(unix)]
+fn identity_from_metadata(metadata: &std::fs::Metadata) -> FileIdentity {
+ use std::os::unix::fs::MetadataExt as _;
+ FileIdentity {
+ device: metadata.dev(),
+ inode: metadata.ino(),
+ mode: metadata.mode(),
+ owner: metadata.uid(),
+ links: metadata.nlink(),
+ length: metadata.len(),
+ modified_seconds: metadata.mtime(),
+ modified_nanoseconds: metadata.mtime_nsec(),
+ changed_seconds: metadata.ctime(),
+ changed_nanoseconds: metadata.ctime_nsec(),
+ }
+}
+
+#[cfg(not(unix))]
+fn identity_from_metadata(metadata: &std::fs::Metadata) -> FileIdentity {
+ FileIdentity {
+ device: 0,
+ inode: 0,
+ mode: 0,
+ owner: 0,
+ links: 0,
+ length: metadata.len(),
+ modified_seconds: 0,
+ modified_nanoseconds: 0,
+ changed_seconds: 0,
+ changed_nanoseconds: 0,
+ }
+}
+
+pub(crate) fn self_test() -> Result<(), String> {
+ #[cfg(unix)]
+ {
+ self_test_suite()?;
+ Ok(())
+ }
+ #[cfg(not(unix))]
+ {
+ Err(ArtifactIoError::new(ArtifactIoFailureKind::UnsupportedPlatform).to_string())
+ }
+}
+
+#[cfg(unix)]
+fn self_test_suite() -> Result<(), String> {
+ let directory =
+ tempfile::tempdir().map_err(|_| "safe artifact I/O self-test setup failed".to_owned())?;
+ let root = directory
+ .path()
+ .canonicalize()
+ .map_err(|_| "safe artifact I/O self-test setup failed".to_owned())?;
+ fs::write(root.join("input"), b"step-293-safe-artifact")
+ .map_err(|_| "safe artifact I/O self-test setup failed".to_owned())?;
+
+ let bytes = read_regular(&root, Path::new("input"), 64).map_err(|error| error.to_string())?;
+ let evidence =
+ hash_regular(&root, Path::new("input"), 64).map_err(|error| error.to_string())?;
+ if bytes != b"step-293-safe-artifact"
+ || hash_regular(&root, Path::new("input"), 64).map_err(|error| error.to_string())?
+ != evidence
+ {
+ return Err("safe artifact I/O self-test evidence failed".to_owned());
+ }
+ let traversal = traverse_regular_files(
+ &root,
+ TraversalLimits {
+ max_entries: 4,
+ max_files: 4,
+ max_total_bytes: 256,
+ max_file_bytes: 128,
+ max_depth: 2,
+ max_path_bytes: 128,
+ },
+ &[],
+ )
+ .map_err(|error| error.to_string())?;
+ if traversal.entry_count() != 1
+ || traversal.total_bytes() != evidence.byte_length
+ || traversal
+ .read(&traversal.files()[0], 64)
+ .map_err(|error| error.to_string())?
+ != bytes
+ || traversal
+ .read_evidenced(&traversal.files()[0], 64)
+ .map_err(|error| error.to_string())?
+ != (bytes.clone(), evidence.clone())
+ {
+ return Err("safe artifact I/O self-test traversal failed".to_owned());
+ }
+
+ let archive_path = root.join("archive.tar.gz");
+ let output = File::create(&archive_path)
+ .map_err(|_| "safe artifact I/O self-test setup failed".to_owned())?;
+ let encoder = GzBuilder::new().write(output, Compression::fast());
+ let mut archive = TarBuilder::new(encoder);
+ let mut header = TarHeader::new_gnu();
+ header.set_size(bytes.len() as u64);
+ header.set_mode(0o644);
+ header.set_cksum();
+ archive
+ .append_data(&mut header, "payload", bytes.as_slice())
+ .map_err(|_| "safe artifact I/O self-test archive failed".to_owned())?;
+ let encoder = archive
+ .into_inner()
+ .map_err(|_| "safe artifact I/O self-test archive failed".to_owned())?;
+ encoder
+ .finish()
+ .map_err(|_| "safe artifact I/O self-test archive failed".to_owned())?;
+ let admitted = admit_tar_gzip_path(
+ &archive_path,
+ TarGzipLimits {
+ max_compressed_bytes: 65_536,
+ max_expanded_bytes: 65_536,
+ max_members: 4,
+ max_member_bytes: 256,
+ max_payload_bytes: 256,
+ max_depth: 4,
+ max_path_bytes: 128,
+ },
+ )
+ .map_err(|error| error.to_string())?;
+ if admitted.member_count != 1 || admitted.payload_bytes != bytes.len() as u64 {
+ return Err("safe artifact I/O self-test archive failed".to_owned());
+ }
+ Ok(())
+}
+
+#[cfg(all(test, unix))]
+mod tests {
+ use std::fs::{self, OpenOptions};
+ use std::os::unix::fs::{FileTypeExt as _, PermissionsExt as _, symlink};
+ use std::time::Duration;
+
+ use flate2::{Compression, GzBuilder};
+ use tar::{Builder, EntryType, Header};
+ use tempfile::TempDir;
+
+ use super::*;
+
+ fn root(directory: &TempDir) -> PathBuf {
+ directory
+ .path()
+ .canonicalize()
+ .expect("canonical test root")
+ }
+
+ fn traversal_limits() -> TraversalLimits {
+ TraversalLimits {
+ max_entries: 8,
+ max_files: 4,
+ max_total_bytes: 64,
+ max_file_bytes: 32,
+ max_depth: 3,
+ max_path_bytes: 64,
+ }
+ }
+
+ fn archive_limits() -> TarGzipLimits {
+ TarGzipLimits {
+ max_compressed_bytes: 32 * 1024,
+ max_expanded_bytes: 64 * 1024,
+ max_members: 8,
+ max_member_bytes: 8 * 1024,
+ max_payload_bytes: 16 * 1024,
+ max_depth: 4,
+ max_path_bytes: 128,
+ }
+ }
+
+ #[test]
+ fn hard_maximums_reject_invalid_requests() {
+ assert_eq!(HARD_MAX_BUFFERED_READ_BYTES, 67_108_864);
+ assert_eq!(HARD_MAX_STREAM_FILE_BYTES, 17_179_869_184);
+ assert_eq!(HARD_MAX_TRAVERSAL_ENTRIES, 65_536);
+ assert_eq!(HARD_MAX_TRAVERSAL_FILES, 65_536);
+ assert_eq!(HARD_MAX_TRAVERSAL_TOTAL_BYTES, 68_719_476_736);
+ assert_eq!(HARD_MAX_TRAVERSAL_DEPTH, 64);
+ assert_eq!(HARD_MAX_PATH_BYTES, 4_096);
+ assert_eq!(HARD_MAX_ARCHIVE_COMPRESSED_BYTES, 2_147_483_648);
+ assert_eq!(HARD_MAX_ARCHIVE_EXPANDED_BYTES, 17_179_869_184);
+ assert_eq!(HARD_MAX_ARCHIVE_MEMBERS, 65_536);
+ assert_eq!(HARD_MAX_ARCHIVE_MEMBER_BYTES, 17_179_869_184);
+ assert_eq!(HARD_MAX_ARCHIVE_PAYLOAD_BYTES, 17_179_869_184);
+ let directory = TempDir::new().expect("tempdir");
+ let root = root(&directory);
+ fs::write(root.join("input"), b"x").expect("input");
+ assert_eq!(
+ read_regular(&root, Path::new("input"), HARD_MAX_BUFFERED_READ_BYTES + 1,)
+ .expect_err("buffered read hard maximum")
+ .kind(),
+ ArtifactIoFailureKind::InvalidRequest
+ );
+ assert_eq!(
+ hash_regular(&root, Path::new("input"), HARD_MAX_STREAM_FILE_BYTES + 1,)
+ .expect_err("streaming file hard maximum")
+ .kind(),
+ ArtifactIoFailureKind::InvalidRequest
+ );
+
+ for mutate in [
+ |limits: &mut TraversalLimits| limits.max_entries = HARD_MAX_TRAVERSAL_ENTRIES + 1,
+ |limits: &mut TraversalLimits| limits.max_files = HARD_MAX_TRAVERSAL_FILES + 1,
+ |limits: &mut TraversalLimits| {
+ limits.max_total_bytes = HARD_MAX_TRAVERSAL_TOTAL_BYTES + 1
+ },
+ |limits: &mut TraversalLimits| limits.max_file_bytes = HARD_MAX_STREAM_FILE_BYTES + 1,
+ |limits: &mut TraversalLimits| limits.max_depth = HARD_MAX_TRAVERSAL_DEPTH + 1,
+ |limits: &mut TraversalLimits| limits.max_path_bytes = HARD_MAX_PATH_BYTES + 1,
+ ] {
+ let mut limits = traversal_limits();
+ mutate(&mut limits);
+ assert_eq!(
+ validate_traversal_limits(limits)
+ .expect_err("traversal hard maximum")
+ .kind(),
+ ArtifactIoFailureKind::InvalidRequest
+ );
+ }
+
+ for mutate in [
+ |limits: &mut TarGzipLimits| {
+ limits.max_compressed_bytes = HARD_MAX_ARCHIVE_COMPRESSED_BYTES + 1
+ },
+ |limits: &mut TarGzipLimits| {
+ limits.max_expanded_bytes = HARD_MAX_ARCHIVE_EXPANDED_BYTES + 1
+ },
+ |limits: &mut TarGzipLimits| limits.max_members = HARD_MAX_ARCHIVE_MEMBERS + 1,
+ |limits: &mut TarGzipLimits| {
+ limits.max_member_bytes = HARD_MAX_ARCHIVE_MEMBER_BYTES + 1
+ },
+ |limits: &mut TarGzipLimits| {
+ limits.max_payload_bytes = HARD_MAX_ARCHIVE_PAYLOAD_BYTES + 1
+ },
+ |limits: &mut TarGzipLimits| limits.max_depth = HARD_MAX_TRAVERSAL_DEPTH + 1,
+ |limits: &mut TarGzipLimits| limits.max_path_bytes = HARD_MAX_PATH_BYTES + 1,
+ ] {
+ let mut limits = archive_limits();
+ mutate(&mut limits);
+ assert_eq!(
+ validate_archive_limits(limits)
+ .expect_err("archive hard maximum")
+ .kind(),
+ ArtifactIoFailureKind::InvalidRequest
+ );
+ }
+ }
+
+ #[test]
+ fn bounded_read_hash_and_copy_are_streaming_and_exact() {
+ let directory = TempDir::new().expect("tempdir");
+ let root = root(&directory);
+ fs::write(root.join("input"), b"01234567").expect("write input");
+
+ assert_eq!(
+ read_regular(&root, Path::new("input"), 8).expect("read at cap"),
+ b"01234567"
+ );
+ assert_eq!(
+ read_regular(&root, Path::new("input"), 7)
+ .expect_err("read over cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes)
+ );
+ let evidence = hash_regular(&root, Path::new("input"), 8).expect("hash");
+ let output_path = root.join("output");
+ let copied = copy_regular_to_new_path(&root.join("input"), &output_path, 8)
+ .expect("create-new copy");
+ assert_eq!(evidence, copied);
+ assert_eq!(fs::read(output_path).expect("output bytes"), b"01234567");
+ assert_eq!(
+ fs::metadata(root.join("output"))
+ .expect("output metadata")
+ .permissions()
+ .mode()
+ & 0o7777,
+ 0o600
+ );
+
+ let failed_output = root.join("failed-output");
+ let error = copy_regular_to_new_path_impl(
+ &root.join("input"),
+ &failed_output,
+ 8,
+ None,
+ || {
+ fs::rename(root.join("input"), root.join("moved-input")).expect("move copy input");
+ fs::write(root.join("input"), b"01234567").expect("replace copy input");
+ },
+ || {},
+ )
+ .expect_err("copy binding change");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ assert_eq!(
+ fs::read(&failed_output).expect("untrusted failed output"),
+ b"01234567"
+ );
+ fs::remove_file(&failed_output).expect("remove untrusted failed output");
+
+ let open_hardlink_output = root.join("open-hardlink-output");
+ let open_hardlink_alias = root.join("open-hardlink-alias");
+ let error = copy_regular_to_new_path_impl(
+ &root.join("input"),
+ &open_hardlink_output,
+ 8,
+ None,
+ || {
+ fs::hard_link(&open_hardlink_output, &open_hardlink_alias)
+ .expect("hardlink open output");
+ },
+ || {},
+ )
+ .expect_err("open hardlink race");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ assert_eq!(
+ fs::read(&open_hardlink_output).expect("untrusted output"),
+ b"01234567"
+ );
+ assert_eq!(
+ fs::read(&open_hardlink_alias).expect("hardlink alias"),
+ b"01234567"
+ );
+ fs::remove_file(&open_hardlink_output).expect("remove untrusted output");
+ fs::remove_file(&open_hardlink_alias).expect("remove hardlink alias");
+
+ let installed_hardlink_output = root.join("installed-hardlink-output");
+ let installed_hardlink_alias = root.join("installed-hardlink-alias");
+ let error = copy_regular_to_new_path_impl(
+ &root.join("input"),
+ &installed_hardlink_output,
+ 8,
+ None,
+ || {},
+ || {
+ fs::hard_link(&installed_hardlink_output, &installed_hardlink_alias)
+ .expect("hardlink installed output");
+ },
+ )
+ .expect_err("installed hardlink race");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ assert_eq!(
+ fs::read(&installed_hardlink_output).expect("untrusted installed output"),
+ b"01234567"
+ );
+ assert!(installed_hardlink_alias.exists());
+ fs::remove_file(&installed_hardlink_output).expect("remove untrusted installed output");
+ fs::remove_file(&installed_hardlink_alias).expect("remove installed hardlink alias");
+
+ let mutated_output = root.join("mutated-output");
+ let error = copy_regular_to_new_path_impl(
+ &root.join("input"),
+ &mutated_output,
+ 8,
+ None,
+ || {},
+ || fs::write(&mutated_output, b"87654321").expect("mutate installed output"),
+ )
+ .expect_err("post-sync output mutation");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ assert_eq!(
+ fs::read(&mutated_output).expect("untrusted mutated output"),
+ b"87654321"
+ );
+ fs::remove_file(&mutated_output).expect("remove untrusted mutated output");
+
+ let name_swap_output = root.join("name-swap-output");
+ let moved_created_output = root.join("moved-created-output");
+ let error = copy_regular_to_new_path_impl(
+ &root.join("input"),
+ &name_swap_output,
+ 8,
+ None,
+ || {
+ fs::rename(&name_swap_output, &moved_created_output).expect("move created output");
+ fs::write(&name_swap_output, b"unrelated").expect("replacement output");
+ },
+ || {},
+ )
+ .expect_err("created name swap");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ assert_eq!(
+ fs::read(&name_swap_output).expect("unrelated replacement preserved"),
+ b"unrelated"
+ );
+ assert_eq!(
+ fs::read(&moved_created_output).expect("moved untrusted output"),
+ b"01234567"
+ );
+ fs::remove_file(&name_swap_output).expect("remove replacement output");
+ fs::remove_file(&moved_created_output).expect("remove moved output");
+
+ fs::create_dir(root.join("output-parent")).expect("output parent");
+ fs::write(root.join("input"), b"01234567").expect("restore input");
+ let rebound_output = root.join("output-parent/installed");
+ let error = copy_regular_to_new_path_impl(
+ &root.join("input"),
+ &rebound_output,
+ 8,
+ None,
+ || {
+ fs::rename(root.join("output-parent"), root.join("old-output-parent"))
+ .expect("move output parent");
+ fs::create_dir(root.join("output-parent")).expect("replace output parent");
+ },
+ || {},
+ )
+ .expect_err("output parent replacement");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ assert!(!rebound_output.exists());
+ assert_eq!(
+ fs::read(root.join("old-output-parent/installed"))
+ .expect("untrusted output in moved parent"),
+ b"01234567"
+ );
+
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o777))
+ .expect("make output parent untrusted");
+ assert_eq!(
+ copy_regular_to_new_path(&root.join("input"), &root.join("untrusted-parent"), 8)
+ .expect_err("writable output parent rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidRequest
+ );
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
+ .expect("restore output parent mode");
+
+ let parent_mode_output = root.join("parent-mode-output");
+ let error = copy_regular_to_new_path_impl(
+ &root.join("input"),
+ &parent_mode_output,
+ 8,
+ None,
+ || {},
+ || {
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o777))
+ .expect("change output parent mode");
+ },
+ )
+ .expect_err("output parent security change");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ assert!(parent_mode_output.exists());
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
+ .expect("restore output parent mode");
+ fs::remove_file(&parent_mode_output).expect("remove untrusted parent-mode output");
+
+ let secure_ancestor = root.join("secure-ancestor");
+ let nested_parent = secure_ancestor.join("output-parent");
+ fs::create_dir(&secure_ancestor).expect("secure ancestor");
+ fs::create_dir(&nested_parent).expect("nested output parent");
+ let ancestor_mode_output = nested_parent.join("installed");
+ let error = copy_regular_to_new_path_impl(
+ &root.join("input"),
+ &ancestor_mode_output,
+ 8,
+ None,
+ || {},
+ || {
+ fs::set_permissions(&secure_ancestor, fs::Permissions::from_mode(0o777))
+ .expect("change output ancestor mode");
+ },
+ )
+ .expect_err("output ancestor security change");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ assert!(ancestor_mode_output.exists());
+ fs::set_permissions(&secure_ancestor, fs::Permissions::from_mode(0o755))
+ .expect("restore output ancestor mode");
+ fs::remove_file(&ancestor_mode_output).expect("remove untrusted ancestor-mode output");
+ }
+
+ #[test]
+ fn no_follow_admission_rejects_symlink_fifo_and_replacement() {
+ let directory = TempDir::new().expect("tempdir");
+ let root = root(&directory);
+ fs::write(root.join("source"), b"original").expect("source");
+ symlink("source", root.join("link")).expect("symlink");
+ assert_eq!(
+ read_regular(&root, Path::new("link"), 32)
+ .expect_err("symlink rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+
+ let fifo = root.join("fifo");
+ let request = crate::bounded_process::ProcessRequest::new("/usr/bin/mkfifo")
+ .arg(fifo.as_os_str())
+ .deadline(Duration::from_secs(5))
+ .output_limits(1024, 1024);
+ let output = crate::bounded_process::run(&request).expect("bounded mkfifo");
+ assert!(output.status().success());
+ assert!(
+ fs::symlink_metadata(&fifo)
+ .expect("fifo metadata")
+ .file_type()
+ .is_fifo()
+ );
+ assert_eq!(
+ read_regular(&root, Path::new("fifo"), 32)
+ .expect_err("fifo rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+
+ let error = read_regular_impl(&root, Path::new("source"), 32, None, || {
+ fs::rename(root.join("source"), root.join("old")).expect("move admitted file");
+ fs::write(root.join("source"), b"original").expect("replacement");
+ })
+ .expect_err("replacement rejected");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+
+ fs::write(root.join("mutable"), b"same-size").expect("mutable");
+ let error = read_regular_impl(&root, Path::new("mutable"), 32, None, || {
+ fs::write(root.join("mutable"), b"new-bytes").expect("in-place mutation");
+ })
+ .expect_err("in-place mutation rejected");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+
+ fs::create_dir(root.join("parent")).expect("parent");
+ fs::write(root.join("parent/member"), b"member").expect("member");
+ let error = read_regular_impl(&root, Path::new("parent/member"), 32, None, || {
+ fs::rename(root.join("parent"), root.join("old-parent")).expect("move parent");
+ fs::create_dir(root.join("parent")).expect("replacement parent");
+ fs::write(root.join("parent/member"), b"member").expect("replacement member");
+ })
+ .expect_err("parent replacement rejected");
+ assert_eq!(error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ }
+
+ #[test]
+ fn traversal_enforces_type_count_byte_and_depth_bounds() {
+ let directory = TempDir::new().expect("tempdir");
+ let root = root(&directory);
+ fs::create_dir(root.join("a")).expect("a");
+ fs::write(root.join("a/one"), b"1").expect("one");
+ fs::write(root.join("two"), b"22").expect("two");
+ let snapshot = traverse_regular_files(&root, traversal_limits(), &[]).expect("traverse");
+ assert_eq!(snapshot.entry_count(), 3);
+ assert_eq!(snapshot.total_bytes(), 3);
+ assert_eq!(
+ snapshot
+ .files()
+ .iter()
+ .map(|file| file.relative_path().to_path_buf())
+ .collect::<Vec<_>>(),
+ [PathBuf::from("a/one"), PathBuf::from("two")]
+ );
+ assert_eq!(
+ snapshot
+ .read(&snapshot.files()[0], 1)
+ .expect("snapshot read"),
+ b"1"
+ );
+ let (bytes, evidenced) = snapshot
+ .read_evidenced(&snapshot.files()[0], 1)
+ .expect("snapshot evidenced read");
+ assert_eq!(bytes, b"1");
+ assert_eq!(
+ evidenced,
+ snapshot
+ .hash(&snapshot.files()[0], 1)
+ .expect("snapshot hash")
+ );
+ let output_directory = TempDir::new().expect("snapshot copy output");
+ let output_root = output_directory
+ .path()
+ .canonicalize()
+ .expect("canonical snapshot copy output");
+ assert_eq!(
+ snapshot
+ .copy_to_new_path(&snapshot.files()[0], &output_root.join("copy"), 1)
+ .expect("snapshot copy"),
+ evidenced
+ );
+ assert_eq!(
+ fs::read(output_root.join("copy")).expect("copied bytes"),
+ b"1"
+ );
+
+ let late_error = traverse_regular_files_impl(&root, traversal_limits(), &[], || {
+ fs::write(root.join("late"), b"late").expect("late insertion");
+ })
+ .expect_err("late insertion is detected");
+ assert_eq!(late_error.kind(), ArtifactIoFailureKind::ChangedDuringRead);
+ fs::remove_file(root.join("late")).expect("remove late insertion");
+
+ fs::create_dir(root.join("excluded")).expect("excluded directory");
+ let excluded = traverse_regular_files(&root, traversal_limits(), &["excluded"])
+ .expect("excluded snapshot");
+ fs::rename(root.join("excluded"), root.join("old-excluded"))
+ .expect("move excluded directory");
+ fs::create_dir(root.join("excluded")).expect("replace excluded directory");
+ assert_eq!(
+ excluded
+ .revalidate()
+ .expect_err("excluded binding change")
+ .kind(),
+ ArtifactIoFailureKind::ChangedDuringRead
+ );
+
+ let mut one_entry = traversal_limits();
+ one_entry.max_entries = 1;
+ assert_eq!(
+ traverse_regular_files(&root, one_entry, &[])
+ .expect_err("entry cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalEntries)
+ );
+ let mut one_file = traversal_limits();
+ one_file.max_files = 1;
+ assert_eq!(
+ traverse_regular_files(&root, one_file, &[])
+ .expect_err("file cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalFiles)
+ );
+ let mut one_file_byte = traversal_limits();
+ one_file_byte.max_file_bytes = 1;
+ assert_eq!(
+ traverse_regular_files(&root, one_file_byte, &[])
+ .expect_err("per-file cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::FileBytes)
+ );
+ let mut short_path = traversal_limits();
+ short_path.max_path_bytes = 2;
+ assert_eq!(
+ traverse_regular_files(&root, short_path, &[])
+ .expect_err("path cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalPathBytes)
+ );
+ let mut two_bytes = traversal_limits();
+ two_bytes.max_total_bytes = 2;
+ assert_eq!(
+ traverse_regular_files(&root, two_bytes, &[])
+ .expect_err("byte cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalTotalBytes)
+ );
+ let mut no_depth = traversal_limits();
+ no_depth.max_depth = 1;
+ fs::create_dir(root.join("a/deep")).expect("deep");
+ fs::write(root.join("a/deep/file"), b"x").expect("deep file");
+ assert_eq!(
+ traverse_regular_files(&root, no_depth, &[])
+ .expect_err("depth cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::TraversalDepth)
+ );
+ }
+
+ fn write_archive(path: &Path, members: &[(&str, EntryType, &[u8])]) {
+ let output = File::create(path).expect("archive output");
+ let encoder = GzBuilder::new().write(output, Compression::fast());
+ let mut builder = Builder::new(encoder);
+ for (name, kind, contents) in members {
+ let mut header = Header::new_gnu();
+ header.set_entry_type(*kind);
+ header.set_mode(0o644);
+ header.set_size(contents.len() as u64);
+ header.set_cksum();
+ builder
+ .append_data(&mut header, name, *contents)
+ .expect("archive member");
+ }
+ let encoder = builder.into_inner().expect("finish tar");
+ encoder.finish().expect("finish gzip");
+ }
+
+ fn write_raw_named_archive(path: &Path, name: &[u8], contents: &[u8]) {
+ assert!(name.len() < 100);
+ let output = File::create(path).expect("archive output");
+ let encoder = GzBuilder::new().write(output, Compression::fast());
+ let mut builder = Builder::new(encoder);
+ let mut header = Header::new_gnu();
+ header.as_mut_bytes()[..name.len()].copy_from_slice(name);
+ header.set_entry_type(EntryType::Regular);
+ header.set_mode(0o644);
+ header.set_size(contents.len() as u64);
+ header.set_cksum();
+ builder
+ .append(&header, contents)
+ .expect("raw archive member");
+ let encoder = builder.into_inner().expect("finish tar");
+ encoder.finish().expect("finish gzip");
+ }
+
+ fn write_single_terminator_archive(path: &Path) {
+ let mut header = Header::new_gnu();
+ header.set_path("one").expect("path");
+ header.set_entry_type(EntryType::Regular);
+ header.set_mode(0o644);
+ header.set_size(3);
+ header.set_cksum();
+ let mut bytes = Vec::from(header.as_bytes());
+ bytes.extend_from_slice(b"one");
+ bytes.resize(1536, 0);
+ let output = File::create(path).expect("archive output");
+ let mut encoder = GzBuilder::new().write(output, Compression::fast());
+ encoder.write_all(&bytes).expect("write raw tar");
+ encoder.finish().expect("finish gzip");
+ }
+
+ #[test]
+ fn tar_gzip_admission_is_parse_only_and_bounded() {
+ let directory = TempDir::new().expect("tempdir");
+ let root = root(&directory);
+ let valid = root.join("valid.tar.gz");
+ write_archive(&valid, &[("bin/tool", EntryType::Regular, b"payload")]);
+ let evidence = admit_tar_gzip_path(&valid, archive_limits()).expect("valid archive");
+ assert_eq!(evidence.member_count, 1);
+ assert_eq!(evidence.payload_bytes, 7);
+ let valid_bytes = fs::read(&valid).expect("valid archive bytes");
+ assert_eq!(evidence.compressed.byte_length, valid_bytes.len() as u64);
+ assert_eq!(
+ evidence.compressed.sha256,
+ hex::encode(Sha256::digest(&valid_bytes))
+ );
+ assert!(!root.join("bin").exists());
+
+ for (name, flags) in [
+ ("optional-header.tar.gz", 0x08),
+ ("reserved-header.tar.gz", 0x20),
+ ] {
+ let path = root.join(name);
+ let mut bytes = valid_bytes.clone();
+ bytes[3] = flags;
+ fs::write(&path, bytes).expect("write rejected gzip header");
+ assert_eq!(
+ admit_tar_gzip_path(&path, archive_limits())
+ .expect_err("non-minimal gzip header rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+ }
+
+ let symlink_archive = root.join("symlink.tar.gz");
+ write_archive(
+ &symlink_archive,
+ &[("escape", EntryType::Symlink, b"target")],
+ );
+ assert_eq!(
+ admit_tar_gzip_path(&symlink_archive, archive_limits())
+ .expect_err("special member rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+
+ let escape_archive = root.join("escape.tar.gz");
+ write_raw_named_archive(&escape_archive, b"../escape", b"payload");
+ assert_eq!(
+ admit_tar_gzip_path(&escape_archive, archive_limits())
+ .expect_err("escape rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+
+ let bomb = root.join("bomb.tar.gz");
+ write_archive(&bomb, &[("large", EntryType::Regular, &[0_u8; 8192])]);
+ let mut bounded = archive_limits();
+ bounded.max_expanded_bytes = 1024;
+ assert_eq!(
+ admit_tar_gzip_path(&bomb, bounded)
+ .expect_err("expanded cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveExpandedBytes)
+ );
+
+ let mut compressed = archive_limits();
+ compressed.max_compressed_bytes = fs::metadata(&valid).expect("valid metadata").len() - 1;
+ assert_eq!(
+ admit_tar_gzip_path(&valid, compressed)
+ .expect_err("compressed cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveCompressedBytes)
+ );
+
+ let two_members = root.join("two-members.tar.gz");
+ write_archive(
+ &two_members,
+ &[
+ ("one", EntryType::Regular, b"1"),
+ ("two", EntryType::Regular, b"2"),
+ ],
+ );
+ let mut one_member = archive_limits();
+ one_member.max_members = 1;
+ assert_eq!(
+ admit_tar_gzip_path(&two_members, one_member)
+ .expect_err("member count cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveMembers)
+ );
+
+ let mut one_member_byte = archive_limits();
+ one_member_byte.max_member_bytes = 1;
+ assert_eq!(
+ admit_tar_gzip_path(&valid, one_member_byte)
+ .expect_err("member byte cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveMemberBytes)
+ );
+
+ let mut one_payload_byte = archive_limits();
+ one_payload_byte.max_payload_bytes = 1;
+ assert_eq!(
+ admit_tar_gzip_path(&two_members, one_payload_byte)
+ .expect_err("payload cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchivePayloadBytes)
+ );
+
+ let deep = root.join("deep.tar.gz");
+ write_archive(&deep, &[("a/b/c/d/e", EntryType::Regular, b"x")]);
+ assert_eq!(
+ admit_tar_gzip_path(&deep, archive_limits())
+ .expect_err("archive depth cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchiveDepth)
+ );
+
+ let long_path = root.join("long-path.tar.gz");
+ write_archive(
+ &long_path,
+ &[("a-long-member-name", EntryType::Regular, b"x")],
+ );
+ let mut short_path = archive_limits();
+ short_path.max_path_bytes = 4;
+ assert_eq!(
+ admit_tar_gzip_path(&long_path, short_path)
+ .expect_err("archive path cap")
+ .kind(),
+ ArtifactIoFailureKind::LimitExceeded(LimitKind::ArchivePathBytes)
+ );
+ }
+
+ #[test]
+ fn archive_rejects_duplicates_prefix_conflicts_and_concatenation() {
+ let directory = TempDir::new().expect("tempdir");
+ let root = root(&directory);
+ let duplicate = root.join("duplicate.tar.gz");
+ write_archive(
+ &duplicate,
+ &[
+ ("same", EntryType::Regular, b"one"),
+ ("same", EntryType::Regular, b"two"),
+ ],
+ );
+ assert_eq!(
+ admit_tar_gzip_path(&duplicate, archive_limits())
+ .expect_err("duplicate rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+
+ let prefix = root.join("prefix.tar.gz");
+ write_archive(
+ &prefix,
+ &[
+ ("a", EntryType::Regular, b"one"),
+ ("a/b", EntryType::Regular, b"two"),
+ ],
+ );
+ assert_eq!(
+ admit_tar_gzip_path(&prefix, archive_limits())
+ .expect_err("prefix rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+
+ let first = root.join("first.tar.gz");
+ let second = root.join("second.tar.gz");
+ write_archive(&first, &[("one", EntryType::Regular, b"one")]);
+ write_archive(&second, &[("two", EntryType::Regular, b"two")]);
+ let second_bytes = fs::read(&second).expect("second bytes");
+ OpenOptions::new()
+ .append(true)
+ .open(&first)
+ .expect("append archive")
+ .write_all(&second_bytes)
+ .expect("concatenate");
+ assert_eq!(
+ admit_tar_gzip_path(&first, archive_limits())
+ .expect_err("concatenated gzip rejected")
+ .kind(),
+ ArtifactIoFailureKind::MalformedArchive
+ );
+
+ let trailing_slash = root.join("trailing-slash.tar.gz");
+ write_raw_named_archive(&trailing_slash, b"file/", b"payload");
+ assert_eq!(
+ admit_tar_gzip_path(&trailing_slash, archive_limits())
+ .expect_err("file trailing slash rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+
+ let truncated = root.join("truncated-terminator.tar.gz");
+ write_single_terminator_archive(&truncated);
+ assert_eq!(
+ admit_tar_gzip_path(&truncated, archive_limits())
+ .expect_err("single terminator rejected")
+ .kind(),
+ ArtifactIoFailureKind::MalformedArchive
+ );
+
+ let malformed = root.join("malformed.tar.gz");
+ fs::write(&malformed, b"not a gzip stream").expect("malformed archive");
+ assert_eq!(
+ admit_tar_gzip_path(&malformed, archive_limits())
+ .expect_err("malformed gzip rejected")
+ .kind(),
+ ArtifactIoFailureKind::MalformedArchive
+ );
+
+ let gzip_truncated = root.join("gzip-truncated.tar.gz");
+ let mut truncated_bytes = fs::read(&second).expect("archive bytes");
+ truncated_bytes.truncate(truncated_bytes.len() - 4);
+ fs::write(&gzip_truncated, truncated_bytes).expect("truncated gzip");
+ assert_eq!(
+ admit_tar_gzip_path(&gzip_truncated, archive_limits())
+ .expect_err("truncated gzip rejected")
+ .kind(),
+ ArtifactIoFailureKind::MalformedArchive
+ );
+
+ for (index, name) in [b"/absolute".as_slice(), b"./dot", b"a//b", b"a\\b"]
+ .into_iter()
+ .enumerate()
+ {
+ let path = root.join(format!("escape-{index}.tar.gz"));
+ write_raw_named_archive(&path, name, b"payload");
+ assert_eq!(
+ admit_tar_gzip_path(&path, archive_limits())
+ .expect_err("nonportable path rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+ }
+
+ for (index, kind) in [
+ EntryType::Symlink,
+ EntryType::Link,
+ EntryType::Fifo,
+ EntryType::Char,
+ EntryType::Block,
+ EntryType::Continuous,
+ EntryType::GNULongName,
+ EntryType::GNULongLink,
+ EntryType::GNUSparse,
+ EntryType::XGlobalHeader,
+ EntryType::XHeader,
+ EntryType::new(b'Z'),
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ let path = root.join(format!("special-{index}.tar.gz"));
+ write_archive(&path, &[("special", kind, b"")]);
+ assert!(
+ admit_tar_gzip_path(&path, archive_limits()).is_err(),
+ "special archive member was admitted"
+ );
+ }
+ }
+
+ #[test]
+ fn hardlinked_regular_inputs_are_rejected() {
+ let directory = TempDir::new().expect("tempdir");
+ let root = root(&directory);
+ fs::write(root.join("original"), b"bytes").expect("original");
+ fs::hard_link(root.join("original"), root.join("alias")).expect("hard link");
+ assert_eq!(
+ read_regular(&root, Path::new("original"), 8)
+ .expect_err("hardlink rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+ assert_eq!(
+ traverse_regular_files(&root, traversal_limits(), &[])
+ .expect_err("traversal hardlink rejected")
+ .kind(),
+ ArtifactIoFailureKind::InvalidObject
+ );
+ }
+
+ #[test]
+ fn diagnostics_are_redacted() {
+ let error = read_regular(
+ Path::new("/definitely-not-present-sensitive-root"),
+ Path::new("secret-token"),
+ 1,
+ )
+ .expect_err("missing input");
+ let diagnostic = format!("{error}");
+ assert!(!diagnostic.contains("sensitive"));
+ assert!(!diagnostic.contains("secret"));
+ }
+}
diff --git a/tools/xtask/src/service_release_artifacts.rs b/tools/xtask/src/service_release_artifacts.rs
@@ -13,6 +13,7 @@ use sha2::{Digest as _, Sha256};
use tar::{Builder as TarBuilder, Header as TarHeader};
use tempfile::TempDir;
+use crate::safe_artifact_io::{self, TarGzipLimits, TraversalLimits};
use crate::service_source_lock::{
LIB_REPOSITORY, LOCK_FILENAME, NixMaterialState, PREDECESSOR_LOCK_FILENAME,
ServiceSourceLockV2, validate_deferred_nix_material,
@@ -75,6 +76,11 @@ const MAX_GIT_OUTPUT_BYTES: usize = 65_536;
const MAX_PACKAGES: usize = 8_192;
const MAX_WORKSPACE_PACKAGES: usize = 64;
const MAX_TEXT_FIELD_BYTES: usize = 512;
+const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184;
+const MAX_ARCHIVE_MEMBERS: u64 = 65_536;
+const MAX_ARCHIVE_DEPTH: usize = 64;
+const MAX_ARCHIVE_PATH_BYTES: usize = 4_096;
+const MAX_RELEASE_TREE_BYTES: u64 = 68_719_476_736;
const FILE_MODE: u32 = 0o644;
const DIRECTORY_MODE: u32 = 0o755;
@@ -409,7 +415,7 @@ fn run_inner(
return Err(ReleaseArtifactError::InvalidServiceMetadata);
}
let service_root = validate_git_root(service_root)?;
- let input_root = validate_exact_input_root(input_root)?;
+ let (input_root, input_snapshot) = validate_exact_input_root(input_root)?;
let (output_parent, output_root) =
validate_output_parent(output_root, &service_root, &input_root)?;
let initial_head = git_head(&service_root)?;
@@ -443,8 +449,9 @@ fn run_inner(
("nixos-module.nix", "nixos-module.nix"),
];
for (input, output) in static_inputs {
- copy_bounded(
- &input_root.join(input),
+ copy_snapshot_file(
+ &input_snapshot,
+ input,
&staging.path().join(output),
MAX_TEXT_INPUT_BYTES,
)?;
@@ -463,27 +470,35 @@ fn run_inner(
)?;
validate_text_artifact(&staging.path().join("LICENSE-MIT"), "LICENSE-MIT")?;
write_generated(&staging.path().join(LOCK_FILENAME), &source_lock_bytes)?;
- create_binary_archive(
- &input_root.join("service-binary"),
+ create_binary_archive_from_snapshot(
+ &input_snapshot,
+ "service-binary",
&staging.path().join("binary.tar.gz"),
&metadata.binary_name,
source_date_epoch,
)?;
- let oci = copy_bounded(
- &input_root.join("oci-image.tar.gz"),
+ let oci = copy_snapshot_file(
+ &input_snapshot,
+ "oci-image.tar.gz",
&staging.path().join("oci-image.tar.gz"),
MAX_OCI_BYTES,
)?;
- let service_source = copy_bounded(
- &input_root.join("service-source.bundle"),
+ admit_oci_archive(&staging.path().join("oci-image.tar.gz"))?;
+ let service_source = copy_snapshot_file(
+ &input_snapshot,
+ "service-source.bundle",
&staging.path().join("service-source.bundle"),
MAX_SOURCE_BUNDLE_BYTES,
)?;
- let lib_source = copy_bounded(
- &input_root.join("lib-source.bundle"),
+ let lib_source = copy_snapshot_file(
+ &input_snapshot,
+ "lib-source.bundle",
&staging.path().join("lib-source.bundle"),
MAX_SOURCE_BUNDLE_BYTES,
)?;
+ input_snapshot
+ .revalidate()
+ .map_err(|_| ReleaseArtifactError::InvalidInputRoot)?;
verify_bundle(&staging.path().join("service-source.bundle"), &initial_head)?;
verify_bundle(
&staging.path().join("lib-source.bundle"),
@@ -896,11 +911,14 @@ fn package_reference(package: &CargoPackage) -> String {
)
}
-fn validate_exact_input_root(path: &Path) -> Result<PathBuf, ReleaseArtifactError> {
+fn validate_exact_input_root(
+ path: &Path,
+) -> Result<(PathBuf, safe_artifact_io::TraversalSnapshot), ReleaseArtifactError> {
let root = validate_absolute_directory(path, ReleaseArtifactError::InvalidInputRoot)?;
- let inventory = directory_inventory(&root, ReleaseArtifactError::InvalidInputRoot)?;
+ let snapshot = flat_directory_snapshot(&root, ReleaseArtifactError::InvalidInputRoot)?;
+ let inventory = snapshot_inventory(&snapshot, ReleaseArtifactError::InvalidInputRoot)?;
if inventory == INPUT_NAMES.into_iter().map(str::to_owned).collect() {
- Ok(root)
+ Ok((root, snapshot))
} else {
Err(ReleaseArtifactError::InvalidInputRoot)
}
@@ -1045,16 +1063,77 @@ fn verify_bundle(path: &Path, revision: &str) -> Result<(), ReleaseArtifactError
}
}
+#[cfg(test)]
fn create_binary_archive(
source: &Path,
output: &Path,
binary_name: &str,
source_date_epoch: u32,
) -> Result<FileEvidence, ReleaseArtifactError> {
- let source_metadata = validate_regular_input(source, MAX_BINARY_BYTES)?;
- hash_regular(source, MAX_BINARY_BYTES)?;
+ let (_stable, stable_source) = binary_staging(output)?;
+ let source_evidence =
+ safe_artifact_io::copy_regular_to_new_path(source, &stable_source, MAX_BINARY_BYTES)
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ if source_evidence.byte_length == 0 {
+ return Err(ReleaseArtifactError::InvalidInputArtifact);
+ }
+ write_binary_archive(
+ &stable_source,
+ output,
+ binary_name,
+ source_date_epoch,
+ source_evidence,
+ )
+}
+
+fn create_binary_archive_from_snapshot(
+ snapshot: &safe_artifact_io::TraversalSnapshot,
+ source_name: &str,
+ output: &Path,
+ binary_name: &str,
+ source_date_epoch: u32,
+) -> Result<FileEvidence, ReleaseArtifactError> {
+ let source = snapshot_file(snapshot, source_name)?;
+ let (_stable, stable_source) = binary_staging(output)?;
+ let source_evidence = snapshot
+ .copy_to_new_path(source, &stable_source, MAX_BINARY_BYTES)
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ if source_evidence.byte_length == 0 {
+ return Err(ReleaseArtifactError::InvalidInputArtifact);
+ }
+ write_binary_archive(
+ &stable_source,
+ output,
+ binary_name,
+ source_date_epoch,
+ source_evidence,
+ )
+}
+
+fn binary_staging(output: &Path) -> Result<(TempDir, PathBuf), ReleaseArtifactError> {
+ let parent = output
+ .parent()
+ .ok_or(ReleaseArtifactError::GenerationFailure)?;
+ let stable = tempfile::Builder::new()
+ .prefix(".radroots-binary-input-")
+ .tempdir_in(parent)
+ .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ let stable_root = stable
+ .path()
+ .canonicalize()
+ .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ Ok((stable, stable_root.join("admitted-binary")))
+}
+
+fn write_binary_archive(
+ stable_source: &Path,
+ output: &Path,
+ binary_name: &str,
+ source_date_epoch: u32,
+ source_evidence: safe_artifact_io::FileEvidence,
+) -> Result<FileEvidence, ReleaseArtifactError> {
let mut source_file =
- fs::File::open(source).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ fs::File::open(stable_source).map_err(|_| ReleaseArtifactError::GenerationFailure)?;
let output_file = fs::OpenOptions::new()
.create_new(true)
.write(true)
@@ -1066,7 +1145,7 @@ fn create_binary_archive(
.write(output_file, Compression::best());
let mut archive = TarBuilder::new(encoder);
let mut header = TarHeader::new_gnu();
- header.set_size(source_metadata.len());
+ header.set_size(source_evidence.byte_length);
header.set_mode(0o755);
header.set_uid(0);
header.set_gid(0);
@@ -1085,58 +1164,81 @@ fn create_binary_archive(
.sync_all()
.map_err(|_| ReleaseArtifactError::GenerationFailure)?;
set_file_mode(output)?;
- validate_unchanged_input(source, &source_metadata)?;
+ admit_binary_archive(output)?;
hash_regular(output, MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES)
}
+fn snapshot_file<'a>(
+ snapshot: &'a safe_artifact_io::TraversalSnapshot,
+ name: &str,
+) -> Result<&'a safe_artifact_io::TraversedFile, ReleaseArtifactError> {
+ snapshot
+ .files()
+ .iter()
+ .find(|file| file.relative_path() == Path::new(name))
+ .ok_or(ReleaseArtifactError::InvalidInputRoot)
+}
+
+fn copy_snapshot_file(
+ snapshot: &safe_artifact_io::TraversalSnapshot,
+ source_name: &str,
+ output: &Path,
+ maximum: u64,
+) -> Result<FileEvidence, ReleaseArtifactError> {
+ let evidence = snapshot
+ .copy_to_new_path(snapshot_file(snapshot, source_name)?, output, maximum)
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
+ set_file_mode(output)?;
+ Ok(FileEvidence {
+ byte_length: evidence.byte_length,
+ sha256: evidence.sha256,
+ })
+}
+
fn copy_bounded(
source: &Path,
output: &Path,
maximum: u64,
) -> Result<FileEvidence, ReleaseArtifactError> {
- let source_metadata = validate_regular_input(source, maximum)?;
- let mut input =
- fs::File::open(source).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
- let mut output_file = fs::OpenOptions::new()
- .create_new(true)
- .write(true)
- .open(output)
- .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
- let mut hasher = Sha256::new();
- let mut total = 0_u64;
- let mut buffer = [0_u8; 65_536];
- loop {
- let read = input
- .read(&mut buffer)
- .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
- if read == 0 {
- break;
- }
- total = total
- .checked_add(read as u64)
- .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
- if total > maximum {
- return Err(ReleaseArtifactError::InvalidInputArtifact);
- }
- hasher.update(&buffer[..read]);
- output_file
- .write_all(&buffer[..read])
- .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
- }
- if total != source_metadata.len() {
- return Err(ReleaseArtifactError::InvalidInputArtifact);
- }
- output_file
- .sync_all()
- .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ let evidence = safe_artifact_io::copy_regular_to_new_path(source, output, maximum)
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
set_file_mode(output)?;
- validate_unchanged_input(source, &source_metadata)?;
Ok(FileEvidence {
- byte_length: total,
- sha256: hex::encode(hasher.finalize()),
+ byte_length: evidence.byte_length,
+ sha256: evidence.sha256,
})
}
+fn admit_binary_archive(path: &Path) -> Result<(), ReleaseArtifactError> {
+ let limits = TarGzipLimits {
+ max_compressed_bytes: MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES,
+ max_expanded_bytes: MAX_BINARY_BYTES + MAX_TEXT_INPUT_BYTES,
+ max_members: 4,
+ max_member_bytes: MAX_BINARY_BYTES,
+ max_payload_bytes: MAX_BINARY_BYTES,
+ max_depth: 4,
+ max_path_bytes: MAX_ARCHIVE_PATH_BYTES,
+ };
+ safe_artifact_io::admit_tar_gzip_path(path, limits)
+ .map(|_| ())
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)
+}
+
+fn admit_oci_archive(path: &Path) -> Result<(), ReleaseArtifactError> {
+ let limits = TarGzipLimits {
+ max_compressed_bytes: MAX_OCI_BYTES,
+ max_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
+ max_members: MAX_ARCHIVE_MEMBERS,
+ max_member_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
+ max_payload_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
+ max_depth: MAX_ARCHIVE_DEPTH,
+ max_path_bytes: MAX_ARCHIVE_PATH_BYTES,
+ };
+ safe_artifact_io::admit_tar_gzip_path(path, limits)
+ .map(|_| ())
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)
+}
+
#[derive(Default)]
struct SecretScanner {
tail: Vec<u8>,
@@ -1237,18 +1339,48 @@ fn write_checksums(root: &Path) -> Result<(), ReleaseArtifactError> {
}
fn inventory_records(root: &Path) -> Result<Vec<ArtifactRecord>, ReleaseArtifactError> {
- let mut names = directory_inventory(root, ReleaseArtifactError::GenerationFailure)?
- .into_iter()
- .collect::<Vec<_>>();
- names.sort();
- names
- .into_iter()
- .map(|name| {
- let maximum = output_maximum(&name)?;
- let evidence = hash_regular(&root.join(&name), maximum)?;
- Ok(artifact_record(&name, &evidence))
- })
- .collect()
+ inventory_records_impl(root, || {})
+}
+
+fn inventory_records_impl<F>(
+ root: &Path,
+ after_snapshot: F,
+) -> Result<Vec<ArtifactRecord>, ReleaseArtifactError>
+where
+ F: FnOnce(),
+{
+ let snapshot = flat_directory_snapshot(root, ReleaseArtifactError::GenerationFailure)?;
+ let names = snapshot_inventory(&snapshot, ReleaseArtifactError::GenerationFailure)?;
+ after_snapshot();
+ if snapshot.root_permission_mode() != DIRECTORY_MODE {
+ return Err(ReleaseArtifactError::GenerationFailure);
+ }
+ let mut records = Vec::with_capacity(names.len());
+ for name in names {
+ let maximum = output_maximum(&name)?;
+ let file = snapshot
+ .files()
+ .iter()
+ .find(|file| file.relative_path() == Path::new(&name))
+ .ok_or(ReleaseArtifactError::GenerationFailure)?;
+ if file.permission_mode() != FILE_MODE {
+ return Err(ReleaseArtifactError::GenerationFailure);
+ }
+ let evidence = snapshot
+ .hash(file, maximum)
+ .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ records.push(artifact_record(
+ &name,
+ &FileEvidence {
+ byte_length: evidence.byte_length,
+ sha256: evidence.sha256,
+ },
+ ));
+ }
+ snapshot
+ .revalidate()
+ .map_err(|_| ReleaseArtifactError::GenerationFailure)?;
+ Ok(records)
}
fn artifact_record(path: &str, evidence: &FileEvidence) -> ArtifactRecord {
@@ -1283,36 +1415,11 @@ fn output_maximum(name: &str) -> Result<u64, ReleaseArtifactError> {
}
fn hash_regular(path: &Path, maximum: u64) -> Result<FileEvidence, ReleaseArtifactError> {
- let metadata =
- fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
- if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum {
- return Err(ReleaseArtifactError::InvalidInputArtifact);
- }
- let mut file = fs::File::open(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
- let mut hasher = Sha256::new();
- let mut total = 0_u64;
- let mut buffer = [0_u8; 65_536];
- loop {
- let read = file
- .read(&mut buffer)
- .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
- if read == 0 {
- break;
- }
- total = total
- .checked_add(read as u64)
- .ok_or(ReleaseArtifactError::InvalidInputArtifact)?;
- if total > maximum {
- return Err(ReleaseArtifactError::InvalidInputArtifact);
- }
- hasher.update(&buffer[..read]);
- }
- if total != metadata.len() {
- return Err(ReleaseArtifactError::InvalidInputArtifact);
- }
+ let evidence = safe_artifact_io::hash_regular_path(path, maximum)
+ .map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
Ok(FileEvidence {
- byte_length: total,
- sha256: hex::encode(hasher.finalize()),
+ byte_length: evidence.byte_length,
+ sha256: evidence.sha256,
})
}
@@ -1321,24 +1428,10 @@ fn read_bounded_regular(
maximum: u64,
error: ReleaseArtifactError,
) -> Result<Vec<u8>, ReleaseArtifactError> {
- let metadata = fs::symlink_metadata(path).map_err(|_| error)?;
- if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum {
- return Err(error);
- }
- let capacity = usize::try_from(metadata.len()).map_err(|_| error)?;
- let mut bytes = Vec::with_capacity(capacity);
- fs::File::open(path)
- .map_err(|_| error)?
- .take(maximum.saturating_add(1))
- .read_to_end(&mut bytes)
- .map_err(|_| error)?;
- if bytes.len() as u64 > maximum {
- Err(error)
- } else {
- Ok(bytes)
- }
+ safe_artifact_io::read_regular_path(path, maximum).map_err(|_| error)
}
+#[cfg(test)]
fn validate_regular_input(path: &Path, maximum: u64) -> Result<fs::Metadata, ReleaseArtifactError> {
let metadata =
fs::symlink_metadata(path).map_err(|_| ReleaseArtifactError::InvalidInputArtifact)?;
@@ -1353,7 +1446,7 @@ fn validate_regular_input(path: &Path, maximum: u64) -> Result<fs::Metadata, Rel
}
}
-#[cfg(unix)]
+#[cfg(all(test, unix))]
fn validate_unchanged_input(
path: &Path,
expected: &fs::Metadata,
@@ -1373,7 +1466,7 @@ fn validate_unchanged_input(
}
}
-#[cfg(not(unix))]
+#[cfg(all(test, not(unix)))]
fn validate_unchanged_input(
path: &Path,
expected: &fs::Metadata,
@@ -1387,56 +1480,77 @@ fn validate_unchanged_input(
}
}
+#[cfg(test)]
fn directory_inventory(
root: &Path,
error: ReleaseArtifactError,
) -> Result<BTreeSet<String>, ReleaseArtifactError> {
+ let snapshot = flat_directory_snapshot(root, error)?;
+ snapshot_inventory(&snapshot, error)
+}
+
+fn flat_directory_snapshot(
+ root: &Path,
+ error: ReleaseArtifactError,
+) -> Result<safe_artifact_io::TraversalSnapshot, ReleaseArtifactError> {
+ let limits = TraversalLimits {
+ max_entries: (OUTPUT_NAMES.len() + 1) as u64,
+ max_files: (OUTPUT_NAMES.len() + 1) as u64,
+ max_total_bytes: MAX_RELEASE_TREE_BYTES,
+ max_file_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
+ max_depth: 1,
+ max_path_bytes: MAX_ARCHIVE_PATH_BYTES,
+ };
+ safe_artifact_io::traverse_regular_files(root, limits, &[]).map_err(|_| error)
+}
+
+fn snapshot_inventory(
+ snapshot: &safe_artifact_io::TraversalSnapshot,
+ error: ReleaseArtifactError,
+) -> Result<BTreeSet<String>, ReleaseArtifactError> {
+ if snapshot.entry_count() != snapshot.files().len() as u64 {
+ return Err(error);
+ }
let mut names = BTreeSet::new();
- for entry in fs::read_dir(root).map_err(|_| error)? {
- let entry = entry.map_err(|_| error)?;
- let file_type = entry.file_type().map_err(|_| error)?;
- if file_type.is_symlink() || !file_type.is_file() {
+ for entry in snapshot.files() {
+ let path = entry.relative_path();
+ if path.components().count() != 1 {
return Err(error);
}
- let name = entry.file_name().into_string().map_err(|_| error)?;
+ let name = path
+ .file_name()
+ .and_then(OsStr::to_str)
+ .ok_or(error)?
+ .to_owned();
if !names.insert(name) || names.len() > OUTPUT_NAMES.len() {
return Err(error);
}
}
+ snapshot.revalidate().map_err(|_| error)?;
Ok(names)
}
fn validate_exact_output_inventory(root: &Path) -> Result<(), ReleaseArtifactError> {
let expected = OUTPUT_NAMES.into_iter().map(str::to_owned).collect();
- if directory_inventory(root, ReleaseArtifactError::GenerationFailure)? != expected {
+ let actual = inventory_records(root)?
+ .into_iter()
+ .map(|record| record.path)
+ .collect::<BTreeSet<_>>();
+ if actual != expected {
return Err(ReleaseArtifactError::GenerationFailure);
}
- validate_directory_mode(root)?;
- for name in OUTPUT_NAMES {
- validate_file_mode(&root.join(name))?;
- }
Ok(())
}
fn compare_output(expected: &Path, actual: &Path) -> Result<(), ReleaseArtifactError> {
let actual = validate_absolute_directory(actual, ReleaseArtifactError::StaleOutput)?;
- if directory_inventory(&actual, ReleaseArtifactError::StaleOutput)?
- != OUTPUT_NAMES.into_iter().map(str::to_owned).collect()
- {
+ let expected_records =
+ inventory_records(expected).map_err(|_| ReleaseArtifactError::StaleOutput)?;
+ let actual_records =
+ inventory_records(&actual).map_err(|_| ReleaseArtifactError::StaleOutput)?;
+ if expected_records != actual_records {
return Err(ReleaseArtifactError::StaleOutput);
}
- validate_directory_mode(&actual).map_err(|_| ReleaseArtifactError::StaleOutput)?;
- for name in OUTPUT_NAMES {
- validate_file_mode(&actual.join(name)).map_err(|_| ReleaseArtifactError::StaleOutput)?;
- let maximum = output_maximum(name)?;
- let left = hash_regular(&expected.join(name), maximum)
- .map_err(|_| ReleaseArtifactError::StaleOutput)?;
- let right = hash_regular(&actual.join(name), maximum)
- .map_err(|_| ReleaseArtifactError::StaleOutput)?;
- if left.byte_length != right.byte_length || left.sha256 != right.sha256 {
- return Err(ReleaseArtifactError::StaleOutput);
- }
- }
Ok(())
}
@@ -1479,14 +1593,14 @@ fn set_directory_mode(_path: &Path) -> Result<(), ReleaseArtifactError> {
Ok(())
}
-#[cfg(unix)]
+#[cfg(all(test, unix))]
fn validate_file_mode(path: &Path) -> Result<(), ReleaseArtifactError> {
use std::os::unix::fs::PermissionsExt as _;
let mode = fs::symlink_metadata(path)
.map_err(|_| ReleaseArtifactError::StaleOutput)?
.permissions()
.mode()
- & 0o777;
+ & 0o7777;
if mode == FILE_MODE {
Ok(())
} else {
@@ -1494,19 +1608,19 @@ fn validate_file_mode(path: &Path) -> Result<(), ReleaseArtifactError> {
}
}
-#[cfg(not(unix))]
+#[cfg(all(test, not(unix)))]
fn validate_file_mode(_path: &Path) -> Result<(), ReleaseArtifactError> {
Ok(())
}
-#[cfg(unix)]
+#[cfg(all(test, unix))]
fn validate_directory_mode(path: &Path) -> Result<(), ReleaseArtifactError> {
use std::os::unix::fs::PermissionsExt as _;
let mode = fs::symlink_metadata(path)
.map_err(|_| ReleaseArtifactError::StaleOutput)?
.permissions()
.mode()
- & 0o777;
+ & 0o7777;
if mode == DIRECTORY_MODE {
Ok(())
} else {
@@ -1514,7 +1628,7 @@ fn validate_directory_mode(path: &Path) -> Result<(), ReleaseArtifactError> {
}
}
-#[cfg(not(unix))]
+#[cfg(all(test, not(unix)))]
fn validate_directory_mode(_path: &Path) -> Result<(), ReleaseArtifactError> {
Ok(())
}
@@ -1738,9 +1852,10 @@ mod tests {
impl ReleaseFixture {
fn new() -> Self {
let root = TempDir::new().expect("fixture root");
- let service = root.path().join("service");
- let lib = root.path().join("lib");
- let input = root.path().join("input");
+ let canonical_root = root.path().canonicalize().expect("canonical fixture root");
+ let service = canonical_root.join("service");
+ let lib = canonical_root.join("lib");
+ let input = canonical_root.join("input");
fs::create_dir_all(service.join("src")).expect("service source");
fs::create_dir_all(lib.join("contracts/crates")).expect("Lib contracts");
fs::create_dir(&input).expect("input root");
@@ -1843,14 +1958,11 @@ version = "0.1.0-alpha"
&input.join("service-binary"),
b"fixture service executable\0\xff",
);
- write_file(
- &input.join("oci-image.tar.gz"),
- b"fixture OCI archive\0\xff",
- );
+ create_oci_fixture(&input.join("oci-image.tar.gz"));
Self {
- output_a: root.path().join("release-a"),
- output_b: root.path().join("release-b"),
+ output_a: canonical_root.join("release-a"),
+ output_b: canonical_root.join("release-b"),
_root: root,
service,
input,
@@ -1956,6 +2068,29 @@ version = "0.1.0-alpha"
assert!(status.success());
}
+ fn create_oci_fixture(output: &Path) {
+ let output_file = fs::File::create(output).expect("create OCI fixture");
+ let encoder = GzBuilder::new()
+ .mtime(0)
+ .operating_system(255)
+ .write(output_file, Compression::best());
+ let mut archive = TarBuilder::new(encoder);
+ let bytes = b"{}";
+ let mut header = TarHeader::new_gnu();
+ header.set_size(bytes.len() as u64);
+ header.set_mode(0o644);
+ header.set_uid(0);
+ header.set_gid(0);
+ header.set_mtime(0);
+ header.set_cksum();
+ archive
+ .append_data(&mut header, "index.json", bytes.as_slice())
+ .expect("write OCI fixture member");
+ let encoder = archive.into_inner().expect("finish OCI fixture tar");
+ let file = encoder.finish().expect("finish OCI fixture gzip");
+ file.sync_all().expect("sync OCI fixture");
+ }
+
fn sample_metadata() -> ReleaseMetadata {
ReleaseMetadata {
service: "fixture_service".to_owned(),
@@ -2567,6 +2702,52 @@ version = "0.1.0-alpha"
);
}
+ #[cfg(unix)]
+ #[test]
+ fn snapshot_backed_inventory_rejects_file_and_root_mode_races() {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let directory = TempDir::new().expect("inventory fixture");
+ let root = directory
+ .path()
+ .canonicalize()
+ .expect("canonical inventory fixture");
+ set_directory_mode(&root).expect("directory mode");
+ let file = root.join("LICENSE-MIT");
+ write_file(&file, b"license\n");
+ set_file_mode(&file).expect("file mode");
+
+ assert_eq!(
+ inventory_records_impl(&root, || {
+ fs::set_permissions(&file, fs::Permissions::from_mode(0o600))
+ .expect("change file mode");
+ }),
+ Err(ReleaseArtifactError::GenerationFailure)
+ );
+ set_file_mode(&file).expect("restore file mode");
+ assert_eq!(
+ inventory_records_impl(&root, || {
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
+ .expect("change root mode");
+ }),
+ Err(ReleaseArtifactError::GenerationFailure)
+ );
+ set_directory_mode(&root).expect("restore directory mode");
+ fs::set_permissions(&file, fs::Permissions::from_mode(0o4644))
+ .expect("add file special mode bit");
+ assert_eq!(
+ inventory_records(&root),
+ Err(ReleaseArtifactError::GenerationFailure)
+ );
+ set_file_mode(&file).expect("restore file mode");
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o1755))
+ .expect("add directory special mode bit");
+ assert_eq!(
+ inventory_records(&root),
+ Err(ReleaseArtifactError::GenerationFailure)
+ );
+ }
+
#[test]
fn remaining_release_boundaries_fail_closed() {
let fixture = ReleaseFixture::new();
@@ -2897,9 +3078,13 @@ version = "0.1.0-alpha"
#[test]
fn binary_archive_is_reproducible_and_metadata_is_fixed() {
let root = TempDir::new().expect("archive fixture");
- let source = root.path().join("service");
- let first = root.path().join("first.tar.gz");
- let second = root.path().join("second.tar.gz");
+ let root = root
+ .path()
+ .canonicalize()
+ .expect("canonical archive fixture");
+ let source = root.join("service");
+ let first = root.join("first.tar.gz");
+ let second = root.join("second.tar.gz");
write_file(&source, b"exact executable bytes");
create_binary_archive(&source, &first, "fixture-service", 1_700_000_000)
.expect("first archive");
diff --git a/tools/xtask/tests/services_hardening_safe_artifact_io_decision.rs b/tools/xtask/tests/services_hardening_safe_artifact_io_decision.rs
@@ -0,0 +1,258 @@
+#![forbid(unsafe_code)]
+
+use serde_json::Value;
+
+const DECISION: &str = include_str!(
+ "../../../contracts/architecture/decisions/services_hardening_safe_artifact_io.v1.json"
+);
+
+fn decision() -> Value {
+ serde_json::from_str(DECISION).expect("safe-artifact-I/O decision must be valid JSON")
+}
+
+#[test]
+fn scope_platform_and_hard_maximums_are_exact() {
+ let value = decision();
+ assert_eq!(value.as_object().map(serde_json::Map::len), Some(16));
+ assert_eq!(
+ value["schema"],
+ "radroots.services-hardening.safe-artifact-io-decisions.v1"
+ );
+ assert_eq!(value["contract_version"], 1);
+ assert_eq!(value["decision_state"], "active");
+ assert_eq!(value["owner"], "tools/xtask");
+ assert_eq!(
+ value["self_test_command"],
+ "cargo xtask safe-artifact-io-self-test"
+ );
+ assert_eq!(
+ value["platform_scope"],
+ serde_json::json!({
+ "implementation": ["macos_aarch64", "linux_x86_64"],
+ "source_gate": ["macos_aarch64"],
+ "cross_platform_promotion_owner": "step-297"
+ })
+ );
+ assert_eq!(
+ value["scope"],
+ serde_json::json!({
+ "claims": [
+ "descriptor_bound_regular_file_io",
+ "bounded_exact_directory_snapshots",
+ "generic_parse_only_tar_gzip_admission",
+ "service_release_artifacts_adoption",
+ "result_level_release_preflight_aggregation"
+ ],
+ "nonclaims": [
+ "semantic_oci_or_binary_archive_validation_before_step_304",
+ "transitive_safe_io_inside_every_preflight_lane",
+ "panic_signal_hang_or_abort_containment_without_step_292_process_isolation",
+ "cross_platform_runtime_promotion_before_step_297"
+ ]
+ })
+ );
+ assert_eq!(
+ value["hard_maximums"],
+ serde_json::json!({
+ "buffered_read_bytes": 67_108_864,
+ "streaming_file_bytes": 17_179_869_184_u64,
+ "traversal_entries": 65_536,
+ "traversal_files": 65_536,
+ "traversal_total_bytes": 68_719_476_736_u64,
+ "traversal_depth": 64,
+ "path_bytes": 4_096,
+ "archive_compressed_bytes": 2_147_483_648_u64,
+ "archive_expanded_bytes": 17_179_869_184_u64,
+ "archive_members": 65_536,
+ "archive_member_bytes": 17_179_869_184_u64,
+ "archive_payload_bytes": 17_179_869_184_u64
+ })
+ );
+}
+
+#[test]
+fn filesystem_traversal_copy_and_archive_models_are_exact() {
+ let value = decision();
+ assert_eq!(
+ value["filesystem_model"],
+ serde_json::json!({
+ "absolute_root_walk": "open_from_filesystem_root_one_component_at_a_time",
+ "open_flags": ["O_CLOEXEC", "O_NOFOLLOW", "O_NONBLOCK"],
+ "intermediate_type": "directory",
+ "leaf_type": "regular_file",
+ "regular_link_count": "exactly_one",
+ "absolute_ancestor_identity": "device_inode_and_directory_type",
+ "regular_identity": "device_inode_type_mode_owner_link_count_length_mtime_ctime",
+ "permission_mode": "mode_bits_0o7777_including_special_bits",
+ "read_completion": "observed_bytes_equal_admitted_length",
+ "post_operation_binding": "reopen_complete_path_and_compare_ancestor_and_leaf_identities",
+ "hardlink_compatibility": "callers_must_privately_materialize_single_link_inputs_before_admission",
+ "trusted_root_normalization": "caller_may_canonicalize_a_trusted_root_before_descriptor_admission",
+ "diagnostic_paths": "redacted"
+ })
+ );
+ assert_eq!(
+ value["create_new_materialization"],
+ serde_json::json!({
+ "output_parent_binding": "descriptor_bound_complete_absolute_chain",
+ "output_chain_security": "every_directory_root_or_effective_uid_owned_and_no_group_or_world_write_mode_bits",
+ "output_chain_acl_precondition": "caller_guarantees_no_write_granting_acl_for_other_credentials_on_every_chain_directory",
+ "output_name": "direct_openat_create_exclusive_nofollow_cloexec",
+ "output_mode": "retained_descriptor_fchmod_0600",
+ "data": "fixed_buffer_stream_and_simultaneous_sha256",
+ "durability": "sync_retained_output_then_sync_parent",
+ "finalization": "capture_full_output_identity_after_write_and_sync_before_post_sync_hook",
+ "post_operation": "compare_retained_and_reopened_output_to_finalized_identity_and_revalidate_parent_chain_and_security",
+ "success_linearization": "final_rebound_and_complete_chain_security_check",
+ "caller_use": "final_revalidate_before_use",
+ "failure": "never_unlink_rename_or_rollback_after_successful_create",
+ "failed_output_trust": "untrusted_caller_owned_residue_requires_quarantine_and_fresh_retry_path",
+ "threat_boundary": "same_effective_credential_or_acl_authorized_mutation_after_final_revalidation_is_outside_strict_pathname_persistence"
+ })
+ );
+ assert_eq!(
+ value["traversal"],
+ serde_json::json!({
+ "entry_count": "every_non_dot_member_encountered_including_excluded_directories",
+ "file_count": "admitted_regular_files",
+ "total_bytes": "checked_sum_of_admitted_regular_lengths",
+ "depth": "root_is_zero_each_descended_directory_adds_one",
+ "path_bytes": "raw_platform_path_bytes_relative_to_root",
+ "ordering": "raw_member_name_bytes_ascending",
+ "special_members": "reject",
+ "excluded_directories": "retain_name_type_and_device_inode_binding_but_do_not_descend",
+ "snapshot_binding": "exact_sorted_membership_and_member_identity_for_root_and_every_descended_directory",
+ "snapshot_consumption": "read_hash_and_copy_require_the_traversed_file_identity",
+ "final_revalidation": "reopen_complete_bindings_and_reenumerate_exact_memberships"
+ })
+ );
+ assert_eq!(
+ value["archive_admission"],
+ serde_json::json!({
+ "format": "one_gzip_member_containing_one_nonempty_tar_stream",
+ "gzip_header": "minimal_ten_byte_header_with_zero_flags_optional_and_reserved_flags_rejected",
+ "operation": "parse_only_never_unpack",
+ "compressed_hash": "sha256_of_every_compressed_input_byte",
+ "tar_iteration": "raw_entries",
+ "tar_termination": "two_or_more_512_byte_zero_blocks",
+ "allowed_members": ["regular_file", "zero_length_directory"],
+ "rejected_members": [
+ "symlink",
+ "hardlink",
+ "fifo",
+ "character_device",
+ "block_device",
+ "sparse",
+ "pax_extension",
+ "gnu_extension",
+ "unknown_type"
+ ],
+ "paths": "utf8_relative_nonempty_components_no_dot_no_dotdot_no_backslash_no_nul",
+ "regular_trailing_slash": "reject",
+ "duplicates": "reject",
+ "file_directory_prefix_conflicts": "reject",
+ "gzip_trailing_or_concatenated_data": "reject",
+ "member_body": "drain_to_eof_and_require_actual_equals_declared",
+ "semantic_archive_qualification": "deferred_to_step_304"
+ })
+ );
+}
+
+#[test]
+fn aggregate_inventory_nonclaims_and_vectors_are_exact() {
+ let value = decision();
+ assert_eq!(
+ value["aggregate_preflight"],
+ serde_json::json!({
+ "required_lanes": [
+ "catalog",
+ "service_source_lock_contract",
+ "service_build_qualification_contract",
+ "service_release_artifacts_contract",
+ "public_native_group",
+ "preview_group",
+ "tools_group",
+ "dto_roots",
+ "protocol_freshness",
+ "artifact_contracts",
+ "release_contracts"
+ ],
+ "execution": "invoke_each_required_lane_exactly_once_in_required_order_after_returned_results",
+ "pass": "exact_order_exact_presence_unique_no_unexpected_and_every_state_pass",
+ "nonpass_states": ["failed", "interrupted", "skipped", "unavailable"],
+ "missing_duplicate_unexpected_or_reordered": "fail_closed",
+ "lane_internal_safety": "owned_by_later_migration_steps_and_step_297",
+ "panic_signal_hang_abort": "not_a_returned_lane_state_requires_step_292_process_isolation"
+ })
+ );
+ assert_eq!(
+ value["adoption"],
+ serde_json::json!({
+ "service_release_artifacts_inputs": "one_exact_flat_snapshot_retained_through_all_snapshot_bound_copies_and_final_revalidation",
+ "service_release_artifacts_output_inventory": "one_exact_flat_snapshot_for_all_hashes_and_final_revalidation",
+ "service_release_artifacts_archives": "generated_binary_and_supplied_oci_tar_gzip_are_generically_admitted",
+ "service_release_artifacts_copy": "descriptor_bound_create_new_no_rollback_copy",
+ "other_preflight_lane_internals": "not_claimed_by_step_293"
+ })
+ );
+ assert_eq!(
+ value["diagnostic_safety"],
+ serde_json::json!({
+ "paths_and_member_names": "redacted",
+ "archive_bytes": "redacted",
+ "operating_system_error_text": "redacted",
+ "source_error_chain": "absent"
+ })
+ );
+ assert_eq!(
+ value["required_vectors"],
+ serde_json::json!([
+ "buffered_read_cap",
+ "streaming_file_cap",
+ "traversal_entry_cap",
+ "traversal_file_cap",
+ "traversal_total_byte_cap",
+ "traversal_file_byte_cap",
+ "traversal_depth_cap",
+ "traversal_path_cap",
+ "symlink",
+ "fifo",
+ "hardlink",
+ "leaf_replacement",
+ "in_place_mutation",
+ "parent_replacement",
+ "late_member_insertion",
+ "excluded_directory_replacement",
+ "artifact_special_mode",
+ "create_new_success_0600",
+ "create_new_untrusted_error_residue",
+ "output_name_swap_preserves_replacement",
+ "output_post_sync_mutation",
+ "output_chain_security",
+ "archive_compressed_cap",
+ "archive_expanded_cap",
+ "archive_member_count_cap",
+ "archive_member_byte_cap",
+ "archive_payload_cap",
+ "archive_depth_cap",
+ "archive_path_cap",
+ "archive_special_member",
+ "archive_escape",
+ "archive_duplicate",
+ "archive_prefix_conflict",
+ "archive_truncated",
+ "archive_concatenated",
+ "archive_malformed",
+ "gzip_optional_or_reserved_header",
+ "aggregate_missing",
+ "aggregate_failed",
+ "aggregate_interrupted",
+ "aggregate_skipped",
+ "aggregate_unavailable",
+ "aggregate_duplicate",
+ "aggregate_unexpected",
+ "aggregate_reordered",
+ "redaction"
+ ])
+ );
+}