lib

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

retired_surface.rs (6583B)


      1 use std::{
      2     collections::BTreeSet,
      3     fs,
      4     path::{Path, PathBuf},
      5 };
      6 
      7 const RELEASE_POLICY: &str = include_str!("../../../contracts/releases/publish_policy.toml");
      8 
      9 const RETIRED_IDENTIFIERS: &[&str] = &[
     10     "IdentityError",
     11     "RadrootsEncryptedIdentityFile",
     12     "RadrootsIdentity",
     13     "RadrootsIdentityEncryptedSecretKeyOptions",
     14     "RadrootsIdentityEncryptedSecretKeySecurity",
     15     "RadrootsIdentityFile",
     16     "RadrootsIdentityId",
     17     "RadrootsIdentityProfile",
     18     "RadrootsIdentityPublic",
     19     "RadrootsIdentitySecretKeyFormat",
     20     concat!("Radroots", "PublicKey"),
     21 ];
     22 
     23 const RETIRED_FUNCTIONS_AND_CONSTANTS: &[&str] = &[
     24     "DEFAULT_IDENTITY_PATH",
     25     "RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT",
     26     "RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX",
     27     "encrypted_identity_wrapping_key_path",
     28     "load_encrypted_identity",
     29     "load_identity_profile",
     30     "rotate_encrypted_identity",
     31     "store_encrypted_identity",
     32     "store_identity_profile",
     33 ];
     34 
     35 #[test]
     36 fn first_party_production_sources_do_not_restore_the_retired_identity_surface() {
     37     let workspace_root = workspace_root();
     38     let mut sources = Vec::new();
     39     collect_production_rust_sources(&workspace_root.join("crates"), &mut sources);
     40     assert!(
     41         !sources.is_empty(),
     42         "workspace production sources are required"
     43     );
     44 
     45     let mut findings = Vec::new();
     46     for path in sources {
     47         let source = fs::read_to_string(&path).expect("read production source");
     48         for (line_index, line) in source.lines().enumerate() {
     49             if line.trim_start().starts_with("//") {
     50                 continue;
     51             }
     52             for retired in RETIRED_IDENTIFIERS {
     53                 if contains_identifier(line, retired) {
     54                     findings.push(format!(
     55                         "{}:{} restores retired identity identifier `{retired}`",
     56                         relative_path(&workspace_root, &path),
     57                         line_index + 1,
     58                     ));
     59                 }
     60             }
     61             for retired in RETIRED_FUNCTIONS_AND_CONSTANTS {
     62                 if contains_identifier(line, retired) {
     63                     findings.push(format!(
     64                         "{}:{} restores retired identity operation `{retired}`",
     65                         relative_path(&workspace_root, &path),
     66                         line_index + 1,
     67                     ));
     68                 }
     69             }
     70         }
     71     }
     72 
     73     assert!(
     74         findings.is_empty(),
     75         "retired identity surface violations:\n{}",
     76         findings.join("\n")
     77     );
     78 }
     79 
     80 #[test]
     81 fn retired_identity_implementation_modules_are_absent() {
     82     let identity_source = workspace_root().join("crates/identity/src");
     83     for retired in ["identity.rs", "storage.rs", "test_fixtures.rs"] {
     84         assert!(
     85             !identity_source.join(retired).exists(),
     86             "retired identity module must remain absent: {retired}"
     87         );
     88     }
     89 }
     90 
     91 #[test]
     92 fn release_policy_enables_replacement_and_keeps_retired_mixed_packages_absent() {
     93     let publication = table(RELEASE_POLICY, "[publication]");
     94     assert!(
     95         publication.contains("frozen = false")
     96             && publication.contains("registry = \"crates-io\"")
     97             && publication.contains("final_enablement_step = 305"),
     98         "publication validation must remain enabled only through the approved Step 305 policy"
     99     );
    100 
    101     let approved = string_array(RELEASE_POLICY, "[publication]", "approved_packages");
    102     assert!(approved.contains("radroots_identity"));
    103     for mixed in [
    104         "radroots_authority",
    105         "radroots_nostr_accounts",
    106         "radroots_nostr_signer",
    107     ] {
    108         assert!(
    109             !approved.contains(mixed),
    110             "mixed package must not be approved for publication: {mixed}"
    111         );
    112         let package_directory = workspace_root()
    113             .join("crates")
    114             .join(mixed.strip_prefix("radroots_").expect("package prefix"));
    115         assert!(
    116             !package_directory.exists(),
    117             "retired mixed package must remain absent: {}",
    118             package_directory.display()
    119         );
    120     }
    121 }
    122 
    123 fn workspace_root() -> PathBuf {
    124     Path::new(env!("CARGO_MANIFEST_DIR"))
    125         .parent()
    126         .and_then(Path::parent)
    127         .expect("lib workspace root")
    128         .to_path_buf()
    129 }
    130 
    131 fn collect_production_rust_sources(directory: &Path, paths: &mut Vec<PathBuf>) {
    132     for entry in fs::read_dir(directory).expect("read workspace source directory") {
    133         let path = entry.expect("workspace source entry").path();
    134         if path.is_dir() {
    135             if path.file_name().and_then(|name| name.to_str()) != Some("target") {
    136                 collect_production_rust_sources(&path, paths);
    137             }
    138         } else if path.extension().and_then(|extension| extension.to_str()) == Some("rs")
    139             && path
    140                 .components()
    141                 .any(|component| component.as_os_str() == "src")
    142         {
    143             paths.push(path);
    144         }
    145     }
    146 }
    147 
    148 fn contains_identifier(source: &str, identifier: &str) -> bool {
    149     source.match_indices(identifier).any(|(index, _)| {
    150         let before = source[..index].chars().next_back();
    151         let after = source[index + identifier.len()..].chars().next();
    152         before.is_none_or(|character| !is_identifier_character(character))
    153             && after.is_none_or(|character| !is_identifier_character(character))
    154     })
    155 }
    156 
    157 fn is_identifier_character(character: char) -> bool {
    158     character == '_' || character.is_ascii_alphanumeric()
    159 }
    160 
    161 fn table<'a>(source: &'a str, heading: &str) -> &'a str {
    162     let body = source
    163         .split_once(heading)
    164         .unwrap_or_else(|| panic!("missing table {heading}"))
    165         .1;
    166     body.split_once("\n[").map_or(body, |(current, _)| current)
    167 }
    168 
    169 fn string_array<'a>(source: &'a str, heading: &str, key: &str) -> BTreeSet<&'a str> {
    170     let table = table(source, heading);
    171     let marker = format!("{key} = [");
    172     let values = table
    173         .split_once(&marker)
    174         .unwrap_or_else(|| panic!("missing {heading} {key}"))
    175         .1
    176         .split_once(']')
    177         .unwrap_or_else(|| panic!("unterminated {heading} {key}"))
    178         .0;
    179     values
    180         .split(',')
    181         .map(str::trim)
    182         .filter_map(|value| {
    183             value
    184                 .strip_prefix('"')
    185                 .and_then(|value| value.strip_suffix('"'))
    186         })
    187         .collect()
    188 }
    189 
    190 fn relative_path(root: &Path, path: &Path) -> String {
    191     path.strip_prefix(root)
    192         .expect("source path is under workspace root")
    193         .to_string_lossy()
    194         .replace('\\', "/")
    195 }