lib

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

build_control.rs (52276B)


      1 use std::{
      2     collections::BTreeSet,
      3     fs,
      4     io::Write,
      5     path::{Component, Path, PathBuf},
      6     process::Command,
      7 };
      8 
      9 use fs2::FileExt;
     10 use serde::{Deserialize, Serialize};
     11 use sha2::{Digest, Sha256};
     12 
     13 use crate::build_output::is_build_output_directory;
     14 
     15 const SOURCE_LOCK_NAME: &str = "radroots.lib.source-lock.v1.toml";
     16 const CONSUMER_MARKER: &str = ".radroots-consumer-root";
     17 const CATALOG_RELATIVE: &str = "contracts/crates/catalog.v2.toml";
     18 const REPOSITORY: &str = "https://github.com/radrootslabs/lib";
     19 const ARCHITECTURE: &str = "radroots.crates.release.v2";
     20 const VERSION: &str = "0.1.0-alpha";
     21 
     22 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     23 pub enum Operation {
     24     Check,
     25     Test,
     26     Clippy,
     27 }
     28 
     29 impl Operation {
     30     pub fn cargo_subcommand(self) -> &'static str {
     31         match self {
     32             Self::Check => "check",
     33             Self::Test => "test",
     34             Self::Clippy => "clippy",
     35         }
     36     }
     37 }
     38 
     39 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     40 pub enum Mode {
     41     Check,
     42     Write,
     43 }
     44 
     45 #[derive(Debug, Deserialize)]
     46 #[serde(deny_unknown_fields)]
     47 struct PackageGroups {
     48     schema: String,
     49     catalog_sha256: String,
     50     group: Vec<PackageGroup>,
     51 }
     52 
     53 #[derive(Debug, Deserialize)]
     54 #[serde(deny_unknown_fields)]
     55 struct PackageGroup {
     56     id: String,
     57     packages: Vec<String>,
     58     active_packages: Vec<String>,
     59     reserved_packages: Vec<String>,
     60 }
     61 
     62 #[derive(Clone, Debug, Deserialize, Serialize)]
     63 #[serde(deny_unknown_fields)]
     64 pub struct SourceLock {
     65     pub schema: String,
     66     pub repository: String,
     67     pub revision: String,
     68     pub architecture: String,
     69     pub workspace_catalog_sha256: String,
     70     pub version: String,
     71     pub source_archive_sha256: Option<String>,
     72     #[serde(default = "default_consumer_lockfile")]
     73     pub lockfile: String,
     74     pub lockfile_sha256: String,
     75 }
     76 
     77 fn default_consumer_lockfile() -> String {
     78     "Cargo.lock".to_owned()
     79 }
     80 
     81 #[derive(Clone, Debug)]
     82 pub struct ConsumerRoot {
     83     path: PathBuf,
     84     product: String,
     85     source_lock: SourceLock,
     86 }
     87 
     88 impl ConsumerRoot {
     89     pub fn open(path: &Path) -> Result<Self, String> {
     90         require_absolute_real_directory(path, "consumer root")?;
     91         let canonical = fs::canonicalize(path)
     92             .map_err(|error| format!("canonicalize consumer root {}: {error}", path.display()))?;
     93         let marker = read_regular_no_follow(&canonical.join(CONSUMER_MARKER))?;
     94         let product = String::from_utf8(marker)
     95             .map_err(|error| format!("consumer marker is not UTF-8: {error}"))?
     96             .trim()
     97             .to_owned();
     98         if !matches!(product.as_str(), "sdk" | "myc" | "rhi") {
     99             return Err("consumer marker must contain sdk, myc, or rhi".to_owned());
    100         }
    101         let source_lock_path = canonical.join(SOURCE_LOCK_NAME);
    102         let source_lock = parse_source_lock(&source_lock_path)?;
    103         validate_source_lock(&source_lock)?;
    104         validate_consumer_files(&canonical, &source_lock)?;
    105         Ok(Self {
    106             path: canonical,
    107             product,
    108             source_lock,
    109         })
    110     }
    111 
    112     fn output(&self, relative: &Path) -> Result<PathBuf, String> {
    113         validate_relative_path(relative, "artifact output")?;
    114         let output = self.path.join(relative);
    115         ensure_no_symlink_components(&self.path, relative)?;
    116         Ok(output)
    117     }
    118 }
    119 
    120 #[derive(Debug, Deserialize, Serialize)]
    121 #[serde(deny_unknown_fields)]
    122 struct CacheManifest {
    123     schema: String,
    124     repository: String,
    125     revision: String,
    126     workspace_catalog_sha256: String,
    127     source_archive_sha256: Option<String>,
    128     tree: String,
    129 }
    130 
    131 #[derive(Debug, Deserialize)]
    132 struct CargoLock {
    133     #[serde(default)]
    134     package: Vec<CargoLockPackage>,
    135 }
    136 
    137 #[derive(Debug, Deserialize)]
    138 struct CargoLockPackage {
    139     source: Option<String>,
    140 }
    141 
    142 #[derive(Debug, Serialize)]
    143 struct ArtifactManifest<'a> {
    144     schema: &'static str,
    145     product: &'a str,
    146     target: &'a str,
    147     language: &'a str,
    148     external_names: Vec<&'a str>,
    149     files: Vec<ArtifactFile>,
    150     provenance: Provenance<'a>,
    151 }
    152 
    153 #[derive(Debug, Serialize)]
    154 struct ArtifactFile {
    155     path: String,
    156     bytes: u64,
    157     sha256: String,
    158 }
    159 
    160 #[derive(Debug, Serialize)]
    161 struct Provenance<'a> {
    162     repository: &'a str,
    163     revision: &'a str,
    164     architecture: &'a str,
    165     catalog_sha256: &'a str,
    166     lockfile_sha256: &'a str,
    167     source_archive_sha256: Option<&'a str>,
    168     source_date_epoch: u64,
    169     builder_id: &'a str,
    170     features: Vec<&'a str>,
    171 }
    172 
    173 pub fn group_plan(
    174     workspace_root: &Path,
    175     group: &str,
    176     operation: Operation,
    177     include_reserved: bool,
    178 ) -> Result<Vec<String>, String> {
    179     validate_identifier(group, "group")?;
    180     let projection_path = workspace_root.join("contracts/crates/generated/package_groups.v1.toml");
    181     let bytes = read_regular_no_follow(&projection_path)?;
    182     let raw = std::str::from_utf8(&bytes)
    183         .map_err(|error| format!("package group projection is not UTF-8: {error}"))?;
    184     let projection = toml::from_str::<PackageGroups>(raw)
    185         .map_err(|error| format!("parse package group projection: {error}"))?;
    186     if projection.schema != "radroots.workspace.package-groups.v1" {
    187         return Err("package group projection schema drifted".to_owned());
    188     }
    189     validate_sha256(&projection.catalog_sha256, "catalog digest")?;
    190     let catalog = read_regular_no_follow(&workspace_root.join(CATALOG_RELATIVE))?;
    191     if sha256(&catalog) != projection.catalog_sha256 {
    192         return Err("package group projection is stale".to_owned());
    193     }
    194     let selected = projection
    195         .group
    196         .iter()
    197         .find(|candidate| candidate.id == group)
    198         .ok_or_else(|| format!("unknown catalog group {group}"))?;
    199     let expected = selected
    200         .active_packages
    201         .iter()
    202         .chain(selected.reserved_packages.iter())
    203         .cloned()
    204         .collect::<BTreeSet<_>>();
    205     if selected.packages.iter().cloned().collect::<BTreeSet<_>>() != expected
    206         || selected.active_packages.is_empty()
    207     {
    208         return Err(format!("catalog group {group} is malformed or not active"));
    209     }
    210     let packages = if include_reserved {
    211         &selected.packages
    212     } else {
    213         &selected.active_packages
    214     };
    215     let mut plan = vec![
    216         operation.cargo_subcommand().to_owned(),
    217         "--locked".to_owned(),
    218     ];
    219     if operation != Operation::Test {
    220         plan.push("--all-targets".to_owned());
    221     }
    222     for package in packages {
    223         plan.push("-p".to_owned());
    224         plan.push(package.clone());
    225     }
    226     if operation == Operation::Clippy {
    227         plan.push("--".to_owned());
    228         plan.push("-D".to_owned());
    229         plan.push("warnings".to_owned());
    230     }
    231     Ok(plan)
    232 }
    233 
    234 pub fn execute_group_plan(workspace_root: &Path, plan: &[String]) -> Result<(), String> {
    235     let output = Command::new("cargo")
    236         .args(plan)
    237         .current_dir(workspace_root)
    238         .status()
    239         .map_err(|error| format!("run catalog group plan: {error}"))?;
    240     if output.success() {
    241         Ok(())
    242     } else {
    243         Err(format!("catalog group plan failed with {output}"))
    244     }
    245 }
    246 
    247 pub fn print_plan(plan: &[String]) {
    248     println!("cargo {}", plan.join(" "));
    249 }
    250 
    251 pub fn validate_consumer(path: &Path) -> Result<ConsumerRoot, String> {
    252     ConsumerRoot::open(path)
    253 }
    254 
    255 pub fn materialize(
    256     consumer_path: &Path,
    257     cache_root: &Path,
    258     offline: bool,
    259 ) -> Result<PathBuf, String> {
    260     let consumer = ConsumerRoot::open(consumer_path)?;
    261     materialize_from(
    262         &consumer,
    263         cache_root,
    264         offline,
    265         &consumer.source_lock.repository,
    266     )
    267 }
    268 
    269 fn materialize_from(
    270     consumer: &ConsumerRoot,
    271     cache_root: &Path,
    272     offline: bool,
    273     fetch_url: &str,
    274 ) -> Result<PathBuf, String> {
    275     require_absolute_real_directory(cache_root, "cache root")?;
    276     let key = format!(
    277         "{}-{}",
    278         consumer.source_lock.revision, consumer.source_lock.workspace_catalog_sha256
    279     );
    280     let destination = cache_root.join(key);
    281     let lock_path = cache_root.join(".radroots-source-cache.lock");
    282     let lock = fs::OpenOptions::new()
    283         .create(true)
    284         .truncate(false)
    285         .read(true)
    286         .write(true)
    287         .open(&lock_path)
    288         .map_err(|error| format!("open source cache lock: {error}"))?;
    289     lock.lock_exclusive()
    290         .map_err(|error| format!("lock source cache: {error}"))?;
    291     let result = if destination.exists() {
    292         verify_cache(&destination, &consumer.source_lock).map(|()| destination.clone())
    293     } else if offline {
    294         Err("offline source materialization requires a verified cache entry".to_owned())
    295     } else {
    296         prefetch_cache(cache_root, &destination, &consumer.source_lock, fetch_url)?;
    297         verify_cache(&destination, &consumer.source_lock)?;
    298         Ok(destination.clone())
    299     };
    300     FileExt::unlock(&lock).map_err(|error| format!("unlock source cache: {error}"))?;
    301     result
    302 }
    303 
    304 fn prefetch_cache(
    305     cache_root: &Path,
    306     destination: &Path,
    307     source_lock: &SourceLock,
    308     fetch_url: &str,
    309 ) -> Result<(), String> {
    310     let staging = tempfile::Builder::new()
    311         .prefix(".radroots-source-stage-")
    312         .tempdir_in(cache_root)
    313         .map_err(|error| format!("create source cache staging directory: {error}"))?;
    314     git(staging.path(), &["init", "--quiet"])?;
    315     git(staging.path(), &["remote", "add", "origin", fetch_url])?;
    316     git(
    317         staging.path(),
    318         &[
    319             "fetch",
    320             "--quiet",
    321             "--depth=1",
    322             "origin",
    323             &source_lock.revision,
    324         ],
    325     )?;
    326     git(
    327         staging.path(),
    328         &["checkout", "--quiet", "--detach", "FETCH_HEAD"],
    329     )?;
    330     let tree = git_stdout(staging.path(), &["rev-parse", "HEAD^{tree}"])?;
    331     let manifest = CacheManifest {
    332         schema: "radroots.source-cache.v1".to_owned(),
    333         repository: source_lock.repository.clone(),
    334         revision: source_lock.revision.clone(),
    335         workspace_catalog_sha256: source_lock.workspace_catalog_sha256.clone(),
    336         source_archive_sha256: source_lock.source_archive_sha256.clone(),
    337         tree: tree.trim().to_owned(),
    338     };
    339     let raw = toml::to_string(&manifest)
    340         .map_err(|error| format!("serialize source cache manifest: {error}"))?;
    341     atomic_write(
    342         &staging.path().join(".radroots-source-cache.v1.toml"),
    343         raw.as_bytes(),
    344     )?;
    345     let staging_path = staging.keep();
    346     fs::rename(&staging_path, destination).map_err(|error| {
    347         format!(
    348             "install source cache {} -> {}: {error}",
    349             staging_path.display(),
    350             destination.display()
    351         )
    352     })?;
    353     set_readonly_tree(destination)
    354 }
    355 
    356 fn verify_cache(path: &Path, source_lock: &SourceLock) -> Result<(), String> {
    357     require_absolute_real_directory(path, "cache entry")?;
    358     let head = git_stdout(path, &["rev-parse", "HEAD"])?;
    359     let tree = git_stdout(path, &["rev-parse", "HEAD^{tree}"])?;
    360     if head.trim() != source_lock.revision {
    361         return Err("source cache revision drifted".to_owned());
    362     }
    363     git(path, &["diff", "--quiet", "--no-ext-diff"])?;
    364     git(path, &["diff", "--cached", "--quiet", "--no-ext-diff"])?;
    365     verify_untracked_cache_paths(path)?;
    366     let manifest_path = path.join(".radroots-source-cache.v1.toml");
    367     let bytes = read_regular_no_follow(&manifest_path)?;
    368     let raw = std::str::from_utf8(&bytes)
    369         .map_err(|error| format!("source cache manifest is not UTF-8: {error}"))?;
    370     let manifest = toml::from_str::<CacheManifest>(raw)
    371         .map_err(|error| format!("parse source cache manifest: {error}"))?;
    372     if manifest.schema != "radroots.source-cache.v1"
    373         || manifest.repository != source_lock.repository
    374         || manifest.revision != source_lock.revision
    375         || manifest.workspace_catalog_sha256 != source_lock.workspace_catalog_sha256
    376         || manifest.source_archive_sha256 != source_lock.source_archive_sha256
    377         || manifest.tree != tree.trim()
    378     {
    379         return Err("source cache manifest drifted".to_owned());
    380     }
    381     let catalog = read_regular_no_follow(&path.join(CATALOG_RELATIVE))?;
    382     if sha256(&catalog) != source_lock.workspace_catalog_sha256 {
    383         return Err("source cache catalog digest drifted".to_owned());
    384     }
    385     Ok(())
    386 }
    387 
    388 pub fn verify_source_archive(path: &Path, expected_sha256: &str) -> Result<(), String> {
    389     if !path.is_absolute() {
    390         return Err("source archive path must be absolute".to_owned());
    391     }
    392     validate_sha256(expected_sha256, "source archive digest")?;
    393     let bytes = read_regular_no_follow(path)?;
    394     if sha256(&bytes) != expected_sha256 {
    395         return Err("source archive digest drifted".to_owned());
    396     }
    397     verify_bundle(path)
    398 }
    399 
    400 pub fn create_source_archive(
    401     source_root: &Path,
    402     revision: &str,
    403     output_path: &Path,
    404 ) -> Result<String, String> {
    405     require_absolute_real_directory(source_root, "archive source root")?;
    406     validate_oid(revision, "archive revision")?;
    407     if !output_path.is_absolute() {
    408         return Err("source archive output must be absolute".to_owned());
    409     }
    410     git(
    411         source_root,
    412         &["cat-file", "-e", &format!("{revision}^{{commit}}")],
    413     )?;
    414     let parent = output_path
    415         .parent()
    416         .ok_or_else(|| "source archive output has no parent".to_owned())?;
    417     create_directories_no_follow(parent)?;
    418     if let Ok(metadata) = fs::symlink_metadata(output_path)
    419         && (metadata.file_type().is_symlink() || !metadata.is_file())
    420     {
    421         return Err("source archive output must be a regular file".to_owned());
    422     }
    423     let temporary = tempfile::Builder::new()
    424         .prefix(".radroots-source-archive-")
    425         .tempfile_in(parent)
    426         .map_err(|error| format!("stage source archive: {error}"))?;
    427     let temporary_path = temporary.path().to_path_buf();
    428     temporary
    429         .close()
    430         .map_err(|error| format!("prepare source archive staging path: {error}"))?;
    431     let archive_repo = tempfile::TempDir::new_in(parent)
    432         .map_err(|error| format!("create archive staging repository: {error}"))?;
    433     git(archive_repo.path(), &["init", "--bare", "--quiet"])?;
    434     let fetch = Command::new("git")
    435         .args(["fetch", "--quiet", "--no-tags"])
    436         .arg(source_root)
    437         .arg(format!("{revision}:refs/heads/archive"))
    438         .current_dir(archive_repo.path())
    439         .output()
    440         .map_err(|error| format!("stage archive revision: {error}"))?;
    441     if !fetch.status.success() {
    442         return Err(format!(
    443             "stage archive revision failed: {}",
    444             String::from_utf8_lossy(&fetch.stderr).trim()
    445         ));
    446     }
    447     let output = Command::new("git")
    448         .args(["bundle", "create"])
    449         .arg(&temporary_path)
    450         .arg("refs/heads/archive")
    451         .current_dir(archive_repo.path())
    452         .output()
    453         .map_err(|error| format!("create source archive: {error}"))?;
    454     if !output.status.success() {
    455         let _ = fs::remove_file(&temporary_path);
    456         return Err(format!(
    457             "create source archive failed: {}",
    458             String::from_utf8_lossy(&output.stderr).trim()
    459         ));
    460     }
    461     let bytes = read_regular_no_follow(&temporary_path)?;
    462     let digest = sha256(&bytes);
    463     verify_bundle(&temporary_path)?;
    464     let listed = Command::new("git")
    465         .args(["bundle", "list-heads"])
    466         .arg(&temporary_path)
    467         .output()
    468         .map_err(|error| format!("list source archive heads: {error}"))?;
    469     if !listed.status.success()
    470         || String::from_utf8_lossy(&listed.stdout).trim()
    471             != format!("{revision} refs/heads/archive")
    472     {
    473         let _ = fs::remove_file(&temporary_path);
    474         return Err("source archive does not contain exactly the requested revision".to_owned());
    475     }
    476     if output_path.exists() {
    477         let existing = read_regular_no_follow(output_path)?;
    478         let _ = fs::remove_file(&temporary_path);
    479         if existing == bytes {
    480             return Ok(digest);
    481         }
    482         return Err(
    483             "immutable source archive output already exists with different bytes".to_owned(),
    484         );
    485     }
    486     fs::File::open(&temporary_path)
    487         .and_then(|file| file.sync_all())
    488         .map_err(|error| format!("sync source archive: {error}"))?;
    489     fs::rename(&temporary_path, output_path)
    490         .map_err(|error| format!("install source archive: {error}"))?;
    491     Ok(digest)
    492 }
    493 
    494 fn verify_bundle(path: &Path) -> Result<(), String> {
    495     let verification_repo = tempfile::TempDir::new()
    496         .map_err(|error| format!("create bundle verification repository: {error}"))?;
    497     git(verification_repo.path(), &["init", "--bare", "--quiet"])?;
    498     let output = Command::new("git")
    499         .args(["bundle", "verify"])
    500         .arg(path)
    501         .current_dir(verification_repo.path())
    502         .output()
    503         .map_err(|error| format!("run git bundle verify: {error}"))?;
    504     if output.status.success() {
    505         Ok(())
    506     } else {
    507         Err(format!(
    508             "source archive is not a valid Git bundle: {}",
    509             String::from_utf8_lossy(&output.stderr).trim()
    510         ))
    511     }
    512 }
    513 
    514 #[allow(clippy::too_many_arguments)]
    515 pub fn artifact(
    516     product: &str,
    517     target: &str,
    518     language: &str,
    519     mode: Mode,
    520     consumer_path: &Path,
    521     source_path: &Path,
    522     output_relative: &Path,
    523     source_date_epoch: u64,
    524     builder_id: &str,
    525     features: &[String],
    526 ) -> Result<(), String> {
    527     validate_identifier(product, "product")?;
    528     validate_identifier(target, "target")?;
    529     validate_identifier(language, "language")?;
    530     validate_identifier(builder_id, "builder id")?;
    531     validate_generation_roots(product, target, language, consumer_path, source_path)?;
    532     let consumer = ConsumerRoot::open(consumer_path)?;
    533     let mut sorted_features = features.iter().map(String::as_str).collect::<Vec<_>>();
    534     sorted_features.sort_unstable();
    535     sorted_features.dedup();
    536     for feature in &sorted_features {
    537         validate_identifier(feature, "feature")?;
    538     }
    539     let external_names = match product {
    540         "sdk" => vec!["radroots", "radroots_sdk"],
    541         _ => return Err("unsupported artifact product".to_owned()),
    542     };
    543     let manifest = ArtifactManifest {
    544         schema: "radroots.artifact-manifest.v1",
    545         product,
    546         target,
    547         language,
    548         external_names,
    549         files: Vec::new(),
    550         provenance: Provenance {
    551             repository: &consumer.source_lock.repository,
    552             revision: &consumer.source_lock.revision,
    553             architecture: &consumer.source_lock.architecture,
    554             catalog_sha256: &consumer.source_lock.workspace_catalog_sha256,
    555             lockfile_sha256: &consumer.source_lock.lockfile_sha256,
    556             source_archive_sha256: consumer.source_lock.source_archive_sha256.as_deref(),
    557             source_date_epoch,
    558             builder_id,
    559             features: sorted_features,
    560         },
    561     };
    562     let mut bytes = serde_json::to_vec_pretty(&manifest)
    563         .map_err(|error| format!("serialize artifact manifest: {error}"))?;
    564     bytes.push(b'\n');
    565     let output = consumer.output(output_relative)?;
    566     match mode {
    567         Mode::Check => {
    568             let current = read_regular_no_follow(&output)?;
    569             if current == bytes {
    570                 Ok(())
    571             } else {
    572                 Err(format!("artifact manifest {} is stale", output.display()))
    573             }
    574         }
    575         Mode::Write => atomic_write(&output, &bytes),
    576     }
    577 }
    578 
    579 pub fn validate_generation_roots(
    580     product: &str,
    581     target: &str,
    582     language: &str,
    583     consumer_path: &Path,
    584     source_path: &Path,
    585 ) -> Result<(), String> {
    586     validate_identifier(product, "product")?;
    587     validate_identifier(target, "target")?;
    588     validate_identifier(language, "language")?;
    589     validate_artifact_route(product, target, language)?;
    590     let consumer = ConsumerRoot::open(consumer_path)?;
    591     if consumer.product != product {
    592         return Err(format!(
    593             "consumer marker {} does not match artifact product {product}",
    594             consumer.product
    595         ));
    596     }
    597     verify_source_root(source_path, &consumer.source_lock)
    598 }
    599 
    600 fn validate_artifact_route(product: &str, target: &str, language: &str) -> Result<(), String> {
    601     let valid = match product {
    602         "sdk" => matches!(
    603             (target, language),
    604             ("typescript", "typescript")
    605                 | ("wasm", "javascript")
    606                 | ("ffi", "swift")
    607                 | ("ffi", "kotlin")
    608         ),
    609         _ => false,
    610     };
    611     if valid {
    612         Ok(())
    613     } else {
    614         Err(format!(
    615             "unsupported artifact route {product}/{target}/{language}"
    616         ))
    617     }
    618 }
    619 
    620 fn verify_source_root(path: &Path, source_lock: &SourceLock) -> Result<(), String> {
    621     require_absolute_real_directory(path, "source root")?;
    622     if git_stdout(path, &["rev-parse", "HEAD"])?.trim() != source_lock.revision {
    623         return Err("source root revision does not match source lock".to_owned());
    624     }
    625     let catalog = read_regular_no_follow(&path.join(CATALOG_RELATIVE))?;
    626     if sha256(&catalog) != source_lock.workspace_catalog_sha256 {
    627         return Err("source root catalog does not match source lock".to_owned());
    628     }
    629     git(path, &["diff", "--quiet", "--no-ext-diff"])?;
    630     git(path, &["diff", "--cached", "--quiet", "--no-ext-diff"])?;
    631     verify_untracked_cache_paths(path)
    632 }
    633 
    634 fn verify_untracked_cache_paths(path: &Path) -> Result<(), String> {
    635     let status = git_stdout(path, &["status", "--porcelain", "--untracked-files=all"])?;
    636     for line in status.lines() {
    637         if line != "?? .radroots-source-cache.v1.toml" {
    638             return Err(format!("source tree contains ungoverned change {line}"));
    639         }
    640     }
    641     Ok(())
    642 }
    643 
    644 fn parse_source_lock(path: &Path) -> Result<SourceLock, String> {
    645     let bytes = read_regular_no_follow(path)?;
    646     let raw = std::str::from_utf8(&bytes)
    647         .map_err(|error| format!("source lock is not UTF-8: {error}"))?;
    648     toml::from_str(raw).map_err(|error| format!("parse source lock {}: {error}", path.display()))
    649 }
    650 
    651 fn validate_source_lock(source_lock: &SourceLock) -> Result<(), String> {
    652     if source_lock.schema != "radroots.lib.source-lock.v1"
    653         || source_lock.repository != REPOSITORY
    654         || source_lock.architecture != ARCHITECTURE
    655         || source_lock.version != VERSION
    656     {
    657         return Err(
    658             "source lock identity, repository, architecture, or version drifted".to_owned(),
    659         );
    660     }
    661     validate_oid(&source_lock.revision, "source lock revision")?;
    662     validate_sha256(
    663         &source_lock.workspace_catalog_sha256,
    664         "source lock catalog digest",
    665     )?;
    666     validate_sha256(&source_lock.lockfile_sha256, "source lock lockfile digest")?;
    667     validate_relative_path(Path::new(&source_lock.lockfile), "source lock lockfile")?;
    668     if let Some(digest) = &source_lock.source_archive_sha256 {
    669         validate_sha256(digest, "source lock archive digest")?;
    670     }
    671     Ok(())
    672 }
    673 
    674 fn validate_consumer_files(root: &Path, source_lock: &SourceLock) -> Result<(), String> {
    675     let lockfile_relative = Path::new(&source_lock.lockfile);
    676     ensure_no_symlink_components(root, lockfile_relative)?;
    677     let lockfile = root.join(lockfile_relative);
    678     let lockfile_bytes = read_regular_no_follow(&lockfile)?;
    679     if sha256(&lockfile_bytes) != source_lock.lockfile_sha256 {
    680         return Err("consumer Cargo.lock digest drifted".to_owned());
    681     }
    682     let lockfile_raw = std::str::from_utf8(&lockfile_bytes)
    683         .map_err(|error| format!("consumer Cargo.lock is not UTF-8: {error}"))?;
    684     let cargo_lock = toml::from_str::<CargoLock>(lockfile_raw)
    685         .map_err(|error| format!("parse consumer Cargo.lock: {error}"))?;
    686     let expected_source = format!(
    687         "git+{}?rev={}#{}",
    688         source_lock.repository, source_lock.revision, source_lock.revision
    689     );
    690     let mut lock_source_count = 0_usize;
    691     for source in cargo_lock
    692         .package
    693         .iter()
    694         .filter_map(|package| package.source.as_deref())
    695         .filter(|source| source.contains("radrootslabs/lib"))
    696     {
    697         lock_source_count += 1;
    698         if source != expected_source {
    699             return Err("consumer Cargo.lock contains a mixed or floating lib source".to_owned());
    700         }
    701     }
    702     if lock_source_count == 0 {
    703         return Err("consumer Cargo.lock contains no canonical lib source".to_owned());
    704     }
    705     let mut manifests = Vec::new();
    706     collect_manifests(root, root, &mut manifests)?;
    707     if manifests.is_empty() {
    708         return Err("consumer root contains no Cargo manifest".to_owned());
    709     }
    710     let mut dependency_count = 0_usize;
    711     for manifest in manifests {
    712         let bytes = read_regular_no_follow(&manifest)?;
    713         let raw = std::str::from_utf8(&bytes)
    714             .map_err(|error| format!("consumer manifest is not UTF-8: {error}"))?;
    715         let value = toml::from_str::<toml::Value>(raw)
    716             .map_err(|error| format!("parse consumer manifest {}: {error}", manifest.display()))?;
    717         validate_manifest_value(&value, source_lock, &mut dependency_count)?;
    718     }
    719     if dependency_count == 0 {
    720         return Err("consumer manifests contain no canonical lib dependency".to_owned());
    721     }
    722     Ok(())
    723 }
    724 
    725 fn collect_manifests(root: &Path, current: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
    726     for entry in fs::read_dir(current)
    727         .map_err(|error| format!("read consumer directory {}: {error}", current.display()))?
    728     {
    729         let entry = entry.map_err(|error| format!("read consumer directory entry: {error}"))?;
    730         let file_type = entry
    731             .file_type()
    732             .map_err(|error| format!("inspect {}: {error}", entry.path().display()))?;
    733         if file_type.is_symlink() {
    734             return Err(format!(
    735                 "consumer tree contains symlink {}",
    736                 entry.path().display()
    737             ));
    738         }
    739         let name = entry.file_name();
    740         if file_type.is_dir() {
    741             if matches!(name.to_str(), Some(".git" | ".radroots"))
    742                 || is_build_output_directory(name.as_os_str())
    743             {
    744                 continue;
    745             }
    746             collect_manifests(root, &entry.path(), output)?;
    747         } else if name == "Cargo.toml" {
    748             entry
    749                 .path()
    750                 .strip_prefix(root)
    751                 .map_err(|_| "consumer manifest escaped root".to_owned())?;
    752             output.push(entry.path());
    753         }
    754     }
    755     output.sort();
    756     Ok(())
    757 }
    758 
    759 fn validate_manifest_value(
    760     value: &toml::Value,
    761     source_lock: &SourceLock,
    762     dependency_count: &mut usize,
    763 ) -> Result<(), String> {
    764     match value {
    765         toml::Value::Table(table) => {
    766             let structured_lib_url = table
    767                 .get("git")
    768                 .and_then(toml::Value::as_str)
    769                 .filter(|git| git.contains("radrootslabs/lib"));
    770             if let Some(git) = structured_lib_url {
    771                 *dependency_count += 1;
    772                 if git != source_lock.repository
    773                     || table.get("rev").and_then(toml::Value::as_str)
    774                         != Some(source_lock.revision.as_str())
    775                     || table.get("version").and_then(toml::Value::as_str) != Some("=0.1.0-alpha")
    776                     || table.contains_key("branch")
    777                     || table.contains_key("tag")
    778                     || table.contains_key("path")
    779                 {
    780                     return Err(
    781                         "consumer manifest contains a mixed or floating lib dependency".to_owned(),
    782                     );
    783                 }
    784             }
    785             for (key, child) in table {
    786                 if structured_lib_url.is_some() && key == "git" {
    787                     continue;
    788                 }
    789                 if key == "patch" && format!("{child:?}").contains("radrootslabs/lib") {
    790                     return Err("consumer manifest contains a lib patch override".to_owned());
    791                 }
    792                 validate_manifest_value(child, source_lock, dependency_count)?;
    793             }
    794         }
    795         toml::Value::Array(values) => {
    796             for child in values {
    797                 validate_manifest_value(child, source_lock, dependency_count)?;
    798             }
    799         }
    800         toml::Value::String(value) if value.contains("radrootslabs/lib") => {
    801             return Err("consumer manifest contains an unstructured lib source".to_owned());
    802         }
    803         _ => {}
    804     }
    805     Ok(())
    806 }
    807 
    808 pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
    809     if bytes.contains(&b'\r') || !bytes.ends_with(b"\n") {
    810         return Err("generated text must use LF and end with one newline".to_owned());
    811     }
    812     atomic_write_bytes(path, bytes)
    813 }
    814 
    815 pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> {
    816     let parent = path
    817         .parent()
    818         .ok_or_else(|| format!("output {} has no parent", path.display()))?;
    819     create_directories_no_follow(parent)?;
    820     if let Ok(metadata) = fs::symlink_metadata(path) {
    821         if metadata.file_type().is_symlink() || !metadata.is_file() {
    822             return Err(format!("output {} must be a regular file", path.display()));
    823         }
    824         if fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))? == bytes {
    825             return Ok(());
    826         }
    827     }
    828     let mut temporary = tempfile::NamedTempFile::new_in(parent)
    829         .map_err(|error| format!("stage output in {}: {error}", parent.display()))?;
    830     temporary
    831         .write_all(bytes)
    832         .map_err(|error| format!("stage output {}: {error}", path.display()))?;
    833     temporary
    834         .as_file()
    835         .sync_all()
    836         .map_err(|error| format!("sync staged output {}: {error}", path.display()))?;
    837     temporary
    838         .persist(path)
    839         .map_err(|error| format!("replace output {}: {}", path.display(), error.error))?;
    840     Ok(())
    841 }
    842 
    843 fn create_directories_no_follow(path: &Path) -> Result<(), String> {
    844     let mut current = PathBuf::new();
    845     for component in path.components() {
    846         current.push(component.as_os_str());
    847         match fs::symlink_metadata(&current) {
    848             Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
    849                 return Err(format!(
    850                     "output parent {} is not a real directory",
    851                     current.display()
    852                 ));
    853             }
    854             Ok(_) => {}
    855             Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
    856                 fs::create_dir(&current).map_err(|error| {
    857                     format!("create output directory {}: {error}", current.display())
    858                 })?;
    859             }
    860             Err(error) => {
    861                 return Err(format!(
    862                     "inspect output directory {}: {error}",
    863                     current.display()
    864                 ));
    865             }
    866         }
    867     }
    868     Ok(())
    869 }
    870 
    871 fn ensure_no_symlink_components(root: &Path, relative: &Path) -> Result<(), String> {
    872     let mut current = root.to_path_buf();
    873     for component in relative.components() {
    874         current.push(component.as_os_str());
    875         match fs::symlink_metadata(&current) {
    876             Ok(metadata) if metadata.file_type().is_symlink() => {
    877                 return Err(format!(
    878                     "artifact path contains symlink {}",
    879                     current.display()
    880                 ));
    881             }
    882             Ok(_) => {}
    883             Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
    884             Err(error) => {
    885                 return Err(format!(
    886                     "inspect artifact path {}: {error}",
    887                     current.display()
    888                 ));
    889             }
    890         }
    891     }
    892     Ok(())
    893 }
    894 
    895 fn read_regular_no_follow(path: &Path) -> Result<Vec<u8>, String> {
    896     let metadata = fs::symlink_metadata(path)
    897         .map_err(|error| format!("inspect {}: {error}", path.display()))?;
    898     if metadata.file_type().is_symlink() || !metadata.is_file() {
    899         return Err(format!(
    900             "{} must be a regular non-symlink file",
    901             path.display()
    902         ));
    903     }
    904     fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))
    905 }
    906 
    907 fn require_absolute_real_directory(path: &Path, label: &str) -> Result<(), String> {
    908     if !path.is_absolute() {
    909         return Err(format!("{label} must be absolute"));
    910     }
    911     let metadata = fs::symlink_metadata(path)
    912         .map_err(|error| format!("inspect {label} {}: {error}", path.display()))?;
    913     if metadata.file_type().is_symlink() || !metadata.is_dir() {
    914         return Err(format!("{label} must be a real directory"));
    915     }
    916     Ok(())
    917 }
    918 
    919 fn validate_relative_path(path: &Path, label: &str) -> Result<(), String> {
    920     if path.as_os_str().is_empty()
    921         || path.is_absolute()
    922         || path
    923             .components()
    924             .any(|component| !matches!(component, Component::Normal(_)))
    925     {
    926         return Err(format!("{label} must be a safe relative path"));
    927     }
    928     Ok(())
    929 }
    930 
    931 fn validate_identifier(value: &str, label: &str) -> Result<(), String> {
    932     if value.is_empty()
    933         || !value.bytes().all(|byte| {
    934             byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'/')
    935         })
    936     {
    937         return Err(format!("{label} contains unsupported characters"));
    938     }
    939     Ok(())
    940 }
    941 
    942 fn validate_oid(value: &str, label: &str) -> Result<(), String> {
    943     if value.len() != 40
    944         || !value
    945             .bytes()
    946             .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
    947     {
    948         return Err(format!("{label} must be lowercase full 40-hex"));
    949     }
    950     Ok(())
    951 }
    952 
    953 fn validate_sha256(value: &str, label: &str) -> Result<(), String> {
    954     if value.len() != 64
    955         || !value
    956             .bytes()
    957             .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
    958     {
    959         return Err(format!("{label} must be lowercase 64-hex"));
    960     }
    961     Ok(())
    962 }
    963 
    964 fn sha256(bytes: &[u8]) -> String {
    965     format!("{:x}", Sha256::digest(bytes))
    966 }
    967 
    968 fn git(root: &Path, args: &[&str]) -> Result<(), String> {
    969     let output = Command::new("git")
    970         .args(args)
    971         .current_dir(root)
    972         .output()
    973         .map_err(|error| format!("run git {}: {error}", args.join(" ")))?;
    974     if output.status.success() {
    975         Ok(())
    976     } else {
    977         Err(format!(
    978             "git {} failed: {}",
    979             args.join(" "),
    980             String::from_utf8_lossy(&output.stderr).trim()
    981         ))
    982     }
    983 }
    984 
    985 fn git_stdout(root: &Path, args: &[&str]) -> Result<String, String> {
    986     let output = Command::new("git")
    987         .args(args)
    988         .current_dir(root)
    989         .output()
    990         .map_err(|error| format!("run git {}: {error}", args.join(" ")))?;
    991     if !output.status.success() {
    992         return Err(format!(
    993             "git {} failed: {}",
    994             args.join(" "),
    995             String::from_utf8_lossy(&output.stderr).trim()
    996         ));
    997     }
    998     String::from_utf8(output.stdout)
    999         .map_err(|error| format!("git {} output is not UTF-8: {error}", args.join(" ")))
   1000 }
   1001 
   1002 fn set_readonly_tree(root: &Path) -> Result<(), String> {
   1003     for entry in walk(root)? {
   1004         let metadata = fs::symlink_metadata(&entry)
   1005             .map_err(|error| format!("inspect cache path {}: {error}", entry.display()))?;
   1006         if metadata.file_type().is_symlink() {
   1007             return Err(format!("source cache contains symlink {}", entry.display()));
   1008         }
   1009         let mut permissions = metadata.permissions();
   1010         permissions.set_readonly(true);
   1011         fs::set_permissions(&entry, permissions)
   1012             .map_err(|error| format!("protect cache path {}: {error}", entry.display()))?;
   1013     }
   1014     Ok(())
   1015 }
   1016 
   1017 fn walk(root: &Path) -> Result<Vec<PathBuf>, String> {
   1018     let mut pending = vec![root.to_path_buf()];
   1019     let mut output = Vec::new();
   1020     while let Some(path) = pending.pop() {
   1021         output.push(path.clone());
   1022         if fs::symlink_metadata(&path)
   1023             .map_err(|error| format!("inspect {}: {error}", path.display()))?
   1024             .is_dir()
   1025         {
   1026             for entry in
   1027                 fs::read_dir(&path).map_err(|error| format!("read {}: {error}", path.display()))?
   1028             {
   1029                 pending.push(
   1030                     entry
   1031                         .map_err(|error| format!("read directory entry: {error}"))?
   1032                         .path(),
   1033                 );
   1034             }
   1035         }
   1036     }
   1037     output.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
   1038     Ok(output)
   1039 }
   1040 
   1041 #[cfg(test)]
   1042 mod tests {
   1043     use super::*;
   1044 
   1045     struct Fixture {
   1046         _root: tempfile::TempDir,
   1047         source: PathBuf,
   1048         consumer: PathBuf,
   1049         cache: PathBuf,
   1050         source_lock: SourceLock,
   1051     }
   1052 
   1053     impl Fixture {
   1054         fn new(product: &str) -> Self {
   1055             let root = tempfile::TempDir::new().expect("fixture root");
   1056             let source = root.path().join("source");
   1057             let consumer = root.path().join("consumer");
   1058             let cache = root.path().join("cache");
   1059             fs::create_dir(&source).expect("source");
   1060             fs::create_dir(&consumer).expect("consumer");
   1061             fs::create_dir(&cache).expect("cache");
   1062             git(&source, &["init", "--initial-branch=master"]).expect("init");
   1063             git(&source, &["config", "user.name", "Build Control Fixture"]).expect("name");
   1064             git(
   1065                 &source,
   1066                 &["config", "user.email", "build-control@radroots.org"],
   1067             )
   1068             .expect("email");
   1069             fs::create_dir_all(source.join("contracts/crates")).expect("contracts");
   1070             fs::write(
   1071                 source.join(CATALOG_RELATIVE),
   1072                 "schema = \"radroots.workspace.catalog.v2\"\n",
   1073             )
   1074             .expect("catalog");
   1075             fs::create_dir_all(source.join("src")).expect("source crate");
   1076             fs::write(
   1077                 source.join("Cargo.toml"),
   1078                 "[package]\nname = \"source_fixture\"\nversion = \"0.1.0-alpha\"\nedition = \"2024\"\n",
   1079             )
   1080             .expect("source manifest");
   1081             fs::write(source.join("src/lib.rs"), "pub const READY: bool = true;\n")
   1082                 .expect("source library");
   1083             fs::write(
   1084                 source.join("Cargo.lock"),
   1085                 "# This file is automatically @generated by Cargo.\n# It is not intended for manual editing.\nversion = 4\n\n[[package]]\nname = \"source_fixture\"\nversion = \"0.1.0-alpha\"\n",
   1086             )
   1087             .expect("source lockfile");
   1088             git(&source, &["add", "--all"]).expect("add");
   1089             git(&source, &["commit", "-m", "seed source fixture"]).expect("commit");
   1090             let revision = git_stdout(&source, &["rev-parse", "HEAD"])
   1091                 .expect("revision")
   1092                 .trim()
   1093                 .to_owned();
   1094             let catalog_sha256 =
   1095                 sha256(&fs::read(source.join(CATALOG_RELATIVE)).expect("read source catalog"));
   1096             fs::write(consumer.join(CONSUMER_MARKER), format!("{product}\n")).expect("marker");
   1097             fs::write(
   1098                 consumer.join("Cargo.toml"),
   1099                 format!(
   1100                     "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\nradroots_core = {{ git = \"{REPOSITORY}\", rev = \"{revision}\", version = \"=0.1.0-alpha\" }}\n"
   1101                 ),
   1102             )
   1103             .expect("manifest");
   1104             let consumer_lock = format!(
   1105                 "version = 4\n\n[[package]]\nname = \"radroots_core\"\nversion = \"0.1.0-alpha\"\nsource = \"git+{REPOSITORY}?rev={revision}#{revision}\"\n"
   1106             );
   1107             fs::write(consumer.join("Cargo.lock"), &consumer_lock).expect("consumer lockfile");
   1108             let source_lock = SourceLock {
   1109                 schema: "radroots.lib.source-lock.v1".to_owned(),
   1110                 repository: REPOSITORY.to_owned(),
   1111                 revision,
   1112                 architecture: ARCHITECTURE.to_owned(),
   1113                 workspace_catalog_sha256: catalog_sha256,
   1114                 version: VERSION.to_owned(),
   1115                 source_archive_sha256: None,
   1116                 lockfile: default_consumer_lockfile(),
   1117                 lockfile_sha256: sha256(consumer_lock.as_bytes()),
   1118             };
   1119             fs::write(
   1120                 consumer.join(SOURCE_LOCK_NAME),
   1121                 toml::to_string(&source_lock).expect("serialize source lock"),
   1122             )
   1123             .expect("source lock");
   1124             Self {
   1125                 _root: root,
   1126                 source,
   1127                 consumer,
   1128                 cache,
   1129                 source_lock,
   1130             }
   1131         }
   1132     }
   1133 
   1134     #[test]
   1135     fn source_lock_and_consumer_root_fail_closed() {
   1136         let fixture = Fixture::new("sdk");
   1137         ConsumerRoot::open(&fixture.consumer).expect("valid consumer");
   1138         let mut invalid = fixture.source_lock.clone();
   1139         invalid.revision = "short".to_owned();
   1140         assert!(validate_source_lock(&invalid).is_err());
   1141 
   1142         fs::write(
   1143             fixture.consumer.join("Cargo.toml"),
   1144             "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\n\n[dependencies]\nradroots_core = { git = \"https://github.com/radrootslabs/lib\", branch = \"master\", version = \"*\" }\n",
   1145         )
   1146         .expect("floating manifest");
   1147         assert!(ConsumerRoot::open(&fixture.consumer).is_err());
   1148     }
   1149 
   1150     #[test]
   1151     fn source_lock_accepts_services_without_creating_artifact_routes() {
   1152         for product in ["myc", "rhi"] {
   1153             let fixture = Fixture::new(product);
   1154             let consumer = ConsumerRoot::open(&fixture.consumer).expect("valid service consumer");
   1155             assert_eq!(consumer.product, product);
   1156             assert!(validate_artifact_route(product, "linux", "rust").is_err());
   1157         }
   1158     }
   1159 
   1160     #[test]
   1161     fn retired_application_consumers_and_artifact_routes_fail_closed() {
   1162         for product in ["mobile", "tera"] {
   1163             let fixture = Fixture::new(product);
   1164             assert!(ConsumerRoot::open(&fixture.consumer).is_err());
   1165             for (target, language) in [
   1166                 ("ios", "swift"),
   1167                 ("android", "kotlin"),
   1168                 ("wasm", "javascript"),
   1169             ] {
   1170                 assert!(validate_artifact_route(product, target, language).is_err());
   1171                 assert!(
   1172                     artifact(
   1173                         product,
   1174                         target,
   1175                         language,
   1176                         Mode::Write,
   1177                         &fixture.consumer,
   1178                         &fixture.source,
   1179                         Path::new("generated/retired.json"),
   1180                         1,
   1181                         "fixture",
   1182                         &[],
   1183                     )
   1184                     .is_err()
   1185                 );
   1186             }
   1187             assert!(!fixture.consumer.join("generated").exists());
   1188         }
   1189     }
   1190 
   1191     #[test]
   1192     fn source_lock_supports_a_contained_nested_lockfile() {
   1193         let mut fixture = Fixture::new("sdk");
   1194         let core = fixture.consumer.join("core");
   1195         fs::create_dir(&core).expect("create nested capsule");
   1196         fs::rename(fixture.consumer.join("Cargo.lock"), core.join("Cargo.lock"))
   1197             .expect("move lockfile");
   1198         fixture.source_lock.lockfile = "core/Cargo.lock".to_owned();
   1199         fs::write(
   1200             fixture.consumer.join(SOURCE_LOCK_NAME),
   1201             toml::to_string(&fixture.source_lock).expect("serialize nested source lock"),
   1202         )
   1203         .expect("write nested source lock");
   1204 
   1205         ConsumerRoot::open(&fixture.consumer).expect("nested lockfile is valid");
   1206 
   1207         fixture.source_lock.lockfile = "../Cargo.lock".to_owned();
   1208         fs::write(
   1209             fixture.consumer.join(SOURCE_LOCK_NAME),
   1210             toml::to_string(&fixture.source_lock).expect("serialize escaping source lock"),
   1211         )
   1212         .expect("write escaping source lock");
   1213         assert!(ConsumerRoot::open(&fixture.consumer).is_err());
   1214     }
   1215 
   1216     #[test]
   1217     fn consumer_manifest_discovery_skips_swiftpm_build_output_only() {
   1218         let fixture = Fixture::new("sdk");
   1219         let swiftpm_checkout = fixture.consumer.join(".build/checkouts/dependency");
   1220         fs::create_dir_all(&swiftpm_checkout).expect("SwiftPM checkout directory");
   1221         fs::write(swiftpm_checkout.join("Cargo.toml"), "not valid TOML")
   1222             .expect("SwiftPM build manifest");
   1223         ConsumerRoot::open(&fixture.consumer).expect("SwiftPM build output is excluded");
   1224 
   1225         let source_lookalike = fixture.consumer.join(".builder");
   1226         fs::create_dir(&source_lookalike).expect("source lookalike directory");
   1227         fs::write(source_lookalike.join("Cargo.toml"), "not valid TOML")
   1228             .expect("source lookalike manifest");
   1229         assert!(ConsumerRoot::open(&fixture.consumer).is_err());
   1230     }
   1231 
   1232     #[test]
   1233     fn materialization_reuses_verified_cache_and_rejects_tampering() {
   1234         let fixture = Fixture::new("sdk");
   1235         let consumer = ConsumerRoot::open(&fixture.consumer).expect("consumer");
   1236         let cached = materialize_from(
   1237             &consumer,
   1238             &fixture.cache,
   1239             false,
   1240             fixture.source.to_str().expect("source path"),
   1241         )
   1242         .expect("prefetch");
   1243         assert_eq!(
   1244             materialize_from(
   1245                 &consumer,
   1246                 &fixture.cache,
   1247                 true,
   1248                 fixture.source.to_str().expect("source path"),
   1249             )
   1250             .expect("offline reuse"),
   1251             cached
   1252         );
   1253         let frozen_target = fixture._root.path().join("frozen-target");
   1254         let frozen = Command::new("cargo")
   1255             .args(["check", "--offline", "--frozen", "--locked"])
   1256             .env("CARGO_TARGET_DIR", &frozen_target)
   1257             .current_dir(&cached)
   1258             .output()
   1259             .expect("run frozen offline source smoke");
   1260         assert!(
   1261             frozen.status.success(),
   1262             "frozen offline source smoke failed: {}",
   1263             String::from_utf8_lossy(&frozen.stderr)
   1264         );
   1265         let catalog = cached.join(CATALOG_RELATIVE);
   1266         make_path_writable(&catalog).expect("make catalog writable");
   1267         fs::write(&catalog, "tampered\n").expect("tamper");
   1268         assert!(verify_cache(&cached, &fixture.source_lock).is_err());
   1269         make_writable(&cached);
   1270     }
   1271 
   1272     #[test]
   1273     fn artifact_manifests_are_deterministic_and_route_checked() {
   1274         let fixture = Fixture::new("sdk");
   1275         let output = Path::new("generated/artifact-manifest.json");
   1276         artifact(
   1277             "sdk",
   1278             "typescript",
   1279             "typescript",
   1280             Mode::Write,
   1281             &fixture.consumer,
   1282             &fixture.source,
   1283             output,
   1284             1_700_000_000,
   1285             "fixture_builder",
   1286             &["zeta".to_owned(), "alpha".to_owned(), "alpha".to_owned()],
   1287         )
   1288         .expect("write artifact manifest");
   1289         artifact(
   1290             "sdk",
   1291             "typescript",
   1292             "typescript",
   1293             Mode::Check,
   1294             &fixture.consumer,
   1295             &fixture.source,
   1296             output,
   1297             1_700_000_000,
   1298             "fixture_builder",
   1299             &["alpha".to_owned(), "zeta".to_owned()],
   1300         )
   1301         .expect("check artifact manifest");
   1302         assert!(
   1303             validate_artifact_route("mobile", "ios", "kotlin").is_err(),
   1304             "unsupported target/language must fail"
   1305         );
   1306         assert!(validate_artifact_route("sdk", "wasm", "javascript").is_ok());
   1307         assert!(validate_artifact_route("sdk", "ffi", "swift").is_ok());
   1308         assert!(validate_artifact_route("sdk", "ffi", "kotlin").is_ok());
   1309         assert!(validate_artifact_route("sdk", "wasm", "typescript").is_err());
   1310         assert!(
   1311             artifact(
   1312                 "sdk",
   1313                 "typescript",
   1314                 "typescript",
   1315                 Mode::Write,
   1316                 &fixture.consumer,
   1317                 &fixture.source,
   1318                 Path::new("../escape"),
   1319                 1,
   1320                 "fixture_builder",
   1321                 &[],
   1322             )
   1323             .is_err()
   1324         );
   1325     }
   1326 
   1327     #[test]
   1328     fn atomic_outputs_reject_unsafe_text_and_path_collisions() {
   1329         let root = tempfile::TempDir::new().expect("output fixture");
   1330         let output = root.path().join("generated/output.json");
   1331         atomic_write(&output, b"prior\n").expect("initial output");
   1332         #[cfg(unix)]
   1333         let initial_inode = {
   1334             use std::os::unix::fs::MetadataExt;
   1335             fs::metadata(&output).expect("initial metadata").ino()
   1336         };
   1337         atomic_write(&output, b"prior\n").expect("identical no-op");
   1338         #[cfg(unix)]
   1339         {
   1340             use std::os::unix::fs::MetadataExt;
   1341             assert_eq!(
   1342                 fs::metadata(&output).expect("no-op metadata").ino(),
   1343                 initial_inode
   1344             );
   1345         }
   1346         assert!(atomic_write(&output, b"missing newline").is_err());
   1347         assert!(atomic_write(&output, b"windows\r\n").is_err());
   1348         assert_eq!(
   1349             fs::read(&output).expect("prior output retained"),
   1350             b"prior\n"
   1351         );
   1352         let directory_output = root.path().join("directory-output");
   1353         fs::create_dir_all(&directory_output).expect("directory collision");
   1354         assert!(atomic_write(&directory_output, b"{}\n").is_err());
   1355 
   1356         #[cfg(unix)]
   1357         {
   1358             use std::os::unix::fs::symlink;
   1359 
   1360             let symlink_root = tempfile::TempDir::new().expect("symlink fixture");
   1361             let outside = tempfile::TempDir::new().expect("outside fixture");
   1362             symlink(outside.path(), symlink_root.path().join("generated"))
   1363                 .expect("output parent symlink");
   1364             assert!(
   1365                 atomic_write(&symlink_root.path().join("generated/output.json"), b"{}\n").is_err()
   1366             );
   1367         }
   1368     }
   1369 
   1370     #[test]
   1371     fn source_archive_round_trip_is_digest_bound_and_immutable() {
   1372         let fixture = Fixture::new("sdk");
   1373         let archive = fixture._root.path().join("archives/source.bundle");
   1374         let digest =
   1375             create_source_archive(&fixture.source, &fixture.source_lock.revision, &archive)
   1376                 .expect("create archive");
   1377         verify_source_archive(&archive, &digest).expect("verify archive");
   1378         assert_eq!(
   1379             create_source_archive(&fixture.source, &fixture.source_lock.revision, &archive,)
   1380                 .expect("identical archive no-op"),
   1381             digest
   1382         );
   1383         assert!(verify_source_archive(&archive, &"0".repeat(64)).is_err());
   1384         assert!(verify_source_archive(Path::new("relative.bundle"), &digest).is_err());
   1385     }
   1386 
   1387     #[cfg(unix)]
   1388     #[test]
   1389     fn consumer_and_source_roots_reject_symlinks() {
   1390         use std::os::unix::fs::symlink;
   1391 
   1392         let fixture = Fixture::new("sdk");
   1393         let consumer_link = fixture._root.path().join("consumer-link");
   1394         symlink(&fixture.consumer, &consumer_link).expect("consumer symlink");
   1395         assert!(ConsumerRoot::open(&consumer_link).is_err());
   1396 
   1397         let source_link = fixture._root.path().join("source-link");
   1398         symlink(&fixture.source, &source_link).expect("source symlink");
   1399         assert!(verify_source_root(&source_link, &fixture.source_lock).is_err());
   1400     }
   1401 
   1402     #[test]
   1403     fn checked_in_group_plan_is_explicit_and_active_only() {
   1404         let plan = group_plan(
   1405             &crate::workspace_root(),
   1406             "public_native",
   1407             Operation::Check,
   1408             false,
   1409         )
   1410         .expect("public native plan");
   1411         assert!(plan.iter().any(|arg| arg == "radroots_core"));
   1412         assert!(plan.iter().any(|arg| arg == "radroots_sdk"));
   1413         assert!(!plan.iter().any(|arg| arg == "--workspace"));
   1414         assert!(group_plan(&crate::workspace_root(), "missing", Operation::Check, false).is_err());
   1415     }
   1416 
   1417     fn make_writable(root: &Path) {
   1418         if let Ok(paths) = walk(root) {
   1419             for path in paths {
   1420                 let _ = make_path_writable(&path);
   1421             }
   1422         }
   1423     }
   1424 
   1425     fn make_path_writable(path: &Path) -> Result<(), std::io::Error> {
   1426         let mut permissions = fs::metadata(path)?.permissions();
   1427         #[cfg(unix)]
   1428         {
   1429             use std::os::unix::fs::PermissionsExt;
   1430             permissions.set_mode(permissions.mode() | 0o200);
   1431         }
   1432         #[cfg(not(unix))]
   1433         permissions.set_readonly(false);
   1434         fs::set_permissions(path, permissions)
   1435     }
   1436 }