lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
Acontracts/architecture/decisions/services_hardening_safe_artifact_io.v1.json | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/README | 4++++
Mtools/xtask/src/main.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Atools/xtask/src/release_preflight.rs | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/xtask/src/safe_artifact_io.rs | 2669+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/service_release_artifacts.rs | 503++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Atools/xtask/tests/services_hardening_safe_artifact_io_decision.rs | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(&current)?)]; + for component in path.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + let next = openat(&current, name, flags, Mode::empty()) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; + current = File::from(next); + chain.push(directory_identity(identity(&current)?)); + } + _ => { + 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(&current)?); + 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(&current, name, flags, Mode::empty()) + .map_err(|_| ArtifactIoError::new(ArtifactIoFailureKind::InvalidObject))?; + current = File::from(next); + let current_identity = output_directory_identity(identity(&current)?); + 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(&current, 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" + ]) + ); +}