lib

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

artifact_admission.rs (43712B)


      1 use std::{
      2     collections::{BTreeMap, BTreeSet},
      3     fmt, fs,
      4     io::{Seek as _, SeekFrom},
      5     path::{Component, Path},
      6     time::Duration,
      7 };
      8 
      9 use goblin::{
     10     Object,
     11     elf::{header::EM_X86_64, program_header::PF_X},
     12     mach::{Mach, constants::cputype::CPU_TYPE_ARM64, header::MH_EXECUTE},
     13 };
     14 use serde_json::{Map, Value, json};
     15 use sha2::{Digest as _, Sha256};
     16 
     17 use crate::{bounded_process, safe_artifact_io};
     18 use safe_artifact_io::TarGzipLimits;
     19 
     20 const CONTRACT_RELATIVE: &str =
     21     "contracts/architecture/decisions/services_hardening_artifact_admission.v1.json";
     22 const MAX_CONTRACT_BYTES: u64 = 65_536;
     23 const MAX_BINARY_PARSE_BYTES: u64 = 67_108_864;
     24 const MAX_OCI_BYTES: u64 = 2_147_483_648;
     25 const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 17_179_869_184;
     26 const MAX_ARCHIVE_MEMBERS: u64 = 65_536;
     27 const MAX_JSON_BYTES: u64 = 1_048_576;
     28 const MAX_PATH_BYTES: usize = 4_096;
     29 const MAX_DEPTH: usize = 64;
     30 const MAX_LAYERS: usize = 2;
     31 const MAX_RUNTIME_STREAM_BYTES: usize = 65_536;
     32 const RUNTIME_DEADLINE: Duration = Duration::from_secs(10);
     33 const AGPL_LICENSE: &str = "AGPL-3.0-or-later";
     34 const LINUX_TARGET: &str = "x86_64-unknown-linux-gnu";
     35 const MACOS_TARGET: &str = "aarch64-apple-darwin";
     36 
     37 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     38 enum AdmissionError {
     39     InvalidContract,
     40     InvalidBinary,
     41     BinarySmokeFailure,
     42     InvalidOci,
     43 }
     44 
     45 impl fmt::Display for AdmissionError {
     46     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     47         formatter.write_str(match self {
     48             Self::InvalidContract => "artifact admission contract is invalid",
     49             Self::InvalidBinary => "service binary admission failed",
     50             Self::BinarySmokeFailure => "service binary smoke admission failed",
     51             Self::InvalidOci => "service OCI admission failed",
     52         })
     53     }
     54 }
     55 
     56 impl std::error::Error for AdmissionError {}
     57 
     58 #[derive(Clone, Copy)]
     59 pub(crate) struct ContractVersions {
     60     pub(crate) admin: u32,
     61     pub(crate) config: u32,
     62     pub(crate) provider: u32,
     63     pub(crate) state: u32,
     64     pub(crate) status: u32,
     65 }
     66 
     67 pub(crate) struct OciExpectation<'a> {
     68     pub(crate) service: &'a str,
     69     pub(crate) binary_name: &'a str,
     70     pub(crate) version: &'a str,
     71     pub(crate) service_revision: &'a str,
     72     pub(crate) lib_revision: &'a str,
     73     pub(crate) license: &'a str,
     74     pub(crate) contract_versions: ContractVersions,
     75 }
     76 
     77 pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> {
     78     let bytes = safe_artifact_io::read_regular_path(
     79         &workspace_root.join(CONTRACT_RELATIVE),
     80         MAX_CONTRACT_BYTES,
     81     )
     82     .map_err(|_| AdmissionError::InvalidContract.to_string())?;
     83     let observed = serde_json::from_slice::<Value>(&bytes)
     84         .map_err(|_| AdmissionError::InvalidContract.to_string())?;
     85     if observed == expected_contract() {
     86         Ok(())
     87     } else {
     88         Err(AdmissionError::InvalidContract.to_string())
     89     }
     90 }
     91 
     92 fn expected_contract() -> Value {
     93     json!({
     94         "schema": "radroots.services-hardening.artifact-admission-decisions.v1",
     95         "contract_version": 1,
     96         "decision_state": "active",
     97         "owner_step": 304,
     98         "command_owner": "tools/xtask",
     99         "supported_binary_targets": [MACOS_TARGET, LINUX_TARGET],
    100         "binary": {
    101             "formats": {
    102                 MACOS_TARGET: "thin_macho64_arm64_execute",
    103                 LINUX_TARGET: "elf64_little_endian_x86_64_execute_or_pie"
    104             },
    105             "maximum_parse_bytes": MAX_BINARY_PARSE_BYTES,
    106             "architecture": "exact_target_match",
    107             "linkage": "parse_declared_dynamic_libraries_and_forbid_external_sqlite",
    108             "sqlite": "one_bundled_native_linkage_under_the_separate_sqlx_only_source_contract",
    109             "structural_smoke": "executable_type_nonzero_entrypoint_and_executable_segment",
    110             "runtime_smoke": "bounded_help_execution_on_the_matching_native_host",
    111             "fat_or_multi_arch": "forbidden"
    112         },
    113         "oci": {
    114             "format": "single_image_docker_archive_tar_gzip",
    115             "platform": "linux_amd64",
    116             "maximum_layers": MAX_LAYERS,
    117             "outer_archive": "descriptor_bound_bounded_safe_materialization_with_exact_inventory",
    118             "manifest": "one_entry_exact_config_repo_tag_and_layer_references",
    119             "config": "content_addressed_json_with_exact_rootless_runtime_and_build_labels",
    120             "license": "Cargo.toml package license derived AGPL-3.0-or-later",
    121             "layers": "parse_only_bounded_tar_validation_with_content_digest_reconciliation",
    122             "entrypoint": "one_regular_executable_payload_at_the_configured_store_path",
    123             "unpacking": "forbidden"
    124         },
    125         "maximums": {
    126             "outer_compressed_bytes": MAX_OCI_BYTES,
    127             "outer_expanded_bytes": MAX_ARCHIVE_EXPANDED_BYTES,
    128             "outer_members": MAX_ARCHIVE_MEMBERS,
    129             "outer_member_bytes": MAX_ARCHIVE_EXPANDED_BYTES,
    130             "json_bytes": MAX_JSON_BYTES,
    131             "layer_members": MAX_ARCHIVE_MEMBERS,
    132             "layer_payload_bytes": MAX_ARCHIVE_EXPANDED_BYTES,
    133             "path_bytes": MAX_PATH_BYTES,
    134             "depth": MAX_DEPTH,
    135             "runtime_seconds": RUNTIME_DEADLINE.as_secs(),
    136             "runtime_stream_bytes": MAX_RUNTIME_STREAM_BYTES
    137         },
    138         "required_negative_vectors": [
    139             "arbitrary_binary_bytes", "wrong_binary_architecture", "fat_macho",
    140             "missing_binary_entrypoint", "external_sqlite_linkage", "runtime_smoke_failure",
    141             "unsafe_outer_tar_member", "wrong_oci_license", "multiple_manifest_entries",
    142             "config_digest_mismatch", "wrong_oci_platform", "wrong_rootless_config",
    143             "unexpected_label", "missing_layer", "layer_digest_mismatch",
    144             "unsafe_layer_path", "missing_entrypoint_payload"
    145         ],
    146         "nonclaims": [
    147             "Linux artifact build on a macOS host without a Linux builder",
    148             "signature notarization publication deployment or production activation"
    149         ]
    150     })
    151 }
    152 
    153 pub(crate) fn admit_binary(path: &Path, target: &str, runtime_smoke: bool) -> Result<(), String> {
    154     admit_binary_inner(path, target, runtime_smoke).map_err(|error| error.to_string())
    155 }
    156 
    157 fn admit_binary_inner(
    158     path: &Path,
    159     target: &str,
    160     runtime_smoke: bool,
    161 ) -> Result<(), AdmissionError> {
    162     if ![MACOS_TARGET, LINUX_TARGET].contains(&target) {
    163         return Err(AdmissionError::InvalidBinary);
    164     }
    165     let bytes = safe_artifact_io::read_regular_path(path, MAX_BINARY_PARSE_BYTES)
    166         .map_err(|_| AdmissionError::InvalidBinary)?;
    167     admit_binary_bytes(&bytes, target)?;
    168     if runtime_smoke && target_matches_host(target) {
    169         runtime_help_smoke(path)?;
    170     }
    171     Ok(())
    172 }
    173 
    174 fn admit_binary_bytes(bytes: &[u8], target: &str) -> Result<(), AdmissionError> {
    175     let libraries = match (
    176         target,
    177         Object::parse(bytes).map_err(|_| AdmissionError::InvalidBinary)?,
    178     ) {
    179         (LINUX_TARGET, Object::Elf(binary)) => {
    180             if !binary.is_64
    181                 || !binary.little_endian
    182                 || binary.header.e_machine != EM_X86_64
    183                 || !matches!(
    184                     binary.header.e_type,
    185                     goblin::elf::header::ET_EXEC | goblin::elf::header::ET_DYN
    186                 )
    187                 || binary.entry == 0
    188                 || !binary.program_headers.iter().any(|header| {
    189                     header.p_flags & PF_X != 0
    190                         && binary.entry >= header.p_vaddr
    191                         && binary.entry < header.p_vaddr.saturating_add(header.p_memsz)
    192                 })
    193             {
    194                 return Err(AdmissionError::InvalidBinary);
    195             }
    196             binary
    197                 .libraries
    198                 .iter()
    199                 .map(|value| (*value).to_owned())
    200                 .collect::<Vec<_>>()
    201         }
    202         (MACOS_TARGET, Object::Mach(Mach::Binary(binary))) => {
    203             if binary.header.cputype != CPU_TYPE_ARM64
    204                 || binary.header.filetype != MH_EXECUTE
    205                 || binary.entry == 0
    206                 || !binary.segments.iter().any(|segment| {
    207                     segment.initprot & 0x4 != 0
    208                         && binary.entry >= segment.vmaddr
    209                         && binary.entry < segment.vmaddr.saturating_add(segment.vmsize)
    210                 })
    211             {
    212                 return Err(AdmissionError::InvalidBinary);
    213             }
    214             binary
    215                 .libs
    216                 .iter()
    217                 .map(|value| (*value).to_owned())
    218                 .collect::<Vec<_>>()
    219         }
    220         _ => return Err(AdmissionError::InvalidBinary),
    221     };
    222     validate_dynamic_libraries(&libraries)
    223 }
    224 
    225 fn validate_dynamic_libraries(libraries: &[String]) -> Result<(), AdmissionError> {
    226     if libraries.iter().any(|library| {
    227         library.is_empty()
    228             || library.len() > 1_024
    229             || library.contains(['\n', '\r', '\0'])
    230             || library.to_ascii_lowercase().contains("sqlite")
    231     }) {
    232         Err(AdmissionError::InvalidBinary)
    233     } else {
    234         Ok(())
    235     }
    236 }
    237 
    238 fn target_matches_host(target: &str) -> bool {
    239     matches!(
    240         (target, std::env::consts::OS, std::env::consts::ARCH),
    241         (MACOS_TARGET, "macos", "aarch64") | (LINUX_TARGET, "linux", "x86_64")
    242     )
    243 }
    244 
    245 fn runtime_help_smoke(path: &Path) -> Result<(), AdmissionError> {
    246     #[cfg(unix)]
    247     {
    248         use std::os::unix::fs::PermissionsExt as _;
    249         fs::set_permissions(path, fs::Permissions::from_mode(0o500))
    250             .map_err(|_| AdmissionError::BinarySmokeFailure)?;
    251     }
    252     let parent = path.parent().ok_or(AdmissionError::BinarySmokeFailure)?;
    253     let output = bounded_process::run(
    254         &bounded_process::ProcessRequest::new(path.as_os_str())
    255             .arg("--help")
    256             .current_dir(parent)
    257             .deadline(RUNTIME_DEADLINE)
    258             .output_limits(MAX_RUNTIME_STREAM_BYTES, MAX_RUNTIME_STREAM_BYTES),
    259     )
    260     .map_err(|_| AdmissionError::BinarySmokeFailure)?;
    261     if output.status().success() {
    262         Ok(())
    263     } else {
    264         Err(AdmissionError::BinarySmokeFailure)
    265     }
    266 }
    267 
    268 pub(crate) fn admit_oci(
    269     path: &Path,
    270     trusted_parent: &Path,
    271     expected: &OciExpectation<'_>,
    272 ) -> Result<(), String> {
    273     admit_oci_inner(path, trusted_parent, expected).map_err(|error| error.to_string())
    274 }
    275 
    276 fn admit_oci_inner(
    277     path: &Path,
    278     trusted_parent: &Path,
    279     expected: &OciExpectation<'_>,
    280 ) -> Result<(), AdmissionError> {
    281     if expected.license != AGPL_LICENSE
    282         || !valid_identifier(expected.service)
    283         || !valid_binary_name(expected.binary_name)
    284         || !valid_hex(expected.service_revision, 40)
    285         || !valid_hex(expected.lib_revision, 40)
    286     {
    287         return Err(AdmissionError::InvalidOci);
    288     }
    289     let limits = TarGzipLimits {
    290         max_compressed_bytes: MAX_OCI_BYTES,
    291         max_expanded_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
    292         max_members: MAX_ARCHIVE_MEMBERS,
    293         max_member_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
    294         max_payload_bytes: MAX_ARCHIVE_EXPANDED_BYTES,
    295         max_depth: MAX_DEPTH,
    296         max_path_bytes: MAX_PATH_BYTES,
    297     };
    298     let materialized = safe_artifact_io::materialize_tar_gzip_path(path, trusted_parent, limits)
    299         .map_err(|_| AdmissionError::InvalidOci)?;
    300     let snapshot = materialized.snapshot();
    301     let manifest = read_json_member(snapshot, "manifest.json")?;
    302     let manifest = manifest.as_array().ok_or(AdmissionError::InvalidOci)?;
    303     if manifest.len() != 1 {
    304         return Err(AdmissionError::InvalidOci);
    305     }
    306     let record = manifest[0].as_object().ok_or(AdmissionError::InvalidOci)?;
    307     require_exact_keys(record, &["Config", "Layers", "RepoTags"])?;
    308     let config_name = json_string(record, "Config")?;
    309     if !config_name.ends_with(".json") || !valid_hex(config_name.trim_end_matches(".json"), 64) {
    310         return Err(AdmissionError::InvalidOci);
    311     }
    312     let image_name = expected.service.replace('_', "-");
    313     if json_string_array(record, "RepoTags")? != [format!("{image_name}:{}", expected.version)] {
    314         return Err(AdmissionError::InvalidOci);
    315     }
    316     let layers = json_string_array(record, "Layers")?;
    317     if layers.is_empty() || layers.len() > MAX_LAYERS || !all_unique(&layers) {
    318         return Err(AdmissionError::InvalidOci);
    319     }
    320     for layer in &layers {
    321         validate_layer_name(layer)?;
    322     }
    323     let config_bytes = read_member(snapshot, config_name, MAX_JSON_BYTES)?;
    324     if sha256(&config_bytes) != config_name.trim_end_matches(".json") {
    325         return Err(AdmissionError::InvalidOci);
    326     }
    327     let config =
    328         serde_json::from_slice::<Value>(&config_bytes).map_err(|_| AdmissionError::InvalidOci)?;
    329     let entrypoint = validate_config(&config, expected)?;
    330     validate_repositories(snapshot, &image_name, expected.version, &layers)?;
    331     validate_outer_inventory(snapshot, config_name, &layers)?;
    332     let rootfs = config
    333         .get("rootfs")
    334         .and_then(Value::as_object)
    335         .ok_or(AdmissionError::InvalidOci)?;
    336     require_exact_keys(rootfs, &["diff_ids", "type"])?;
    337     if json_string(rootfs, "type")? != "layers" {
    338         return Err(AdmissionError::InvalidOci);
    339     }
    340     let diff_ids = json_string_array(rootfs, "diff_ids")?;
    341     if diff_ids.len() != layers.len() {
    342         return Err(AdmissionError::InvalidOci);
    343     }
    344     let mut entrypoint_count = 0_u32;
    345     for (layer, diff_id) in layers.iter().zip(diff_ids) {
    346         let evidence = snapshot
    347             .hash(
    348                 snapshot_member(snapshot, layer)?,
    349                 MAX_ARCHIVE_EXPANDED_BYTES,
    350             )
    351             .map_err(|_| AdmissionError::InvalidOci)?;
    352         if diff_id != format!("sha256:{}", evidence.sha256) {
    353             return Err(AdmissionError::InvalidOci);
    354         }
    355         entrypoint_count = entrypoint_count
    356             .checked_add(validate_layer_tar(materialized.root(), layer, &entrypoint)?)
    357             .ok_or(AdmissionError::InvalidOci)?;
    358     }
    359     materialized
    360         .revalidate()
    361         .map_err(|_| AdmissionError::InvalidOci)?;
    362     if entrypoint_count == 1 {
    363         Ok(())
    364     } else {
    365         Err(AdmissionError::InvalidOci)
    366     }
    367 }
    368 
    369 fn validate_config(
    370     config: &Value,
    371     expected: &OciExpectation<'_>,
    372 ) -> Result<String, AdmissionError> {
    373     let object = config.as_object().ok_or(AdmissionError::InvalidOci)?;
    374     if json_string(object, "architecture")? != "amd64"
    375         || json_string(object, "os")? != "linux"
    376         || json_string(object, "created")? != "1970-01-01T00:00:01+00:00"
    377     {
    378         return Err(AdmissionError::InvalidOci);
    379     }
    380     let runtime = object
    381         .get("config")
    382         .and_then(Value::as_object)
    383         .ok_or(AdmissionError::InvalidOci)?;
    384     require_exact_keys(
    385         runtime,
    386         &[
    387             "Entrypoint",
    388             "Env",
    389             "Labels",
    390             "StopSignal",
    391             "User",
    392             "WorkingDir",
    393         ],
    394     )?;
    395     if json_string(runtime, "User")? != "65532:65532"
    396         || json_string(runtime, "WorkingDir")? != "/"
    397         || json_string(runtime, "StopSignal")? != "SIGTERM"
    398         || json_string_array(runtime, "Env")?
    399             != ["SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"]
    400     {
    401         return Err(AdmissionError::InvalidOci);
    402     }
    403     let entrypoints = json_string_array(runtime, "Entrypoint")?;
    404     if entrypoints.len() != 1 {
    405         return Err(AdmissionError::InvalidOci);
    406     }
    407     let entrypoint = entrypoints[0].clone();
    408     if !entrypoint.starts_with("/nix/store/")
    409         || !entrypoint.ends_with(&format!("/bin/{}", expected.binary_name))
    410         || !valid_absolute_path(&entrypoint)
    411     {
    412         return Err(AdmissionError::InvalidOci);
    413     }
    414     let labels = runtime
    415         .get("Labels")
    416         .and_then(Value::as_object)
    417         .ok_or(AdmissionError::InvalidOci)?;
    418     let expected_labels = expected_labels(expected);
    419     if labels.len() != expected_labels.len()
    420         || expected_labels
    421             .iter()
    422             .any(|(key, value)| labels.get(key).and_then(Value::as_str) != Some(value))
    423     {
    424         return Err(AdmissionError::InvalidOci);
    425     }
    426     Ok(entrypoint)
    427 }
    428 
    429 fn expected_labels(expected: &OciExpectation<'_>) -> BTreeMap<String, String> {
    430     let mut labels = BTreeMap::new();
    431     for (key, value) in [
    432         (
    433             "dev.radroots.build.feature-profile",
    434             "service-host".to_owned(),
    435         ),
    436         (
    437             "dev.radroots.build.lib-revision",
    438             expected.lib_revision.to_owned(),
    439         ),
    440         ("dev.radroots.build.rust-version", "1.97.1".to_owned()),
    441         ("dev.radroots.build.target", LINUX_TARGET.to_owned()),
    442         (
    443             "dev.radroots.contract.admin-version",
    444             expected.contract_versions.admin.to_string(),
    445         ),
    446         (
    447             "dev.radroots.contract.config-version",
    448             expected.contract_versions.config.to_string(),
    449         ),
    450         (
    451             "dev.radroots.contract.provider-version",
    452             expected.contract_versions.provider.to_string(),
    453         ),
    454         (
    455             "dev.radroots.contract.state-version",
    456             expected.contract_versions.state.to_string(),
    457         ),
    458         (
    459             "dev.radroots.contract.status-version",
    460             expected.contract_versions.status.to_string(),
    461         ),
    462         (
    463             "dev.radroots.mount.config",
    464             format!("/etc/radroots/services/{}", expected.service),
    465         ),
    466         ("dev.radroots.mount.config.mode", "read-only".to_owned()),
    467         (
    468             "dev.radroots.mount.credentials",
    469             format!("/etc/radroots/secrets/services/{}", expected.service),
    470         ),
    471         (
    472             "dev.radroots.mount.credentials.mode",
    473             "read-only".to_owned(),
    474         ),
    475         (
    476             "dev.radroots.mount.runtime",
    477             format!("/run/radroots/services/{}", expected.service),
    478         ),
    479         ("dev.radroots.mount.runtime.mode", "read-write".to_owned()),
    480         (
    481             "dev.radroots.mount.state",
    482             format!("/var/lib/radroots/services/{}", expected.service),
    483         ),
    484         ("dev.radroots.mount.state.mode", "read-write".to_owned()),
    485         ("dev.radroots.rootfs", "read-only-compatible".to_owned()),
    486         (
    487             "org.opencontainers.image.description",
    488             format!("Hardened {} service image", expected.service),
    489         ),
    490         (
    491             "org.opencontainers.image.licenses",
    492             expected.license.to_owned(),
    493         ),
    494         (
    495             "org.opencontainers.image.revision",
    496             expected.service_revision.to_owned(),
    497         ),
    498         (
    499             "org.opencontainers.image.title",
    500             expected.service.to_owned(),
    501         ),
    502         (
    503             "org.opencontainers.image.version",
    504             expected.version.to_owned(),
    505         ),
    506     ] {
    507         labels.insert(key.to_owned(), value);
    508     }
    509     labels
    510 }
    511 
    512 #[cfg(test)]
    513 pub(crate) fn fixture_labels(expected: &OciExpectation<'_>) -> BTreeMap<String, String> {
    514     expected_labels(expected)
    515 }
    516 
    517 fn validate_repositories(
    518     snapshot: &safe_artifact_io::TraversalSnapshot,
    519     image_name: &str,
    520     version: &str,
    521     layers: &[String],
    522 ) -> Result<(), AdmissionError> {
    523     let repositories = read_json_member(snapshot, "repositories")?;
    524     let last_layer = layers
    525         .last()
    526         .and_then(|path| path.split('/').next())
    527         .ok_or(AdmissionError::InvalidOci)?;
    528     if repositories == json!({ image_name: { version: last_layer } }) {
    529         Ok(())
    530     } else {
    531         Err(AdmissionError::InvalidOci)
    532     }
    533 }
    534 
    535 fn validate_outer_inventory(
    536     snapshot: &safe_artifact_io::TraversalSnapshot,
    537     config_name: &str,
    538     layers: &[String],
    539 ) -> Result<(), AdmissionError> {
    540     let mut expected_files = BTreeSet::from([
    541         "manifest.json".to_owned(),
    542         "repositories".to_owned(),
    543         config_name.to_owned(),
    544     ]);
    545     let mut expected_directories = BTreeSet::new();
    546     for layer in layers {
    547         let directory = layer.split('/').next().ok_or(AdmissionError::InvalidOci)?;
    548         expected_directories.insert(directory.to_owned());
    549         expected_files.insert(format!("{directory}/VERSION"));
    550         expected_files.insert(format!("{directory}/json"));
    551         expected_files.insert(layer.clone());
    552     }
    553     let observed_files = snapshot
    554         .files()
    555         .iter()
    556         .map(|file| {
    557             file.relative_path()
    558                 .to_str()
    559                 .map(str::to_owned)
    560                 .ok_or(AdmissionError::InvalidOci)
    561         })
    562         .collect::<Result<BTreeSet<_>, _>>()?;
    563     let observed_directories = snapshot
    564         .directories()
    565         .filter_map(|(path, _)| (!path.as_os_str().is_empty()).then_some(path))
    566         .map(|path| {
    567             path.to_str()
    568                 .map(str::to_owned)
    569                 .ok_or(AdmissionError::InvalidOci)
    570         })
    571         .collect::<Result<BTreeSet<_>, _>>()?;
    572     if observed_files != expected_files || observed_directories != expected_directories {
    573         return Err(AdmissionError::InvalidOci);
    574     }
    575     for layer in layers {
    576         let directory = layer.split('/').next().ok_or(AdmissionError::InvalidOci)?;
    577         if read_member(snapshot, &format!("{directory}/VERSION"), 16)? != b"1.0" {
    578             return Err(AdmissionError::InvalidOci);
    579         }
    580         serde_json::from_slice::<Value>(&read_member(
    581             snapshot,
    582             &format!("{directory}/json"),
    583             MAX_JSON_BYTES,
    584         )?)
    585         .map_err(|_| AdmissionError::InvalidOci)?;
    586     }
    587     Ok(())
    588 }
    589 
    590 fn validate_layer_tar(
    591     materialized_root: &Path,
    592     relative: &str,
    593     entrypoint: &str,
    594 ) -> Result<u32, AdmissionError> {
    595     let path = materialized_root.join(relative);
    596     let mut file = fs::File::open(&path).map_err(|_| AdmissionError::InvalidOci)?;
    597     let length = file
    598         .metadata()
    599         .map_err(|_| AdmissionError::InvalidOci)?
    600         .len();
    601     if length == 0 || length > MAX_ARCHIVE_EXPANDED_BYTES {
    602         return Err(AdmissionError::InvalidOci);
    603     }
    604     file.seek(SeekFrom::Start(0))
    605         .map_err(|_| AdmissionError::InvalidOci)?;
    606     let mut archive = tar::Archive::new(file);
    607     let mut count = 0_u64;
    608     let mut payload = 0_u64;
    609     let mut paths = BTreeSet::new();
    610     let mut entrypoint_count = 0_u32;
    611     let expected_entrypoint = entrypoint.trim_start_matches('/').as_bytes();
    612     for entry in archive.entries().map_err(|_| AdmissionError::InvalidOci)? {
    613         let mut entry = entry.map_err(|_| AdmissionError::InvalidOci)?;
    614         count = count.checked_add(1).ok_or(AdmissionError::InvalidOci)?;
    615         if count > MAX_ARCHIVE_MEMBERS {
    616             return Err(AdmissionError::InvalidOci);
    617         }
    618         let path = entry.path_bytes();
    619         validate_relative_path(&path)?;
    620         let normalized = path.strip_suffix(b"/").unwrap_or(&path).to_vec();
    621         if !paths.insert(normalized.clone()) {
    622             return Err(AdmissionError::InvalidOci);
    623         }
    624         let kind = entry.header().entry_type();
    625         if kind.is_file() {
    626             payload = payload
    627                 .checked_add(entry.size())
    628                 .ok_or(AdmissionError::InvalidOci)?;
    629             if payload > MAX_ARCHIVE_EXPANDED_BYTES {
    630                 return Err(AdmissionError::InvalidOci);
    631             }
    632             if normalized == expected_entrypoint {
    633                 if entry
    634                     .header()
    635                     .mode()
    636                     .map_err(|_| AdmissionError::InvalidOci)?
    637                     & 0o111
    638                     == 0
    639                 {
    640                     return Err(AdmissionError::InvalidOci);
    641                 }
    642                 entrypoint_count = entrypoint_count
    643                     .checked_add(1)
    644                     .ok_or(AdmissionError::InvalidOci)?;
    645             }
    646             std::io::copy(&mut entry, &mut std::io::sink())
    647                 .map_err(|_| AdmissionError::InvalidOci)?;
    648         } else if kind.is_dir() {
    649             if !path.ends_with(b"/") || entry.size() != 0 {
    650                 return Err(AdmissionError::InvalidOci);
    651             }
    652         } else if kind.is_symlink() || kind.is_hard_link() {
    653             if entry.size() != 0 {
    654                 return Err(AdmissionError::InvalidOci);
    655             }
    656             let target = entry.link_name_bytes().ok_or(AdmissionError::InvalidOci)?;
    657             validate_link_target(&normalized, &target)?;
    658         } else {
    659             return Err(AdmissionError::InvalidOci);
    660         }
    661     }
    662     if count == 0 {
    663         Err(AdmissionError::InvalidOci)
    664     } else {
    665         Ok(entrypoint_count)
    666     }
    667 }
    668 
    669 fn validate_layer_name(path: &str) -> Result<(), AdmissionError> {
    670     let Some((directory, leaf)) = path.split_once('/') else {
    671         return Err(AdmissionError::InvalidOci);
    672     };
    673     if leaf == "layer.tar" && valid_hex(directory, 64) {
    674         Ok(())
    675     } else {
    676         Err(AdmissionError::InvalidOci)
    677     }
    678 }
    679 
    680 fn validate_relative_path(path: &[u8]) -> Result<(), AdmissionError> {
    681     if path.is_empty()
    682         || path.len() > MAX_PATH_BYTES
    683         || path.starts_with(b"/")
    684         || path.contains(&0)
    685         || path.contains(&b'\\')
    686         || std::str::from_utf8(path).is_err()
    687     {
    688         return Err(AdmissionError::InvalidOci);
    689     }
    690     let path = path.strip_suffix(b"/").unwrap_or(path);
    691     let mut depth = 0_usize;
    692     for component in path.split(|byte| *byte == b'/') {
    693         depth = depth.checked_add(1).ok_or(AdmissionError::InvalidOci)?;
    694         if component.is_empty() || matches!(component, b"." | b"..") || depth > MAX_DEPTH {
    695             return Err(AdmissionError::InvalidOci);
    696         }
    697     }
    698     Ok(())
    699 }
    700 
    701 fn validate_link_target(path: &[u8], target: &[u8]) -> Result<(), AdmissionError> {
    702     if target.is_empty()
    703         || target.len() > MAX_PATH_BYTES
    704         || target.starts_with(b"/")
    705         || target.contains(&0)
    706         || target.contains(&b'\\')
    707         || std::str::from_utf8(target).is_err()
    708     {
    709         return Err(AdmissionError::InvalidOci);
    710     }
    711     let mut depth = path.split(|byte| *byte == b'/').count().saturating_sub(1);
    712     for component in target.split(|byte| *byte == b'/') {
    713         match component {
    714             b"" | b"." => {}
    715             b".." => depth = depth.checked_sub(1).ok_or(AdmissionError::InvalidOci)?,
    716             _ => {
    717                 depth = depth.checked_add(1).ok_or(AdmissionError::InvalidOci)?;
    718                 if depth > MAX_DEPTH {
    719                     return Err(AdmissionError::InvalidOci);
    720                 }
    721             }
    722         }
    723     }
    724     Ok(())
    725 }
    726 
    727 fn valid_absolute_path(value: &str) -> bool {
    728     let path = Path::new(value);
    729     path.is_absolute()
    730         && value.len() <= MAX_PATH_BYTES
    731         && path
    732             .components()
    733             .all(|component| matches!(component, Component::RootDir | Component::Normal(_)))
    734 }
    735 fn read_json_member(
    736     snapshot: &safe_artifact_io::TraversalSnapshot,
    737     name: &str,
    738 ) -> Result<Value, AdmissionError> {
    739     serde_json::from_slice(&read_member(snapshot, name, MAX_JSON_BYTES)?)
    740         .map_err(|_| AdmissionError::InvalidOci)
    741 }
    742 fn read_member(
    743     snapshot: &safe_artifact_io::TraversalSnapshot,
    744     name: &str,
    745     maximum: u64,
    746 ) -> Result<Vec<u8>, AdmissionError> {
    747     snapshot
    748         .read(snapshot_member(snapshot, name)?, maximum)
    749         .map_err(|_| AdmissionError::InvalidOci)
    750 }
    751 fn snapshot_member<'a>(
    752     snapshot: &'a safe_artifact_io::TraversalSnapshot,
    753     name: &str,
    754 ) -> Result<&'a safe_artifact_io::TraversedFile, AdmissionError> {
    755     snapshot
    756         .files()
    757         .iter()
    758         .find(|file| file.relative_path() == Path::new(name))
    759         .ok_or(AdmissionError::InvalidOci)
    760 }
    761 fn require_exact_keys(
    762     object: &Map<String, Value>,
    763     expected: &[&str],
    764 ) -> Result<(), AdmissionError> {
    765     let observed = object.keys().map(String::as_str).collect::<BTreeSet<_>>();
    766     if observed == expected.iter().copied().collect() {
    767         Ok(())
    768     } else {
    769         Err(AdmissionError::InvalidOci)
    770     }
    771 }
    772 fn json_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a str, AdmissionError> {
    773     object
    774         .get(key)
    775         .and_then(Value::as_str)
    776         .ok_or(AdmissionError::InvalidOci)
    777 }
    778 fn json_string_array(
    779     object: &Map<String, Value>,
    780     key: &str,
    781 ) -> Result<Vec<String>, AdmissionError> {
    782     object
    783         .get(key)
    784         .and_then(Value::as_array)
    785         .ok_or(AdmissionError::InvalidOci)?
    786         .iter()
    787         .map(|value| {
    788             value
    789                 .as_str()
    790                 .map(str::to_owned)
    791                 .ok_or(AdmissionError::InvalidOci)
    792         })
    793         .collect()
    794 }
    795 fn all_unique(values: &[String]) -> bool {
    796     values.iter().collect::<BTreeSet<_>>().len() == values.len()
    797 }
    798 fn valid_hex(value: &str, length: usize) -> bool {
    799     value.len() == length
    800         && value
    801             .bytes()
    802             .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
    803 }
    804 fn valid_identifier(value: &str) -> bool {
    805     value.len() <= 128
    806         && value
    807             .bytes()
    808             .next()
    809             .is_some_and(|byte| byte.is_ascii_lowercase())
    810         && value
    811             .bytes()
    812             .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
    813 }
    814 fn valid_binary_name(value: &str) -> bool {
    815     value.len() <= 128
    816         && value
    817             .bytes()
    818             .next()
    819             .is_some_and(|byte| byte.is_ascii_lowercase())
    820         && value.bytes().all(|byte| {
    821             byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
    822         })
    823 }
    824 fn sha256(bytes: &[u8]) -> String {
    825     hex::encode(Sha256::digest(bytes))
    826 }
    827 
    828 #[cfg(test)]
    829 mod tests {
    830     use super::*;
    831 
    832     // Synthetic ELF headers with one executable PT_LOAD segment. These bytes
    833     // exercise format admission only; no fixture is executed or released.
    834     fn elf_fixture(is_64: bool, little_endian: bool) -> Vec<u8> {
    835         fn put(bytes: &mut [u8], offset: usize, width: usize, value: u64, little: bool) {
    836             let encoded = if little {
    837                 value.to_le_bytes()
    838             } else {
    839                 value.to_be_bytes()
    840             };
    841             let source = if little {
    842                 &encoded[..width]
    843             } else {
    844                 &encoded[8 - width..]
    845             };
    846             bytes[offset..offset + width].copy_from_slice(source);
    847         }
    848         let header = if is_64 { 64 } else { 52 };
    849         let program = if is_64 { 56 } else { 32 };
    850         let mut bytes = vec![0; header + program + 8];
    851         bytes[..4].copy_from_slice(goblin::elf::header::ELFMAG);
    852         bytes[4] = if is_64 { 2 } else { 1 };
    853         bytes[5] = if little_endian { 1 } else { 2 };
    854         bytes[6] = 1;
    855         let length = bytes.len() as u64;
    856         let word = if is_64 { 8 } else { 4 };
    857         for (offset, width, value) in [
    858             (16, 2, 2),
    859             (18, 2, u64::from(EM_X86_64)),
    860             (20, 4, 1),
    861             (24, word, 0x400000 + (header + program) as u64),
    862             (24 + word, word, header as u64),
    863             (if is_64 { 52 } else { 40 }, 2, header as u64),
    864             (if is_64 { 54 } else { 42 }, 2, program as u64),
    865             (if is_64 { 56 } else { 44 }, 2, 1),
    866             (header, 4, 1),
    867             (header + if is_64 { 4 } else { 24 }, 4, 5),
    868             (header + if is_64 { 16 } else { 8 }, word, 0x400000),
    869             (header + if is_64 { 32 } else { 16 }, word, length),
    870             (header + if is_64 { 40 } else { 20 }, word, length),
    871         ] {
    872             put(&mut bytes, offset, width, value, little_endian);
    873         }
    874         bytes
    875     }
    876 
    877     #[test]
    878     fn linux_format_requires_both_64_bit_class_and_little_endian_encoding() {
    879         let expected = expected_contract();
    880         assert_eq!(
    881             expected["binary"]["formats"][LINUX_TARGET],
    882             "elf64_little_endian_x86_64_execute_or_pie"
    883         );
    884         assert!(admit_binary_bytes(&elf_fixture(true, true), LINUX_TARGET).is_ok());
    885         let results = [(false, true), (true, false)].map(|(class, endian)| {
    886             let bytes = elf_fixture(class, endian);
    887             let Object::Elf(parsed) = Object::parse(&bytes).unwrap() else {
    888                 panic!("ELF fixture");
    889             };
    890             assert_eq!(parsed.is_64, class);
    891             assert_eq!(parsed.little_endian, endian);
    892             admit_binary_bytes(&bytes, LINUX_TARGET)
    893         });
    894         assert_eq!(results, [Err(AdmissionError::InvalidBinary); 2]);
    895     }
    896 
    897     #[test]
    898     fn mach_executable_admission_binds_machine_type_entry_and_executable_segment() {
    899         let mut original = vec![0u8; 136];
    900         for (offset, value) in [
    901             (0, 0xfeedfacfu32),
    902             (4, CPU_TYPE_ARM64),
    903             (12, MH_EXECUTE),
    904             (16, 2),
    905             (20, 96),
    906             (32, 0x19),
    907             (36, 72),
    908             (88, 5),
    909             (92, 5),
    910             (104, 0x80000028),
    911             (108, 24),
    912         ] {
    913             original[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
    914         }
    915         original[40..46].copy_from_slice(b"__TEXT");
    916         for (offset, value) in [(56, 0x100000000u64), (64, 136), (80, 136), (112, 128)] {
    917             original[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
    918         }
    919         assert!(admit_binary_bytes(&original, MACOS_TARGET).is_ok());
    920         for (offset, bytes) in [
    921             (4, 7u32.to_le_bytes().to_vec()),
    922             (12, 6u32.to_le_bytes().to_vec()),
    923             (92, 1u32.to_le_bytes().to_vec()),
    924             (112, 136u64.to_le_bytes().to_vec()),
    925         ] {
    926             let mut changed = original.clone();
    927             changed[offset..offset + bytes.len()].copy_from_slice(&bytes);
    928             assert_eq!(
    929                 admit_binary_bytes(&changed, MACOS_TARGET),
    930                 Err(AdmissionError::InvalidBinary),
    931                 "offset {offset}"
    932             );
    933         }
    934         let mut no_entry = original;
    935         no_entry[56..64].fill(0);
    936         no_entry[112..120].fill(0);
    937         assert_eq!(
    938             admit_binary_bytes(&no_entry, MACOS_TARGET),
    939             Err(AdmissionError::InvalidBinary)
    940         );
    941     }
    942 
    943     #[test]
    944     fn linux_entrypoint_must_belong_to_an_executable_segment_of_the_exact_machine() {
    945         let original = elf_fixture(true, true);
    946         for (offset, replacement) in [
    947             (16, 1u16.to_le_bytes().to_vec()),
    948             (18, 183u16.to_le_bytes().to_vec()),
    949             (24, 0u64.to_le_bytes().to_vec()),
    950             (24, 0x3fffffu64.to_le_bytes().to_vec()),
    951             (
    952                 24,
    953                 (0x400000u64 + original.len() as u64).to_le_bytes().to_vec(),
    954             ),
    955             (68, 4u32.to_le_bytes().to_vec()),
    956         ] {
    957             let mut invalid = original.clone();
    958             invalid[offset..offset + replacement.len()].copy_from_slice(&replacement);
    959             assert_eq!(
    960                 admit_binary_bytes(&invalid, LINUX_TARGET),
    961                 Err(AdmissionError::InvalidBinary),
    962                 "header offset {offset}"
    963             );
    964         }
    965         let mut pie = original;
    966         pie[16..18].copy_from_slice(&goblin::elf::header::ET_DYN.to_le_bytes());
    967         assert!(admit_binary_bytes(&pie, LINUX_TARGET).is_ok());
    968         assert!(admit_binary_bytes(&pie, MACOS_TARGET).is_err());
    969         assert!(admit_binary_inner(Path::new("/absent"), "unknown", false).is_err());
    970         for libraries in [
    971             vec![String::new()],
    972             vec!["x".repeat(1025)],
    973             vec!["lib\nname".into()],
    974             vec!["SQLite.DLL".into()],
    975         ] {
    976             assert_eq!(
    977                 validate_dynamic_libraries(&libraries),
    978                 Err(AdmissionError::InvalidBinary)
    979             );
    980         }
    981         assert!(validate_dynamic_libraries(&["libc.so.6".into()]).is_ok());
    982     }
    983 
    984     #[test]
    985     fn archive_paths_and_links_enforce_byte_depth_and_traversal_bounds() {
    986         let exact_depth = vec!["a"; MAX_DEPTH].join("/");
    987         let excessive_depth = vec!["a"; MAX_DEPTH + 1].join("/");
    988         assert!(validate_relative_path(exact_depth.as_bytes()).is_ok());
    989         assert!(validate_relative_path(&vec![b'a'; MAX_PATH_BYTES]).is_ok());
    990         for path in [
    991             b"".to_vec(),
    992             b"/absolute".to_vec(),
    993             b"a\0b".to_vec(),
    994             b"a\\b".to_vec(),
    995             vec![0xff],
    996             b"a//b".to_vec(),
    997             b"a/./b".to_vec(),
    998             b"a/../b".to_vec(),
    999             vec![b'a'; MAX_PATH_BYTES + 1],
   1000             excessive_depth.into_bytes(),
   1001         ] {
   1002             assert!(validate_relative_path(&path).is_err(), "{path:?}");
   1003         }
   1004         assert!(validate_relative_path(b"directory/").is_ok());
   1005         assert!(validate_link_target(b"a/link", b"./../target").is_ok());
   1006         assert!(validate_link_target(b"a/link", b"target//leaf").is_ok());
   1007         for target in [
   1008             b"".to_vec(),
   1009             b"/absolute".to_vec(),
   1010             b"a\0b".to_vec(),
   1011             b"a\\b".to_vec(),
   1012             vec![0xff],
   1013             vec![b'a'; MAX_PATH_BYTES + 1],
   1014             vec!["a"; MAX_DEPTH + 1].join("/").into_bytes(),
   1015         ] {
   1016             assert!(
   1017                 validate_link_target(b"a/link", &target).is_err(),
   1018                 "{target:?}"
   1019             );
   1020         }
   1021         for path in [
   1022             "layer.tar".to_owned(),
   1023             "bad/layer.tar".to_owned(),
   1024             format!("{}/other.tar", "a".repeat(64)),
   1025             format!("{}/nested/layer.tar", "a".repeat(64)),
   1026         ] {
   1027             assert!(validate_layer_name(&path).is_err());
   1028         }
   1029         assert!(validate_layer_name(&format!("{}/layer.tar", "a".repeat(64))).is_ok());
   1030     }
   1031 
   1032     #[test]
   1033     fn oci_runtime_config_binds_rootless_identity_entrypoint_and_every_label() {
   1034         let expected = OciExpectation {
   1035             service: "fixture_service",
   1036             binary_name: "fixture-service",
   1037             version: "0.1.0-alpha",
   1038             service_revision: "1111111111111111111111111111111111111111",
   1039             lib_revision: "2222222222222222222222222222222222222222",
   1040             license: AGPL_LICENSE,
   1041             contract_versions: ContractVersions {
   1042                 admin: 3,
   1043                 config: 1,
   1044                 provider: 5,
   1045                 state: 2,
   1046                 status: 4,
   1047             },
   1048         };
   1049         let entrypoint = "/nix/store/fixture/bin/fixture-service";
   1050         let original = json!({"architecture":"amd64", "os":"linux", "created":"1970-01-01T00:00:01+00:00",
   1051             "config":{"User":"65532:65532", "WorkingDir":"/", "StopSignal":"SIGTERM",
   1052             "Env":["SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"], "Entrypoint":[entrypoint], "Labels":fixture_labels(&expected)}});
   1053         assert_eq!(validate_config(&original, &expected).unwrap(), entrypoint);
   1054         for (pointer, replacement) in [
   1055             ("", json!(null)),
   1056             ("/architecture", json!("arm64")),
   1057             ("/os", json!("macos")),
   1058             ("/created", json!("now")),
   1059             ("/config", json!(null)),
   1060             ("/config/User", json!("root")),
   1061             ("/config/WorkingDir", json!("/tmp")),
   1062             ("/config/StopSignal", json!("SIGKILL")),
   1063             ("/config/Env", json!([])),
   1064             ("/config/Env", json!([null])),
   1065             ("/config/Entrypoint", json!([])),
   1066             ("/config/Entrypoint", json!([entrypoint, entrypoint])),
   1067             ("/config/Entrypoint", json!(["/bin/fixture-service"])),
   1068             (
   1069                 "/config/Entrypoint",
   1070                 json!(["/nix/store/fixture/bin/other"]),
   1071             ),
   1072             (
   1073                 "/config/Entrypoint",
   1074                 json!(["/nix/store/../bin/fixture-service"]),
   1075             ),
   1076             ("/config/Labels", json!(null)),
   1077             ("/config/Labels", json!({})),
   1078         ] {
   1079             let mut changed = original.clone();
   1080             *changed.pointer_mut(pointer).unwrap() = replacement;
   1081             assert!(validate_config(&changed, &expected).is_err(), "{pointer}");
   1082         }
   1083         for key in original["config"]["Labels"].as_object().unwrap().keys() {
   1084             let mut changed = original.clone();
   1085             changed["config"]["Labels"][key] = json!("unbound");
   1086             assert!(validate_config(&changed, &expected).is_err(), "label {key}");
   1087         }
   1088         for value in ["", "A", "0a", "a-b", "a/b"] {
   1089             assert!(!valid_identifier(value));
   1090         }
   1091         assert!(valid_identifier("service_1"));
   1092         assert!(valid_binary_name("service-1"));
   1093         for value in ["", "A", "0a", "a/b"] {
   1094             assert!(!valid_binary_name(value));
   1095         }
   1096         assert!(!valid_identifier(&"a".repeat(129)));
   1097         assert!(!valid_binary_name(&"a".repeat(129)));
   1098         assert!(!valid_absolute_path("relative"));
   1099         assert!(!valid_absolute_path(&format!(
   1100             "/{}",
   1101             "a".repeat(MAX_PATH_BYTES)
   1102         )));
   1103     }
   1104 
   1105     #[test]
   1106     fn contract_is_exact() {
   1107         let root = Path::new(env!("CARGO_MANIFEST_DIR"))
   1108             .parent()
   1109             .and_then(Path::parent)
   1110             .expect("root");
   1111         validate_contract(root).expect("artifact admission contract");
   1112     }
   1113 
   1114     #[test]
   1115     fn native_binary_and_negative_formats_are_bounded() {
   1116         let target = if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
   1117             MACOS_TARGET
   1118         } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
   1119             LINUX_TARGET
   1120         } else {
   1121             return;
   1122         };
   1123         let binary = std::env::current_exe().expect("test binary");
   1124         let bytes = fs::read(&binary).expect("test binary bytes");
   1125         admit_binary_bytes(&bytes, target).expect("native binary structure");
   1126         if bytes.len() <= MAX_BINARY_PARSE_BYTES as usize {
   1127             admit_binary_inner(&binary, target, false).expect("bounded native admission");
   1128         } else {
   1129             assert_eq!(
   1130                 admit_binary_inner(&binary, target, false),
   1131                 Err(AdmissionError::InvalidBinary)
   1132             );
   1133         }
   1134         let wrong = if target == MACOS_TARGET {
   1135             LINUX_TARGET
   1136         } else {
   1137             MACOS_TARGET
   1138         };
   1139         assert_eq!(
   1140             admit_binary_inner(&binary, wrong, false),
   1141             Err(AdmissionError::InvalidBinary)
   1142         );
   1143         let root = tempfile::tempdir().expect("tempdir");
   1144         let arbitrary = root.path().join("binary");
   1145         fs::write(&arbitrary, b"arbitrary bytes").expect("fixture");
   1146         assert_eq!(
   1147             admit_binary_inner(&arbitrary, target, false),
   1148             Err(AdmissionError::InvalidBinary)
   1149         );
   1150         assert_eq!(
   1151             validate_dynamic_libraries(&["libsqlite3.so.0".to_owned()]),
   1152             Err(AdmissionError::InvalidBinary)
   1153         );
   1154     }
   1155 
   1156     #[test]
   1157     fn archive_paths_and_agpl_labels_fail_closed() {
   1158         assert!(validate_relative_path(b"nix/store/hash/bin/service").is_ok());
   1159         assert!(validate_relative_path(b"../escape").is_err());
   1160         assert!(validate_link_target(b"nix/store/hash/lib/link", b"../target").is_ok());
   1161         assert!(validate_link_target(b"link", b"../escape").is_err());
   1162         let expected = OciExpectation {
   1163             service: "fixture_service",
   1164             binary_name: "fixture-service",
   1165             version: "0.1.0-alpha",
   1166             service_revision: "1111111111111111111111111111111111111111",
   1167             lib_revision: "2222222222222222222222222222222222222222",
   1168             license: AGPL_LICENSE,
   1169             contract_versions: ContractVersions {
   1170                 admin: 3,
   1171                 config: 1,
   1172                 provider: 5,
   1173                 state: 2,
   1174                 status: 4,
   1175             },
   1176         };
   1177         let labels = expected_labels(&expected);
   1178         assert_eq!(labels["org.opencontainers.image.licenses"], AGPL_LICENSE);
   1179         assert_eq!(labels.len(), 23);
   1180     }
   1181 }