lib

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

catalog.rs (66586B)


      1 use std::{
      2     collections::{BTreeMap, BTreeSet},
      3     fs,
      4     path::{Component, Path},
      5     process::Command,
      6 };
      7 
      8 use serde::Deserialize;
      9 use sha2::{Digest, Sha256};
     10 
     11 use crate::contract::artifact_bundle::{
     12     GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction,
     13 };
     14 
     15 const CATALOG_RELATIVE: &str = "contracts/crates/catalog.v2.toml";
     16 const RELEASE_RELATIVE: &str = "contracts/crates/release.v2.toml";
     17 const CONSOLIDATION_RELATIVE: &str = "contracts/consolidation/architecture.v1.toml";
     18 const GROUPS_RELATIVE: &str = "contracts/crates/generated/package_groups.v1.toml";
     19 const PLATFORMS_RELATIVE: &str = "contracts/crates/generated/platform_inventory.v1.toml";
     20 const RELEASE_INVENTORY_RELATIVE: &str = "contracts/crates/generated/release_inventory.v2.toml";
     21 const COVERAGE_RELATIVE: &str = "contracts/coverage.toml";
     22 const CATALOG_SCHEMA: &str = "radroots.workspace.catalog.v2";
     23 const RELEASE_ID: &str = "radroots.crates.release.v2";
     24 const CONSOLIDATION_ID: &str = "radroots.rust.consolidation.v1";
     25 const VERSION: &str = "0.1.0-alpha";
     26 const APPLICATION_RETIREMENT: &str =
     27     "contracts/architecture/decisions/tera_application_ownership.v1.json";
     28 const RETIRED_APPLICATION_PACKAGES: [&str; 4] = [
     29     "radroots_mobile_bindgen",
     30     "radroots_mobile_core",
     31     "radroots_mobile_ffi",
     32     "radroots_mobile_wasm",
     33 ];
     34 
     35 #[derive(Debug, Deserialize)]
     36 #[serde(deny_unknown_fields)]
     37 struct ApplicationRetirement {
     38     schema: String,
     39     source_repository: String,
     40     source_catalog_revision: String,
     41     source_catalog_sha256: String,
     42     transfer_donor_commit: String,
     43     transfer_donor_tree: String,
     44     target_repository: String,
     45     target_revision: String,
     46     transfer_evidence_sha256: String,
     47     package: Vec<RetiredApplication>,
     48 }
     49 
     50 #[derive(Debug, Deserialize)]
     51 #[serde(deny_unknown_fields)]
     52 struct RetiredApplication {
     53     former_catalog_entry: CatalogPackage,
     54     target_name: String,
     55     target_path: String,
     56     projected_commit: String,
     57     projected_tree: String,
     58 }
     59 
     60 #[derive(Debug, Deserialize)]
     61 #[serde(deny_unknown_fields)]
     62 struct Catalog {
     63     schema_version: u16,
     64     schema: String,
     65     architecture: String,
     66     consolidation: String,
     67     version: String,
     68     rust_version: String,
     69     edition: String,
     70     resolver: String,
     71     public_package_count: usize,
     72     package_count: usize,
     73     digest_algorithm: String,
     74     source_tree_digest_algorithm: String,
     75     native_introduction_tree_digest_algorithm: String,
     76     source_provenance_policy: String,
     77     provenance_correction_contract: String,
     78     retired_packages: Vec<String>,
     79     package: Vec<CatalogPackage>,
     80 }
     81 
     82 #[derive(Debug, Deserialize)]
     83 #[serde(deny_unknown_fields)]
     84 struct CatalogPackage {
     85     name: String,
     86     path: String,
     87     state: String,
     88     tier: String,
     89     visibility: String,
     90     publish: bool,
     91     version: String,
     92     license: String,
     93     platforms: Vec<String>,
     94     groups: Vec<String>,
     95     owners: Vec<String>,
     96     permitted_dependency_tiers: Vec<String>,
     97     provenance_kind: String,
     98     source_repository: Option<String>,
     99     source_revision: Option<String>,
    100     source_path: Option<String>,
    101     source_tree_sha256: Option<String>,
    102     introduction_tree_sha256: Option<String>,
    103     compatibility: Vec<String>,
    104     removal_gate: Option<String>,
    105     replaces: Vec<String>,
    106 }
    107 
    108 #[derive(Debug, Deserialize)]
    109 #[serde(deny_unknown_fields)]
    110 struct ReleaseV2 {
    111     schema_version: u16,
    112     spec_id: String,
    113     status: String,
    114     supersedes_without_mutation: String,
    115     canonical_repository: String,
    116     version: String,
    117     public_package_count: usize,
    118     publication_authorized: bool,
    119     public_packages: Vec<String>,
    120     v1_artifact: Vec<V1Artifact>,
    121     v1_retired_human_artifact: Vec<RetiredV1HumanArtifact>,
    122 }
    123 
    124 #[derive(Debug, Deserialize)]
    125 #[serde(deny_unknown_fields)]
    126 struct V1Artifact {
    127     path: String,
    128     sha256: String,
    129 }
    130 
    131 #[derive(Debug, Deserialize)]
    132 #[serde(deny_unknown_fields)]
    133 struct RetiredV1HumanArtifact {
    134     former_path: String,
    135     sha256: String,
    136 }
    137 
    138 #[derive(Debug, Deserialize)]
    139 #[serde(deny_unknown_fields)]
    140 struct ConsolidationV1 {
    141     schema_version: u16,
    142     consolidation_id: String,
    143     architecture: String,
    144     canonical_rust_repository: String,
    145     donor_repositories: Vec<String>,
    146     consumer_repositories: Vec<String>,
    147     source_retirement_requires: Vec<String>,
    148     repository_archival_requires: Vec<String>,
    149 }
    150 
    151 #[derive(Debug, Deserialize)]
    152 struct CoveragePolicy {
    153     required: CoverageRequired,
    154 }
    155 
    156 #[derive(Debug, Deserialize)]
    157 struct CoverageRequired {
    158     crates: Vec<String>,
    159 }
    160 
    161 #[derive(Debug, Deserialize)]
    162 struct CargoMetadata {
    163     packages: Vec<CargoPackage>,
    164     workspace_members: Vec<String>,
    165 }
    166 
    167 #[derive(Debug, Deserialize)]
    168 struct CargoPackage {
    169     id: String,
    170     name: String,
    171     version: String,
    172     manifest_path: String,
    173     license: Option<String>,
    174     publish: Option<Vec<String>>,
    175     dependencies: Vec<CargoDependency>,
    176 }
    177 
    178 #[derive(Debug, Deserialize)]
    179 struct CargoDependency {
    180     name: String,
    181     source: Option<String>,
    182     req: String,
    183     path: Option<String>,
    184     kind: Option<String>,
    185     target: Option<String>,
    186 }
    187 
    188 pub fn run(args: &[String], workspace_root: &Path) -> Result<(), String> {
    189     match args {
    190         [command] if command == "check" => check(workspace_root),
    191         [command] if command == "write" => write(workspace_root),
    192         _ => Err("catalog accepts check or write".to_owned()),
    193     }
    194 }
    195 
    196 pub(crate) fn check(workspace_root: &Path) -> Result<(), String> {
    197     check_with_provenance(workspace_root, true)
    198 }
    199 
    200 pub(crate) fn check_source_export(workspace_root: &Path) -> Result<(), String> {
    201     check_with_provenance(workspace_root, false)
    202 }
    203 
    204 pub(crate) fn active_group(workspace_root: &Path, group: &str) -> Result<Vec<String>, String> {
    205     validate_identifier(group, "package group")?;
    206     active_packages_matching(workspace_root, |package| {
    207         package.groups.iter().any(|candidate| candidate == group)
    208     })
    209 }
    210 
    211 pub(crate) fn active_packages(workspace_root: &Path) -> Result<Vec<String>, String> {
    212     active_packages_matching(workspace_root, |_| true)
    213 }
    214 
    215 fn active_packages_matching(
    216     workspace_root: &Path,
    217     predicate: impl Fn(&CatalogPackage) -> bool,
    218 ) -> Result<Vec<String>, String> {
    219     let catalog = parse_file::<Catalog>(workspace_root, CATALOG_RELATIVE)?;
    220     validate_catalog(&catalog)?;
    221     validate_application_retirement(&catalog, workspace_root)?;
    222     let mut packages = catalog
    223         .package
    224         .iter()
    225         .filter(|package| package.state == "active" && predicate(package))
    226         .map(|package| package.name.clone())
    227         .collect::<Vec<_>>();
    228     packages.sort_unstable();
    229     Ok(packages)
    230 }
    231 
    232 fn check_with_provenance(
    233     workspace_root: &Path,
    234     require_git_provenance: bool,
    235 ) -> Result<(), String> {
    236     let loaded = load_and_validate(workspace_root, require_git_provenance)?;
    237     for artifact in render_projections(&loaded.catalog, &loaded.catalog_digest) {
    238         let current = read_regular_file(workspace_root, artifact.relative)?;
    239         if current != artifact.contents {
    240             return Err(format!(
    241                 "generated catalog projection {} is stale; run catalog write",
    242                 artifact.relative
    243             ));
    244         }
    245     }
    246     Ok(())
    247 }
    248 
    249 fn write(workspace_root: &Path) -> Result<(), String> {
    250     let loaded = load_and_validate(workspace_root, true)?;
    251     let artifacts = render_projections(&loaded.catalog, &loaded.catalog_digest);
    252     with_artifact_bundle_transaction(workspace_root, |transaction| transaction.write(artifacts))
    253 }
    254 
    255 struct LoadedCatalog {
    256     catalog: Catalog,
    257     catalog_digest: String,
    258 }
    259 
    260 fn load_and_validate(
    261     workspace_root: &Path,
    262     require_git_provenance: bool,
    263 ) -> Result<LoadedCatalog, String> {
    264     let catalog_bytes = read_regular_file(workspace_root, CATALOG_RELATIVE)?;
    265     let catalog = parse_toml::<Catalog>(CATALOG_RELATIVE, &catalog_bytes)?;
    266     let release = parse_file::<ReleaseV2>(workspace_root, RELEASE_RELATIVE)?;
    267     let consolidation = parse_file::<ConsolidationV1>(workspace_root, CONSOLIDATION_RELATIVE)?;
    268     let coverage = parse_file::<CoveragePolicy>(workspace_root, COVERAGE_RELATIVE)?;
    269     validate_catalog(&catalog)?;
    270     validate_application_retirement(&catalog, workspace_root)?;
    271     validate_coverage_authority(&catalog, &coverage)?;
    272     validate_release(&release, &catalog, workspace_root)?;
    273     validate_consolidation(&consolidation)?;
    274     validate_workspace_manifest(&catalog, workspace_root)?;
    275     let metadata = cargo_metadata(workspace_root)?;
    276     validate_metadata(&catalog, &metadata, workspace_root)?;
    277     if require_git_provenance {
    278         validate_active_source_provenance(&catalog, workspace_root)?;
    279     }
    280     Ok(LoadedCatalog {
    281         catalog,
    282         catalog_digest: sha256(&catalog_bytes),
    283     })
    284 }
    285 
    286 fn parse_file<T: for<'de> Deserialize<'de>>(
    287     workspace_root: &Path,
    288     relative: &str,
    289 ) -> Result<T, String> {
    290     let bytes = read_regular_file(workspace_root, relative)?;
    291     parse_toml(relative, &bytes)
    292 }
    293 
    294 fn parse_toml<T: for<'de> Deserialize<'de>>(relative: &str, bytes: &[u8]) -> Result<T, String> {
    295     let raw =
    296         std::str::from_utf8(bytes).map_err(|error| format!("{relative} is not UTF-8: {error}"))?;
    297     toml::from_str(raw).map_err(|error| format!("parse {relative}: {error}"))
    298 }
    299 
    300 fn validate_catalog(catalog: &Catalog) -> Result<(), String> {
    301     if catalog.schema_version != 2
    302         || catalog.schema != CATALOG_SCHEMA
    303         || catalog.architecture != RELEASE_ID
    304         || catalog.consolidation != CONSOLIDATION_ID
    305         || catalog.version != VERSION
    306         || catalog.rust_version != "1.97.1"
    307         || catalog.edition != "2024"
    308         || catalog.resolver != "3"
    309         || catalog.public_package_count != 19
    310         || catalog.package_count != catalog.package.len()
    311         || catalog.digest_algorithm != "sha256-raw-bytes-v1"
    312         || catalog.source_tree_digest_algorithm != "sha256-git-ls-tree-r-v1"
    313         || catalog.native_introduction_tree_digest_algorithm != "sha256-git-tree-records-z-v1"
    314         || catalog.source_provenance_policy != "imported_revision_tree_or_native_introduction_tree"
    315         || catalog.provenance_correction_contract != "approved_correction_record_required"
    316     {
    317         return Err("catalog identity, toolchain, or cardinality drifted".to_owned());
    318     }
    319 
    320     let expected_public = expected_public_packages();
    321     let allowed_states = BTreeSet::from(["active", "reserved_import", "reserved_refactor"]);
    322     let allowed_visibilities = BTreeSet::from([
    323         "public_release",
    324         "private_runtime",
    325         "private_adapter",
    326         "private_boundary",
    327         "private_codegen",
    328         "private_fixture",
    329         "private_tool",
    330     ]);
    331     let allowed_licenses = BTreeSet::from([
    332         "MIT OR Apache-2.0",
    333         "GPL-3.0-only",
    334         "GPL-3.0-or-later",
    335         "MPL-2.0",
    336     ]);
    337     let mut names = BTreeSet::new();
    338     let mut paths = BTreeSet::new();
    339     let mut public = BTreeSet::new();
    340     let mut package_by_name = BTreeMap::new();
    341     let mut groups = BTreeSet::new();
    342     for package in &catalog.package {
    343         validate_identifier(&package.name, "package name")?;
    344         validate_relative_path(&package.path)?;
    345         validate_identifier(&package.tier, "package tier")?;
    346         let expected_path = match package.name.as_str() {
    347             "radroots" => "crates/radroots".to_owned(),
    348             "xtask" => "tools/xtask".to_owned(),
    349             name => format!(
    350                 "crates/{}",
    351                 name.strip_prefix("radroots_")
    352                     .ok_or_else(|| format!("package {name} lacks the radroots_ prefix"))?
    353             ),
    354         };
    355         if package.path != expected_path {
    356             return Err(format!(
    357                 "package {} must use canonical path {expected_path}",
    358                 package.name
    359             ));
    360         }
    361         if !names.insert(package.name.as_str()) || !paths.insert(package.path.as_str()) {
    362             return Err(format!(
    363                 "duplicate package name or path for {}",
    364                 package.name
    365             ));
    366         }
    367         if !allowed_states.contains(package.state.as_str())
    368             || !allowed_visibilities.contains(package.visibility.as_str())
    369             || !allowed_licenses.contains(package.license.as_str())
    370             || package.version != VERSION
    371             || package.platforms.is_empty()
    372             || package.groups.is_empty()
    373             || package.owners.is_empty()
    374             || package.permitted_dependency_tiers.is_empty()
    375             || package.compatibility.is_empty()
    376         {
    377             return Err(format!("catalog package {} is incomplete", package.name));
    378         }
    379         validate_unique_identifiers(&package.platforms, "platform")?;
    380         validate_unique_identifiers(&package.groups, "group")?;
    381         validate_unique_identifiers(&package.owners, "owner")?;
    382         validate_unique_identifiers(
    383             &package.permitted_dependency_tiers,
    384             "permitted dependency tier",
    385         )?;
    386         validate_unique_identifiers(&package.compatibility, "compatibility authority")?;
    387         for group in &package.groups {
    388             groups.insert(group.as_str());
    389         }
    390         if package.visibility == "public_release" {
    391             public.insert(package.name.as_str());
    392             if !package.publish || package.license != "MIT OR Apache-2.0" {
    393                 return Err(format!(
    394                     "public package {} must be permissive and publication-eligible",
    395                     package.name
    396                 ));
    397             }
    398         } else if package.publish {
    399             return Err(format!(
    400                 "private package {} must set publish=false",
    401                 package.name
    402             ));
    403         }
    404         if package.license.starts_with("GPL-")
    405             && !matches!(
    406                 package.visibility.as_str(),
    407                 "private_runtime" | "private_adapter" | "private_boundary" | "private_codegen"
    408             )
    409         {
    410             return Err(format!("GPL package {} has an invalid class", package.name));
    411         }
    412         validate_package_provenance(package)?;
    413         for replaced in &package.replaces {
    414             validate_package_identity(replaced, "replaced package")?;
    415         }
    416         if package.state == "active" && package.removal_gate.is_some() {
    417             return Err(format!(
    418                 "active package {} has a removal gate",
    419                 package.name
    420             ));
    421         }
    422         if package.state != "active"
    423             && package
    424                 .removal_gate
    425                 .as_deref()
    426                 .is_none_or(|gate| gate.trim().is_empty())
    427         {
    428             return Err(format!(
    429                 "reserved package {} lacks a removal gate",
    430                 package.name
    431             ));
    432         }
    433         package_by_name.insert(package.name.as_str(), package);
    434     }
    435     if public != expected_public {
    436         return Err("catalog must contain exactly the 19 approved public packages".to_owned());
    437     }
    438     let required_groups = BTreeSet::from([
    439         "boundaries",
    440         "coverage_required",
    441         "portable",
    442         "preview",
    443         "public_native",
    444         "sdk",
    445         "tools",
    446         "wasm",
    447     ]);
    448     if !required_groups.is_subset(&groups) {
    449         return Err("catalog is missing one or more required package groups".to_owned());
    450     }
    451     let portable = catalog
    452         .package
    453         .iter()
    454         .filter(|package| package.groups.iter().any(|group| group == "portable"))
    455         .map(|package| package.name.as_str())
    456         .collect::<BTreeSet<_>>();
    457     if portable != expected_public {
    458         return Err("portable group must contain exactly the 19 public packages".to_owned());
    459     }
    460     for package in &catalog.package {
    461         for tier in &package.permitted_dependency_tiers {
    462             if !catalog
    463                 .package
    464                 .iter()
    465                 .any(|candidate| candidate.tier == *tier)
    466             {
    467                 return Err(format!(
    468                     "package {} permits unknown tier {tier}",
    469                     package.name
    470                 ));
    471             }
    472         }
    473     }
    474     let retired = catalog
    475         .retired_packages
    476         .iter()
    477         .map(String::as_str)
    478         .collect::<BTreeSet<_>>();
    479     if retired.len() != catalog.retired_packages.len()
    480         || retired
    481             .iter()
    482             .any(|name| package_by_name.contains_key(name))
    483         || !expected_retired_packages().is_subset(&retired)
    484     {
    485         return Err("retired package identities are incomplete or reused".to_owned());
    486     }
    487     let xtasks = catalog
    488         .package
    489         .iter()
    490         .filter(|package| package.name == "xtask")
    491         .collect::<Vec<_>>();
    492     if xtasks.len() != 1
    493         || xtasks[0].path != "tools/xtask"
    494         || xtasks[0].visibility != "private_tool"
    495         || xtasks[0].state != "active"
    496     {
    497         return Err("catalog must contain exactly one canonical xtask".to_owned());
    498     }
    499     Ok(())
    500 }
    501 
    502 fn validate_package_provenance(package: &CatalogPackage) -> Result<(), String> {
    503     match package.provenance_kind.as_str() {
    504         "imported" => {
    505             let source_repository = package.source_repository.as_deref().ok_or_else(|| {
    506                 format!("imported package {} lacks source repository", package.name)
    507             })?;
    508             let source_revision = package.source_revision.as_deref().ok_or_else(|| {
    509                 format!("imported package {} lacks source revision", package.name)
    510             })?;
    511             let source_path = package
    512                 .source_path
    513                 .as_deref()
    514                 .ok_or_else(|| format!("imported package {} lacks source path", package.name))?;
    515             let source_tree_sha256 = package.source_tree_sha256.as_deref().ok_or_else(|| {
    516                 format!("imported package {} lacks source tree digest", package.name)
    517             })?;
    518             if package.introduction_tree_sha256.is_some() {
    519                 return Err(format!(
    520                     "imported package {} declares native introduction provenance",
    521                     package.name
    522                 ));
    523             }
    524             if source_repository
    525                 != format!(
    526                     "https://github.com/radrootslabs/{}",
    527                     source_repository_name(source_repository)?
    528                 )
    529             {
    530                 return Err(format!(
    531                     "package {} source repository is noncanonical",
    532                     package.name
    533                 ));
    534             }
    535             validate_oid(source_revision, "source revision")?;
    536             validate_relative_path(source_path)?;
    537             validate_sha256(source_tree_sha256, "source tree digest")
    538         }
    539         "native" => {
    540             if package.source_repository.is_some()
    541                 || package.source_revision.is_some()
    542                 || package.source_path.is_some()
    543                 || package.source_tree_sha256.is_some()
    544             {
    545                 return Err(format!(
    546                     "native package {} must not declare imported provenance",
    547                     package.name
    548                 ));
    549             }
    550             let introduction_tree_sha256 =
    551                 package.introduction_tree_sha256.as_deref().ok_or_else(|| {
    552                     format!(
    553                         "native package {} lacks introduction tree digest",
    554                         package.name
    555                     )
    556                 })?;
    557             validate_sha256(introduction_tree_sha256, "introduction tree digest")?;
    558             if package.state != "active"
    559                 || package.publish
    560                 || package.visibility == "public_release"
    561             {
    562                 return Err(format!(
    563                     "native package {} must be active and unpublished",
    564                     package.name
    565                 ));
    566             }
    567             Ok(())
    568         }
    569         _ => Err(format!(
    570             "package {} has unknown provenance kind {}",
    571             package.name, package.provenance_kind
    572         )),
    573     }
    574 }
    575 
    576 fn validate_coverage_authority(catalog: &Catalog, coverage: &CoveragePolicy) -> Result<(), String> {
    577     let catalog_required = catalog
    578         .package
    579         .iter()
    580         .filter(|package| {
    581             package.state == "active"
    582                 && package
    583                     .groups
    584                     .iter()
    585                     .any(|group| group == "coverage_required")
    586         })
    587         .map(|package| package.name.as_str())
    588         .collect::<BTreeSet<_>>();
    589     let policy_required = coverage
    590         .required
    591         .crates
    592         .iter()
    593         .map(String::as_str)
    594         .collect::<BTreeSet<_>>();
    595     if policy_required.len() != coverage.required.crates.len() {
    596         return Err("coverage policy contains duplicate required packages".to_owned());
    597     }
    598     if catalog_required != policy_required {
    599         return Err(
    600             "coverage required packages must exactly match the catalog coverage_required group"
    601                 .to_owned(),
    602         );
    603     }
    604     Ok(())
    605 }
    606 
    607 fn validate_release(
    608     release: &ReleaseV2,
    609     catalog: &Catalog,
    610     workspace_root: &Path,
    611 ) -> Result<(), String> {
    612     if release.schema_version != 2
    613         || release.spec_id != RELEASE_ID
    614         || release.status != "approved_not_published"
    615         || release.supersedes_without_mutation != "radroots.crates.release.v1"
    616         || release.canonical_repository != "https://github.com/radrootslabs/lib"
    617         || release.version != VERSION
    618         || release.public_package_count != 19
    619         || release.publication_authorized
    620         || release
    621             .public_packages
    622             .iter()
    623             .map(String::as_str)
    624             .collect::<BTreeSet<_>>()
    625             != expected_public_packages()
    626     {
    627         return Err("release v2 identity or public allocation drifted".to_owned());
    628     }
    629     if release.public_packages.len() != release.public_package_count
    630         || catalog.public_package_count != release.public_package_count
    631     {
    632         return Err("release v2 package cardinality drifted".to_owned());
    633     }
    634     let mut paths = BTreeSet::new();
    635     for artifact in &release.v1_artifact {
    636         validate_relative_path(&artifact.path)?;
    637         validate_sha256(&artifact.sha256, "v1 artifact digest")?;
    638         if !paths.insert(artifact.path.as_str()) {
    639             return Err("release v2 repeats a v1 artifact".to_owned());
    640         }
    641         let bytes = read_regular_file(workspace_root, &artifact.path)?;
    642         if sha256(&bytes) != artifact.sha256 {
    643             return Err(format!("historical v1 artifact {} drifted", artifact.path));
    644         }
    645     }
    646     let expected = BTreeSet::from([
    647         "contracts/crates/release_v1/radroots_crates_release_v1.dot",
    648         "contracts/crates/release_v1/radroots_crates_release_v1.sha256",
    649         "contracts/crates/release_v1/radroots_crates_release_v1.toml",
    650         "contracts/crates/release_v1/radroots_crates_release_v1_inventory.csv",
    651     ]);
    652     if paths != expected {
    653         return Err("release v2 must pin every historical v1 machine artifact".to_owned());
    654     }
    655     if release.v1_retired_human_artifact.len() != 1 {
    656         return Err("release v2 must record the retired v1 human artifact".to_owned());
    657     }
    658     let retired = &release.v1_retired_human_artifact[0];
    659     validate_relative_path(&retired.former_path)?;
    660     validate_sha256(&retired.sha256, "retired v1 human artifact digest")?;
    661     if retired.former_path != "docs/specs/radroots_crates_release_v1.md"
    662         || retired.sha256 != "ea2c1f0f5c53fae56a247ae7519b065c9a0d62dafb998b75a48075f4a875b5eb"
    663     {
    664         return Err("release v2 retired v1 human artifact drifted".to_owned());
    665     }
    666     Ok(())
    667 }
    668 
    669 fn validate_consolidation(contract: &ConsolidationV1) -> Result<(), String> {
    670     if contract.schema_version != 1
    671         || contract.consolidation_id != CONSOLIDATION_ID
    672         || contract.architecture != RELEASE_ID
    673         || contract.canonical_rust_repository != "https://github.com/radrootslabs/lib"
    674         || contract
    675             .donor_repositories
    676             .iter()
    677             .map(String::as_str)
    678             .collect::<BTreeSet<_>>()
    679             != BTreeSet::from([
    680                 "https://github.com/radrootslabs/app_rt",
    681                 "https://github.com/radrootslabs/sdk",
    682                 "https://github.com/radrootslabs/studio_app",
    683             ])
    684         || contract
    685             .consumer_repositories
    686             .iter()
    687             .map(String::as_str)
    688             .collect::<BTreeSet<_>>()
    689             != BTreeSet::from([
    690                 "https://github.com/radrootslabs/sdk",
    691                 "https://github.com/radrootslabs/studio_app",
    692             ])
    693         || contract.source_retirement_requires.is_empty()
    694         || contract.repository_archival_requires.is_empty()
    695     {
    696         return Err("consolidation v1 repository authority drifted".to_owned());
    697     }
    698     validate_unique_identifiers(
    699         &contract.source_retirement_requires,
    700         "source retirement requirement",
    701     )?;
    702     validate_unique_identifiers(
    703         &contract.repository_archival_requires,
    704         "repository archival requirement",
    705     )
    706 }
    707 
    708 fn cargo_metadata(workspace_root: &Path) -> Result<CargoMetadata, String> {
    709     let output = Command::new("cargo")
    710         .args(["metadata", "--format-version", "1", "--locked", "--no-deps"])
    711         .current_dir(workspace_root)
    712         .output()
    713         .map_err(|error| format!("run cargo metadata: {error}"))?;
    714     if !output.status.success() {
    715         return Err(format!(
    716             "cargo metadata failed: {}",
    717             String::from_utf8_lossy(&output.stderr).trim()
    718         ));
    719     }
    720     serde_json::from_slice(&output.stdout)
    721         .map_err(|error| format!("parse cargo metadata output: {error}"))
    722 }
    723 
    724 fn validate_workspace_manifest(catalog: &Catalog, workspace_root: &Path) -> Result<(), String> {
    725     let bytes = read_regular_file(workspace_root, "Cargo.toml")?;
    726     let raw =
    727         std::str::from_utf8(&bytes).map_err(|error| format!("Cargo.toml is not UTF-8: {error}"))?;
    728     let manifest =
    729         toml::from_str::<toml::Value>(raw).map_err(|error| format!("parse Cargo.toml: {error}"))?;
    730     let workspace = manifest
    731         .get("workspace")
    732         .and_then(toml::Value::as_table)
    733         .ok_or_else(|| "Cargo.toml lacks [workspace]".to_owned())?;
    734     let members = workspace
    735         .get("members")
    736         .and_then(toml::Value::as_array)
    737         .ok_or_else(|| "Cargo.toml lacks explicit workspace members".to_owned())?
    738         .iter()
    739         .map(|value| {
    740             value
    741                 .as_str()
    742                 .ok_or_else(|| "workspace member must be a string".to_owned())
    743         })
    744         .collect::<Result<BTreeSet<_>, _>>()?;
    745     let active_paths = catalog
    746         .package
    747         .iter()
    748         .filter(|package| package.state == "active")
    749         .map(|package| package.path.as_str())
    750         .collect::<BTreeSet<_>>();
    751     if members != active_paths
    752         || members
    753             .iter()
    754             .any(|member| member.contains('*') || member.contains('?') || member.contains('['))
    755     {
    756         return Err(
    757             "Cargo workspace members must exactly match explicit active catalog paths".to_owned(),
    758         );
    759     }
    760     let default_members = workspace
    761         .get("default-members")
    762         .and_then(toml::Value::as_array)
    763         .ok_or_else(|| "Cargo.toml lacks explicit default members".to_owned())?
    764         .iter()
    765         .map(|value| {
    766             value
    767                 .as_str()
    768                 .ok_or_else(|| "default member must be a string".to_owned())
    769         })
    770         .collect::<Result<BTreeSet<_>, _>>()?;
    771     let expected_defaults = catalog
    772         .package
    773         .iter()
    774         .filter(|package| {
    775             package.state == "active"
    776                 && (package.groups.iter().any(|group| group == "portable")
    777                     || package.name == "xtask")
    778         })
    779         .map(|package| package.path.as_str())
    780         .collect::<BTreeSet<_>>();
    781     if default_members != expected_defaults {
    782         return Err(
    783             "Cargo default members must match active portable packages plus xtask".to_owned(),
    784         );
    785     }
    786     if workspace.get("resolver").and_then(toml::Value::as_str) != Some("3") {
    787         return Err("Cargo workspace resolver must remain 3".to_owned());
    788     }
    789     let dependencies = manifest
    790         .get("workspace")
    791         .and_then(|workspace| workspace.get("dependencies"))
    792         .and_then(toml::Value::as_table)
    793         .ok_or_else(|| "Cargo.toml lacks [workspace.dependencies]".to_owned())?;
    794     for (name, value) in dependencies {
    795         let name = value
    796             .get("package")
    797             .and_then(toml::Value::as_str)
    798             .unwrap_or(name);
    799         validate_foundation_dependency(name, value.get("git").and_then(toml::Value::as_str))?;
    800     }
    801     for package in catalog
    802         .package
    803         .iter()
    804         .filter(|package| package.state == "active" && package.name != "xtask")
    805     {
    806         let dependency = dependencies
    807             .get(&package.name)
    808             .and_then(toml::Value::as_table)
    809             .ok_or_else(|| format!("workspace dependency {} is missing", package.name))?;
    810         if dependency.get("path").and_then(toml::Value::as_str) != Some(package.path.as_str())
    811             || dependency.get("version").and_then(toml::Value::as_str)
    812                 != Some(format!("={VERSION}").as_str())
    813             || ["git", "rev", "branch", "tag"]
    814                 .iter()
    815                 .any(|key| dependency.contains_key(*key))
    816         {
    817             return Err(format!(
    818                 "workspace dependency {} must use its catalog path and exact version only",
    819                 package.name
    820             ));
    821         }
    822     }
    823     Ok(())
    824 }
    825 
    826 fn validate_foundation_dependency(name: &str, source: Option<&str>) -> Result<(), String> {
    827     if expected_retired_packages().contains(name)
    828         || name == "tera"
    829         || name.starts_with("tera_")
    830         || source.is_some_and(|source| source.contains("radrootslabs/tera"))
    831     {
    832         return Err(format!(
    833             "shared foundation cannot depend on retired or application package {name}"
    834         ));
    835     }
    836     Ok(())
    837 }
    838 
    839 fn validate_application_retirement(catalog: &Catalog, root: &Path) -> Result<(), String> {
    840     let bytes = read_regular_file(root, APPLICATION_RETIREMENT)?;
    841     let retirement: ApplicationRetirement = serde_json::from_slice(&bytes)
    842         .map_err(|error| format!("parse application ownership decision: {error}"))?;
    843     if retirement.schema != "radroots.tera.application-ownership.v1"
    844         || retirement.source_repository != "https://github.com/radrootslabs/lib"
    845         || retirement.target_repository != "https://github.com/radrootslabs/tera"
    846         || retirement.package.len() != RETIRED_APPLICATION_PACKAGES.len()
    847     {
    848         return Err("application ownership decision identity drifted".to_owned());
    849     }
    850     validate_oid(
    851         &retirement.source_catalog_revision,
    852         "former catalog revision",
    853     )?;
    854     validate_oid(
    855         &retirement.transfer_donor_commit,
    856         "application donor commit",
    857     )?;
    858     validate_oid(&retirement.transfer_donor_tree, "application donor tree")?;
    859     validate_oid(&retirement.target_revision, "application target revision")?;
    860     validate_sha256(&retirement.source_catalog_sha256, "former catalog digest")?;
    861     validate_sha256(
    862         &retirement.transfer_evidence_sha256,
    863         "transfer evidence digest",
    864     )?;
    865     let mut names = BTreeSet::new();
    866     for transferred in &retirement.package {
    867         let former = &transferred.former_catalog_entry;
    868         if !RETIRED_APPLICATION_PACKAGES.contains(&former.name.as_str())
    869             || !names.insert(former.name.as_str())
    870             || !catalog.retired_packages.contains(&former.name)
    871             || catalog
    872                 .package
    873                 .iter()
    874                 .any(|package| package.name == former.name)
    875             || former.state != "active"
    876             || former.publish
    877             || former.license != "GPL-3.0-or-later"
    878             || former.path != format!("crates/{}", former.name.trim_start_matches("radroots_"))
    879             || transferred.target_name != former.name.replacen("radroots_mobile_", "tera_", 1)
    880             || transferred.target_path != format!("core/crates/{}", transferred.target_name)
    881         {
    882             return Err(
    883                 "application package retirement or retained attribution drifted".to_owned(),
    884             );
    885         }
    886         validate_package_provenance(former)?;
    887         validate_oid(
    888             &transferred.projected_commit,
    889             "application projected commit",
    890         )?;
    891         validate_oid(&transferred.projected_tree, "application projected tree")?;
    892         match fs::symlink_metadata(root.join(&former.path)) {
    893             Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
    894             Err(error) => return Err(format!("inspect retired application path: {error}")),
    895             Ok(_) => {
    896                 return Err(format!(
    897                     "retired application path {} remains present",
    898                     former.path
    899                 ));
    900             }
    901         }
    902     }
    903     Ok(())
    904 }
    905 
    906 fn validate_metadata(
    907     catalog: &Catalog,
    908     metadata: &CargoMetadata,
    909     workspace_root: &Path,
    910 ) -> Result<(), String> {
    911     let workspace_ids = metadata
    912         .workspace_members
    913         .iter()
    914         .map(String::as_str)
    915         .collect::<BTreeSet<_>>();
    916     let workspace_packages = metadata
    917         .packages
    918         .iter()
    919         .filter(|package| workspace_ids.contains(package.id.as_str()))
    920         .map(|package| (package.name.as_str(), package))
    921         .collect::<BTreeMap<_, _>>();
    922     let catalog_by_name = catalog
    923         .package
    924         .iter()
    925         .map(|package| (package.name.as_str(), package))
    926         .collect::<BTreeMap<_, _>>();
    927     let active = catalog
    928         .package
    929         .iter()
    930         .filter(|package| package.state == "active")
    931         .map(|package| package.name.as_str())
    932         .collect::<BTreeSet<_>>();
    933     if workspace_packages.keys().copied().collect::<BTreeSet<_>>() != active {
    934         return Err("active catalog packages and Cargo workspace members differ".to_owned());
    935     }
    936     for package in catalog
    937         .package
    938         .iter()
    939         .filter(|package| package.state != "active")
    940     {
    941         if workspace_root.join(&package.path).exists() {
    942             return Err(format!(
    943                 "reserved package path {} exists before its catalog activation",
    944                 package.path
    945             ));
    946         }
    947     }
    948     let canonical_root = fs::canonicalize(workspace_root)
    949         .map_err(|error| format!("canonicalize {}: {error}", workspace_root.display()))?;
    950     for (name, cargo) in &workspace_packages {
    951         let package = catalog_by_name[name];
    952         let manifest = Path::new(&cargo.manifest_path);
    953         let relative_manifest = manifest
    954             .strip_prefix(&canonical_root)
    955             .map_err(|_| format!("manifest {} escapes workspace", manifest.display()))?;
    956         let actual_path = relative_manifest
    957             .parent()
    958             .ok_or_else(|| format!("manifest {} has no package root", manifest.display()))?
    959             .to_string_lossy()
    960             .replace('\\', "/");
    961         let cargo_publish = cargo
    962             .publish
    963             .as_ref()
    964             .is_some_and(|registries| !registries.is_empty());
    965         if package.path != actual_path
    966             || package.version != cargo.version
    967             || package.license != cargo.license.as_deref().unwrap_or("")
    968             || package.publish != cargo_publish
    969         {
    970             return Err(format!("catalog and Cargo metadata drifted for {name}"));
    971         }
    972         for dependency in &cargo.dependencies {
    973             validate_foundation_dependency(&dependency.name, dependency.source.as_deref())?;
    974             let Some(target) = catalog_by_name.get(dependency.name.as_str()) else {
    975                 continue;
    976             };
    977             if dependency.path.is_none() || dependency.req != format!("={VERSION}") {
    978                 return Err(format!(
    979                     "first-party dependency {name} -> {} must use path plus ={VERSION}",
    980                     dependency.name
    981                 ));
    982             }
    983             if !package
    984                 .permitted_dependency_tiers
    985                 .iter()
    986                 .any(|tier| tier == &target.tier)
    987             {
    988                 return Err(format!(
    989                     "catalog tier policy forbids {name} -> {} ({:?}, {:?})",
    990                     dependency.name, dependency.kind, dependency.target
    991                 ));
    992             }
    993             if package.visibility == "public_release" && target.visibility != "public_release" {
    994                 return Err(format!(
    995                     "public package {name} depends on private package {}",
    996                     dependency.name
    997                 ));
    998             }
    999             if package.license == "MIT OR Apache-2.0" && target.license.starts_with("GPL-") {
   1000                 return Err(format!(
   1001                     "permissive package {name} depends on GPL package {}",
   1002                     dependency.name
   1003                 ));
   1004             }
   1005         }
   1006     }
   1007     Ok(())
   1008 }
   1009 
   1010 fn validate_active_source_provenance(
   1011     catalog: &Catalog,
   1012     workspace_root: &Path,
   1013 ) -> Result<(), String> {
   1014     for package in catalog
   1015         .package
   1016         .iter()
   1017         .filter(|package| package.state == "active")
   1018     {
   1019         match package.provenance_kind.as_str() {
   1020             "imported"
   1021                 if package.source_repository.as_deref()
   1022                     == Some("https://github.com/radrootslabs/lib") =>
   1023             {
   1024                 validate_imported_source_provenance(package, workspace_root)?;
   1025             }
   1026             "native" => validate_native_source_provenance(package, workspace_root)?,
   1027             _ => {}
   1028         }
   1029     }
   1030     Ok(())
   1031 }
   1032 
   1033 fn validate_imported_source_provenance(
   1034     package: &CatalogPackage,
   1035     workspace_root: &Path,
   1036 ) -> Result<(), String> {
   1037     let source_revision = package
   1038         .source_revision
   1039         .as_deref()
   1040         .ok_or_else(|| format!("imported package {} lacks source revision", package.name))?;
   1041     let source_path = package
   1042         .source_path
   1043         .as_deref()
   1044         .ok_or_else(|| format!("imported package {} lacks source path", package.name))?;
   1045     let expected = package
   1046         .source_tree_sha256
   1047         .as_deref()
   1048         .ok_or_else(|| format!("imported package {} lacks source tree digest", package.name))?;
   1049     let output = Command::new("git")
   1050         .args(["ls-tree", "-r", source_revision, "--", source_path])
   1051         .current_dir(workspace_root)
   1052         .output()
   1053         .map_err(|error| format!("run git ls-tree for {}: {error}", package.name))?;
   1054     if !output.status.success() {
   1055         return Err(format!(
   1056             "source revision for {} is unavailable: {}",
   1057             package.name,
   1058             String::from_utf8_lossy(&output.stderr).trim()
   1059         ));
   1060     }
   1061     if sha256(&output.stdout) != expected {
   1062         return Err(format!(
   1063             "source tree provenance drifted for {}",
   1064             package.name
   1065         ));
   1066     }
   1067     Ok(())
   1068 }
   1069 
   1070 fn validate_native_source_provenance(
   1071     package: &CatalogPackage,
   1072     workspace_root: &Path,
   1073 ) -> Result<(), String> {
   1074     let expected = package
   1075         .introduction_tree_sha256
   1076         .as_deref()
   1077         .ok_or_else(|| format!("native package {} lacks introduction digest", package.name))?;
   1078     let introducing_commit = native_introducing_commit(workspace_root, &package.path)?;
   1079     let actual = if let Some(commit) = introducing_commit {
   1080         committed_tree_digest(workspace_root, &commit, &package.path)?
   1081     } else {
   1082         staged_tree_digest(workspace_root, &package.path)?
   1083     };
   1084     if actual != expected {
   1085         return Err(format!(
   1086             "native introduction tree provenance drifted for {}",
   1087             package.name
   1088         ));
   1089     }
   1090     Ok(())
   1091 }
   1092 
   1093 fn native_introducing_commit(
   1094     workspace_root: &Path,
   1095     package_path: &str,
   1096 ) -> Result<Option<String>, String> {
   1097     let output = Command::new("git")
   1098         .args([
   1099             "log",
   1100             "--format=%H",
   1101             "--diff-filter=A",
   1102             "--reverse",
   1103             "--no-renames",
   1104             "HEAD",
   1105             "--",
   1106             package_path,
   1107         ])
   1108         .current_dir(workspace_root)
   1109         .output()
   1110         .map_err(|error| format!("derive native introduction commit: {error}"))?;
   1111     if !output.status.success() {
   1112         return Err(format!(
   1113             "derive native introduction commit for {package_path}: {}",
   1114             String::from_utf8_lossy(&output.stderr).trim()
   1115         ));
   1116     }
   1117     let stdout = std::str::from_utf8(&output.stdout)
   1118         .map_err(|error| format!("native introduction history is not UTF-8: {error}"))?;
   1119     let Some(commit) = stdout.lines().next() else {
   1120         return Ok(None);
   1121     };
   1122     validate_oid(commit, "native introducing commit")?;
   1123     Ok(Some(commit.to_owned()))
   1124 }
   1125 
   1126 fn committed_tree_digest(
   1127     workspace_root: &Path,
   1128     commit: &str,
   1129     package_path: &str,
   1130 ) -> Result<String, String> {
   1131     let output = Command::new("git")
   1132         .args(["ls-tree", "-r", "-z", commit, "--", package_path])
   1133         .current_dir(workspace_root)
   1134         .output()
   1135         .map_err(|error| format!("read native introduction tree: {error}"))?;
   1136     if !output.status.success() {
   1137         return Err(format!(
   1138             "read native introduction tree for {package_path}: {}",
   1139             String::from_utf8_lossy(&output.stderr).trim()
   1140         ));
   1141     }
   1142     if output.stdout.is_empty() {
   1143         return Err(format!(
   1144             "native introducing commit {commit} has no tree at {package_path}"
   1145         ));
   1146     }
   1147     Ok(sha256(&output.stdout))
   1148 }
   1149 
   1150 fn staged_tree_digest(workspace_root: &Path, package_path: &str) -> Result<String, String> {
   1151     let output = Command::new("git")
   1152         .args(["ls-files", "--stage", "-z", "--", package_path])
   1153         .current_dir(workspace_root)
   1154         .output()
   1155         .map_err(|error| format!("read staged native tree: {error}"))?;
   1156     if !output.status.success() {
   1157         return Err(format!(
   1158             "read staged native tree for {package_path}: {}",
   1159             String::from_utf8_lossy(&output.stderr).trim()
   1160         ));
   1161     }
   1162     let records = canonical_staged_tree_records(&output.stdout)?;
   1163     if records.is_empty() {
   1164         return Err(format!(
   1165             "native package {package_path} has no committed introduction or staged tree"
   1166         ));
   1167     }
   1168     Ok(sha256(&records))
   1169 }
   1170 
   1171 fn canonical_staged_tree_records(input: &[u8]) -> Result<Vec<u8>, String> {
   1172     let mut output = Vec::new();
   1173     for record in input
   1174         .split(|byte| *byte == 0)
   1175         .filter(|record| !record.is_empty())
   1176     {
   1177         let tab = record
   1178             .iter()
   1179             .position(|byte| *byte == b'\t')
   1180             .ok_or_else(|| "staged tree record lacks a path separator".to_owned())?;
   1181         let header = std::str::from_utf8(&record[..tab])
   1182             .map_err(|error| format!("staged tree record header is not UTF-8: {error}"))?;
   1183         let fields = header.split(' ').collect::<Vec<_>>();
   1184         if fields.len() != 3 || fields[2] != "0" {
   1185             return Err("staged native tree must contain only stage-zero records".to_owned());
   1186         }
   1187         let mode = fields[0];
   1188         let oid = fields[1];
   1189         if mode.len() != 6 || !mode.bytes().all(|byte| matches!(byte, b'0'..=b'7')) {
   1190             return Err("staged native tree contains an invalid mode".to_owned());
   1191         }
   1192         validate_oid(oid, "staged native object")?;
   1193         if oid.bytes().all(|byte| byte == b'0') {
   1194             return Err("staged native tree contains an intent-to-add object".to_owned());
   1195         }
   1196         if record[tab + 1..].is_empty() {
   1197             return Err("staged native tree contains an empty path".to_owned());
   1198         }
   1199         let object_type = if mode == "160000" { "commit" } else { "blob" };
   1200         output.extend_from_slice(mode.as_bytes());
   1201         output.push(b' ');
   1202         output.extend_from_slice(object_type.as_bytes());
   1203         output.push(b' ');
   1204         output.extend_from_slice(oid.as_bytes());
   1205         output.push(b'\t');
   1206         output.extend_from_slice(&record[tab + 1..]);
   1207         output.push(0);
   1208     }
   1209     Ok(output)
   1210 }
   1211 
   1212 fn render_projections(catalog: &Catalog, digest: &str) -> Vec<GeneratedArtifact> {
   1213     let mut groups = BTreeMap::<&str, Vec<&str>>::new();
   1214     let mut active_groups = BTreeMap::<&str, Vec<&str>>::new();
   1215     let mut reserved_groups = BTreeMap::<&str, Vec<&str>>::new();
   1216     let mut platforms = BTreeMap::<&str, Vec<&str>>::new();
   1217     let mut public = Vec::new();
   1218     let mut private = Vec::new();
   1219     let mut reserved = Vec::new();
   1220     for package in &catalog.package {
   1221         for group in &package.groups {
   1222             groups.entry(group).or_default().push(&package.name);
   1223             if package.state == "active" {
   1224                 active_groups.entry(group).or_default().push(&package.name);
   1225             } else {
   1226                 reserved_groups
   1227                     .entry(group)
   1228                     .or_default()
   1229                     .push(&package.name);
   1230             }
   1231         }
   1232         for platform in &package.platforms {
   1233             platforms.entry(platform).or_default().push(&package.name);
   1234         }
   1235         if package.visibility == "public_release" {
   1236             public.push(package.name.as_str());
   1237         } else {
   1238             private.push(package.name.as_str());
   1239         }
   1240         if package.state != "active" {
   1241             reserved.push(package.name.as_str());
   1242         }
   1243     }
   1244     sort_map_values(&mut groups);
   1245     sort_map_values(&mut active_groups);
   1246     sort_map_values(&mut reserved_groups);
   1247     sort_map_values(&mut platforms);
   1248     public.sort_unstable();
   1249     private.sort_unstable();
   1250     reserved.sort_unstable();
   1251     vec![
   1252         GeneratedArtifact {
   1253             relative: GROUPS_RELATIVE,
   1254             contents: render_group_projection(
   1255                 digest,
   1256                 &groups,
   1257                 &active_groups,
   1258                 &reserved_groups,
   1259             )
   1260             .into_bytes(),
   1261         },
   1262         GeneratedArtifact {
   1263             relative: PLATFORMS_RELATIVE,
   1264             contents: render_map_projection(
   1265                 "radroots.workspace.platform-inventory.v1",
   1266                 digest,
   1267                 "platform",
   1268                 &platforms,
   1269             )
   1270             .into_bytes(),
   1271         },
   1272         GeneratedArtifact {
   1273             relative: RELEASE_INVENTORY_RELATIVE,
   1274             contents: format!(
   1275                 "schema = \"radroots.workspace.release-inventory.v2\"\ncatalog_sha256 = \"{digest}\"\narchitecture = \"{RELEASE_ID}\"\nversion = \"{VERSION}\"\npublic_packages = {}\nprivate_packages = {}\nreserved_packages = {}\n",
   1276                 toml_array(&public),
   1277                 toml_array(&private),
   1278                 toml_array(&reserved)
   1279             )
   1280             .into_bytes(),
   1281         },
   1282     ]
   1283 }
   1284 
   1285 fn render_group_projection(
   1286     digest: &str,
   1287     groups: &BTreeMap<&str, Vec<&str>>,
   1288     active: &BTreeMap<&str, Vec<&str>>,
   1289     reserved: &BTreeMap<&str, Vec<&str>>,
   1290 ) -> String {
   1291     let mut output = format!(
   1292         "schema = \"radroots.workspace.package-groups.v1\"\ncatalog_sha256 = \"{digest}\"\n"
   1293     );
   1294     for (id, packages) in groups {
   1295         output.push_str(&format!(
   1296             "\n[[group]]\nid = \"{id}\"\npackages = {}\nactive_packages = {}\nreserved_packages = {}\n",
   1297             toml_array(packages),
   1298             toml_array(active.get(id).map(Vec::as_slice).unwrap_or_default()),
   1299             toml_array(reserved.get(id).map(Vec::as_slice).unwrap_or_default()),
   1300         ));
   1301     }
   1302     output
   1303 }
   1304 
   1305 fn render_map_projection(
   1306     schema: &str,
   1307     digest: &str,
   1308     table: &str,
   1309     values: &BTreeMap<&str, Vec<&str>>,
   1310 ) -> String {
   1311     let mut output = format!("schema = \"{schema}\"\ncatalog_sha256 = \"{digest}\"\n");
   1312     for (id, packages) in values {
   1313         output.push_str(&format!(
   1314             "\n[[{table}]]\nid = \"{id}\"\npackages = {}\n",
   1315             toml_array(packages)
   1316         ));
   1317     }
   1318     output
   1319 }
   1320 
   1321 fn toml_array(values: &[&str]) -> String {
   1322     format!(
   1323         "[{}]",
   1324         values
   1325             .iter()
   1326             .map(|value| format!("\"{value}\""))
   1327             .collect::<Vec<_>>()
   1328             .join(", ")
   1329     )
   1330 }
   1331 
   1332 fn sort_map_values(values: &mut BTreeMap<&str, Vec<&str>>) {
   1333     for packages in values.values_mut() {
   1334         packages.sort_unstable();
   1335         packages.dedup();
   1336     }
   1337 }
   1338 
   1339 fn expected_public_packages() -> BTreeSet<&'static str> {
   1340     BTreeSet::from([
   1341         "radroots",
   1342         "radroots_blossom",
   1343         "radroots_core",
   1344         "radroots_event",
   1345         "radroots_event_codec",
   1346         "radroots_geonames",
   1347         "radroots_identity",
   1348         "radroots_nostr",
   1349         "radroots_nostr_connect",
   1350         "radroots_protocol",
   1351         "radroots_sdk",
   1352         "radroots_secrets",
   1353         "radroots_signing",
   1354         "radroots_storage",
   1355         "radroots_storage_sqlite",
   1356         "radroots_sync",
   1357         "radroots_trade",
   1358         "radroots_transport",
   1359         "radroots_transport_nostr",
   1360     ])
   1361 }
   1362 
   1363 fn expected_retired_packages() -> BTreeSet<&'static str> {
   1364     BTreeSet::from([
   1365         "radroots-studio-application",
   1366         "radroots-studio-domain",
   1367         "radroots-studio-ffi",
   1368         "radroots-studio-nostr",
   1369         "radroots-studio-storage",
   1370         "radroots-studio-uniffi-bindgen",
   1371         "radroots_studio_application",
   1372         "radroots_studio_domain",
   1373         "radroots_studio_ffi",
   1374         "radroots_studio_nostr",
   1375         "radroots_studio_preferences",
   1376         "radroots_studio_runtime",
   1377         "radroots_studio_storage",
   1378         "radroots_studio_uniffi_bindgen",
   1379         "radroots_app_bindgen",
   1380         "radroots_app_core",
   1381         "radroots_app_ffi",
   1382         "radroots_app_wasm",
   1383         "radroots_mobile_bindgen",
   1384         "radroots_mobile_core",
   1385         "radroots_mobile_ffi",
   1386         "radroots_mobile_wasm",
   1387         "radroots_sdk_xtask",
   1388     ])
   1389 }
   1390 
   1391 fn validate_unique_identifiers(values: &[String], context: &str) -> Result<(), String> {
   1392     let mut unique = BTreeSet::new();
   1393     for value in values {
   1394         validate_identifier(value, context)?;
   1395         if !unique.insert(value.as_str()) {
   1396             return Err(format!("duplicate {context} {value}"));
   1397         }
   1398     }
   1399     Ok(())
   1400 }
   1401 
   1402 fn validate_identifier(value: &str, context: &str) -> Result<(), String> {
   1403     if value.is_empty()
   1404         || !value
   1405             .bytes()
   1406             .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
   1407     {
   1408         return Err(format!("{context} must use lowercase snake case"));
   1409     }
   1410     Ok(())
   1411 }
   1412 
   1413 fn validate_package_identity(value: &str, context: &str) -> Result<(), String> {
   1414     if value.is_empty()
   1415         || !value.bytes().all(|byte| {
   1416             byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
   1417         })
   1418     {
   1419         return Err(format!("{context} is invalid"));
   1420     }
   1421     Ok(())
   1422 }
   1423 
   1424 fn validate_relative_path(value: &str) -> Result<(), String> {
   1425     let path = Path::new(value);
   1426     if value.is_empty()
   1427         || path.is_absolute()
   1428         || value.contains('\\')
   1429         || value
   1430             .split('/')
   1431             .any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
   1432         || path
   1433             .components()
   1434             .any(|component| !matches!(component, Component::Normal(_)))
   1435     {
   1436         return Err(format!("catalog path {value:?} is unsafe"));
   1437     }
   1438     Ok(())
   1439 }
   1440 
   1441 fn validate_oid(value: &str, context: &str) -> Result<(), String> {
   1442     if value.len() != 40
   1443         || !value
   1444             .bytes()
   1445             .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
   1446     {
   1447         return Err(format!("{context} must be lowercase full 40-hex"));
   1448     }
   1449     Ok(())
   1450 }
   1451 
   1452 fn validate_sha256(value: &str, context: &str) -> Result<(), String> {
   1453     if value.len() != 64
   1454         || !value
   1455             .bytes()
   1456             .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
   1457     {
   1458         return Err(format!("{context} must be lowercase 64-hex"));
   1459     }
   1460     Ok(())
   1461 }
   1462 
   1463 fn source_repository_name(url: &str) -> Result<&str, String> {
   1464     url.strip_prefix("https://github.com/radrootslabs/")
   1465         .filter(|name| {
   1466             !name.is_empty()
   1467                 && name
   1468                     .bytes()
   1469                     .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
   1470         })
   1471         .ok_or_else(|| format!("invalid canonical source repository {url}"))
   1472 }
   1473 
   1474 fn sha256(bytes: &[u8]) -> String {
   1475     format!("{:x}", Sha256::digest(bytes))
   1476 }
   1477 
   1478 #[cfg(test)]
   1479 mod tests {
   1480     use super::*;
   1481     use tempfile::TempDir;
   1482 
   1483     fn checked_in_catalog() -> Catalog {
   1484         parse_file(&crate::workspace_root(), CATALOG_RELATIVE).expect("checked-in catalog")
   1485     }
   1486 
   1487     fn git(root: &Path, args: &[&str]) {
   1488         let output = Command::new("git")
   1489             .args(args)
   1490             .current_dir(root)
   1491             .output()
   1492             .expect("run git fixture command");
   1493         assert!(
   1494             output.status.success(),
   1495             "git {} failed: {}",
   1496             args.join(" "),
   1497             String::from_utf8_lossy(&output.stderr)
   1498         );
   1499     }
   1500 
   1501     fn git_fixture() -> TempDir {
   1502         let root = tempfile::tempdir().expect("temporary git fixture");
   1503         git(root.path(), &["init", "--quiet"]);
   1504         git(root.path(), &["config", "user.name", "Catalog Test"]);
   1505         git(
   1506             root.path(),
   1507             &["config", "user.email", "catalog-test@example.invalid"],
   1508         );
   1509         fs::write(root.path().join("README.md"), "# Fixture\n").expect("baseline file");
   1510         git(root.path(), &["add", "README.md"]);
   1511         git(root.path(), &["commit", "--quiet", "-m", "baseline"]);
   1512         root
   1513     }
   1514 
   1515     fn native_package<'a>(
   1516         catalog: &'a mut Catalog,
   1517         path: &str,
   1518         digest: &str,
   1519     ) -> &'a mut CatalogPackage {
   1520         let package = catalog
   1521             .package
   1522             .iter_mut()
   1523             .find(|package| package.name == "radroots_nostrdb")
   1524             .expect("private package fixture");
   1525         package.path = path.to_owned();
   1526         package.provenance_kind = "native".to_owned();
   1527         package.source_repository = None;
   1528         package.source_revision = None;
   1529         package.source_path = None;
   1530         package.source_tree_sha256 = None;
   1531         package.introduction_tree_sha256 = Some(digest.to_owned());
   1532         package
   1533     }
   1534 
   1535     #[test]
   1536     fn public_inventory_is_exact() {
   1537         assert_eq!(expected_public_packages().len(), 19);
   1538         assert!(expected_public_packages().contains("radroots"));
   1539         assert!(expected_public_packages().contains("radroots_sdk"));
   1540     }
   1541 
   1542     #[test]
   1543     fn paths_and_digests_fail_closed() {
   1544         assert!(validate_relative_path("crates/sdk").is_ok());
   1545         assert!(validate_relative_path("../sdk").is_err());
   1546         assert!(validate_relative_path("crates/./sdk").is_err());
   1547         assert!(validate_sha256(&"a".repeat(64), "digest").is_ok());
   1548         assert!(validate_sha256(&"A".repeat(64), "digest").is_err());
   1549     }
   1550 
   1551     #[test]
   1552     fn provenance_kinds_are_disjoint_and_native_is_unpublished() {
   1553         let mut catalog = checked_in_catalog();
   1554         let imported = catalog
   1555             .package
   1556             .iter_mut()
   1557             .find(|package| package.name == "radroots_core")
   1558             .expect("imported fixture");
   1559         imported.source_revision = None;
   1560         assert!(validate_package_provenance(imported).is_err());
   1561 
   1562         let mut catalog = checked_in_catalog();
   1563         let native = native_package(&mut catalog, "crates/nostrdb", &"a".repeat(64));
   1564         assert!(validate_package_provenance(native).is_ok());
   1565         native.source_revision = Some("a".repeat(40));
   1566         assert!(validate_package_provenance(native).is_err());
   1567 
   1568         let mut catalog = checked_in_catalog();
   1569         let public = catalog
   1570             .package
   1571             .iter_mut()
   1572             .find(|package| package.name == "radroots_core")
   1573             .expect("public fixture");
   1574         public.provenance_kind = "native".to_owned();
   1575         public.source_repository = None;
   1576         public.source_revision = None;
   1577         public.source_path = None;
   1578         public.source_tree_sha256 = None;
   1579         public.introduction_tree_sha256 = Some("a".repeat(64));
   1580         assert!(validate_package_provenance(public).is_err());
   1581     }
   1582 
   1583     #[test]
   1584     fn native_provenance_matches_staged_then_derived_introduction_tree() {
   1585         let root = git_fixture();
   1586         let package_root = root.path().join("crates/native_fixture");
   1587         fs::create_dir_all(&package_root).expect("native package root");
   1588         fs::write(
   1589             package_root.join("Cargo.toml"),
   1590             "[package]\nname='fixture'\n",
   1591         )
   1592         .expect("native manifest");
   1593         fs::write(package_root.join("lib.rs"), "pub fn initial() {}\n").expect("native source");
   1594         git(root.path(), &["add", "crates/native_fixture"]);
   1595 
   1596         let staged =
   1597             staged_tree_digest(root.path(), "crates/native_fixture").expect("staged tree digest");
   1598         assert_eq!(
   1599             native_introducing_commit(root.path(), "crates/native_fixture")
   1600                 .expect("precommit history"),
   1601             None
   1602         );
   1603         let mut catalog = checked_in_catalog();
   1604         let package = native_package(&mut catalog, "crates/native_fixture", &staged);
   1605         validate_native_source_provenance(package, root.path()).expect("staged provenance");
   1606 
   1607         fs::write(package_root.join("lib.rs"), "unstaged change\n").expect("unstaged change");
   1608         validate_native_source_provenance(package, root.path())
   1609             .expect("only the staged introduction is authoritative before commit");
   1610         git(
   1611             root.path(),
   1612             &["commit", "--quiet", "-m", "add native package"],
   1613         );
   1614 
   1615         let introducing = native_introducing_commit(root.path(), "crates/native_fixture")
   1616             .expect("committed history")
   1617             .expect("introducing commit");
   1618         assert_eq!(
   1619             committed_tree_digest(root.path(), &introducing, "crates/native_fixture")
   1620                 .expect("committed introduction digest"),
   1621             staged
   1622         );
   1623         validate_native_source_provenance(package, root.path())
   1624             .expect("derived committed provenance");
   1625 
   1626         git(root.path(), &["add", "crates/native_fixture/lib.rs"]);
   1627         git(
   1628             root.path(),
   1629             &["commit", "--quiet", "-m", "change native package"],
   1630         );
   1631         validate_native_source_provenance(package, root.path())
   1632             .expect("later changes do not rewrite introduction provenance");
   1633         package.introduction_tree_sha256 = Some("b".repeat(64));
   1634         assert!(validate_native_source_provenance(package, root.path()).is_err());
   1635     }
   1636 
   1637     #[test]
   1638     fn native_precommit_provenance_requires_stage_zero_index_records() {
   1639         let root = git_fixture();
   1640         fs::create_dir_all(root.path().join("crates/native_fixture")).expect("native package root");
   1641         fs::write(
   1642             root.path().join("crates/native_fixture/lib.rs"),
   1643             "pub fn unstaged() {}\n",
   1644         )
   1645         .expect("unstaged native source");
   1646         assert!(staged_tree_digest(root.path(), "crates/native_fixture").is_err());
   1647 
   1648         let oid = "a".repeat(40);
   1649         let conflicted = format!("100644 {oid} 1\tcrates/native_fixture/lib.rs\0");
   1650         assert!(canonical_staged_tree_records(conflicted.as_bytes()).is_err());
   1651 
   1652         let intent_to_add = format!(
   1653             "100644 {} 0\tcrates/native_fixture/lib.rs\0",
   1654             "0".repeat(40)
   1655         );
   1656         assert!(canonical_staged_tree_records(intent_to_add.as_bytes()).is_err());
   1657     }
   1658 
   1659     #[test]
   1660     fn generated_maps_are_sorted_and_digest_bound() {
   1661         let mut values = BTreeMap::from([("sdk", vec!["z", "a", "a"])]);
   1662         sort_map_values(&mut values);
   1663         let rendered = render_map_projection("fixture.v1", &"a".repeat(64), "group", &values);
   1664         assert!(rendered.contains("catalog_sha256"));
   1665         assert!(rendered.contains("packages = [\"a\", \"z\"]"));
   1666     }
   1667 
   1668     #[test]
   1669     fn checked_in_catalog_structure_is_valid() {
   1670         validate_catalog(&checked_in_catalog()).expect("catalog structure");
   1671     }
   1672 
   1673     #[test]
   1674     fn retired_application_packages_cannot_return_as_dependencies_or_sources() {
   1675         for name in
   1676             RETIRED_APPLICATION_PACKAGES
   1677                 .into_iter()
   1678                 .chain(["tera", "tera_core", "tera_ffi"])
   1679         {
   1680             assert!(validate_foundation_dependency(name, None).is_err());
   1681         }
   1682         for source in [
   1683             "git+https://github.com/radrootslabs/tera?rev=immutable",
   1684             "https://github.com/radrootslabs/tera.git",
   1685             "ssh://git@github.com/radrootslabs/tera.git",
   1686         ] {
   1687             assert!(validate_foundation_dependency("renamed_application", Some(source)).is_err());
   1688         }
   1689         validate_foundation_dependency("radroots_sdk", None).expect("shared SDK remains owned");
   1690         validate_foundation_dependency("radroots_storage_sqlite", None)
   1691             .expect("shared storage remains owned");
   1692         let catalog = checked_in_catalog();
   1693         validate_application_retirement(&catalog, &crate::workspace_root())
   1694             .expect("retirement is coherent");
   1695         let fixture = tempfile::TempDir::new().expect("retirement fixture");
   1696         let decision = fixture.path().join(APPLICATION_RETIREMENT);
   1697         fs::create_dir_all(decision.parent().expect("decision parent"))
   1698             .expect("decision directory");
   1699         fs::copy(
   1700             crate::workspace_root().join(APPLICATION_RETIREMENT),
   1701             &decision,
   1702         )
   1703         .expect("decision");
   1704         validate_application_retirement(&catalog, fixture.path()).expect("no application source");
   1705         fs::create_dir_all(fixture.path().join("crates/mobile_core")).expect("reintroduced source");
   1706         assert!(validate_application_retirement(&catalog, fixture.path()).is_err());
   1707     }
   1708 
   1709     #[test]
   1710     fn retirement_rejects_missing_duplicate_or_modified_package_evidence() {
   1711         let fixture = tempfile::TempDir::new().expect("retirement fixture");
   1712         let path = fixture.path().join(APPLICATION_RETIREMENT);
   1713         fs::create_dir_all(path.parent().expect("parent")).expect("decision directory");
   1714         let original: serde_json::Value = serde_json::from_slice(
   1715             &fs::read(crate::workspace_root().join(APPLICATION_RETIREMENT)).expect("decision"),
   1716         )
   1717         .expect("decision JSON");
   1718         let catalog = checked_in_catalog();
   1719         for case in 0..5 {
   1720             let mut changed = original.clone();
   1721             match case {
   1722                 0 => {
   1723                     changed["package"].as_array_mut().expect("packages").pop();
   1724                 }
   1725                 1 => {
   1726                     changed["package"][1] = changed["package"][0].clone();
   1727                 }
   1728                 2 => {
   1729                     changed["package"][0]["former_catalog_entry"]["license"] = "MIT".into();
   1730                 }
   1731                 3 => {
   1732                     changed["package"][0]["target_path"] = "../outside".into();
   1733                 }
   1734                 _ => {
   1735                     changed["unknown"] = true.into();
   1736                 }
   1737             }
   1738             fs::write(&path, serde_json::to_vec(&changed).expect("encode"))
   1739                 .expect("altered decision");
   1740             assert!(validate_application_retirement(&catalog, fixture.path()).is_err());
   1741         }
   1742         let mut reactivated = checked_in_catalog();
   1743         reactivated
   1744             .retired_packages
   1745             .retain(|name| name != "radroots_mobile_core");
   1746         assert!(validate_catalog(&reactivated).is_err());
   1747     }
   1748 
   1749     #[test]
   1750     fn catalog_rejects_version_visibility_license_and_retirement_drift() {
   1751         let mut catalog = checked_in_catalog();
   1752         catalog
   1753             .package
   1754             .iter_mut()
   1755             .find(|package| package.name == "xtask")
   1756             .expect("xtask")
   1757             .publish = true;
   1758         assert!(validate_catalog(&catalog).is_err());
   1759 
   1760         let mut catalog = checked_in_catalog();
   1761         catalog
   1762             .package
   1763             .iter_mut()
   1764             .find(|package| package.name == "radroots_core")
   1765             .expect("core")
   1766             .license = "GPL-3.0-only".to_owned();
   1767         assert!(validate_catalog(&catalog).is_err());
   1768 
   1769         let mut catalog = checked_in_catalog();
   1770         catalog
   1771             .package
   1772             .iter_mut()
   1773             .find(|package| package.name == "radroots_sdk_ffi")
   1774             .expect("sdk ffi")
   1775             .version = "0.1.0-alpha.1".to_owned();
   1776         assert!(validate_catalog(&catalog).is_err());
   1777 
   1778         let mut catalog = checked_in_catalog();
   1779         catalog.retired_packages.push("radroots_core".to_owned());
   1780         assert!(validate_catalog(&catalog).is_err());
   1781     }
   1782 
   1783     #[test]
   1784     fn catalog_rejects_naming_path_and_package_count_drift() {
   1785         let mut catalog = checked_in_catalog();
   1786         catalog.package_count -= 1;
   1787         assert!(validate_catalog(&catalog).is_err());
   1788 
   1789         let mut catalog = checked_in_catalog();
   1790         catalog.package[0].name = "radroots-bad-name".to_owned();
   1791         assert!(validate_catalog(&catalog).is_err());
   1792 
   1793         let mut catalog = checked_in_catalog();
   1794         catalog.package[0].path = "../escape".to_owned();
   1795         assert!(validate_catalog(&catalog).is_err());
   1796     }
   1797 
   1798     #[test]
   1799     fn metadata_rejects_floating_sources_and_tier_edges() {
   1800         let workspace_root = crate::workspace_root();
   1801         let catalog = checked_in_catalog();
   1802         let mut metadata = cargo_metadata(&workspace_root).expect("cargo metadata");
   1803         let dependency = metadata
   1804             .packages
   1805             .iter_mut()
   1806             .find(|package| package.name == "radroots_event")
   1807             .and_then(|package| {
   1808                 package
   1809                     .dependencies
   1810                     .iter_mut()
   1811                     .find(|dependency| dependency.name == "radroots_core")
   1812             })
   1813             .expect("event to core dependency");
   1814         dependency.path = None;
   1815         dependency.req = "*".to_owned();
   1816         assert!(validate_metadata(&catalog, &metadata, &workspace_root).is_err());
   1817 
   1818         let mut catalog = checked_in_catalog();
   1819         catalog
   1820             .package
   1821             .iter_mut()
   1822             .find(|package| package.name == "radroots_event")
   1823             .expect("event")
   1824             .permitted_dependency_tiers
   1825             .retain(|tier| tier != "foundation");
   1826         let metadata = cargo_metadata(&workspace_root).expect("cargo metadata");
   1827         assert!(validate_metadata(&catalog, &metadata, &workspace_root).is_err());
   1828     }
   1829 }