cacrt 0.1.1

Curated, no_std/no-alloc access to DER-encoded CA root certificates by OpenSSL subject hash
Documentation
//! Build-time code generation for `cacrt`.
//!
//! Reads the curated `roots/*.pem` set, computes each certificate's OpenSSL
//! subject hash, validates it against the curation rules, and emits a sorted,
//! indexed static table to `$OUT_DIR/certs.rs` (plus the raw DER blobs under
//! `$OUT_DIR/der/`). The library `include!`s that file; nothing here ends up in
//! the runtime, which stays `#![no_std]` with no `alloc`.

#[path = "build_support/mod.rs"]
mod build_support;

use build_support::{curation, der, pem, subject_hash};
use std::fmt::Write as _;
use std::path::Path;

struct Entry {
    subject_hash: u32,
    der: Vec<u8>,
    subject: Vec<u8>,
    label: String,
}

fn main() {
    let manifest = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
    let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR");
    let roots = Path::new(&manifest).join("roots");

    // Rebuild when the root set or the generators change.
    println!("cargo:rerun-if-changed=roots");
    println!("cargo:rerun-if-changed=build_support");
    println!("cargo:rerun-if-changed=build.rs");

    let now = curation::now_yyyymmddhhmmss();
    let mut entries = Vec::new();

    // Roots are organized one folder per operating company, so walk recursively.
    let mut pems = Vec::new();
    collect_pems(&roots, &mut pems);
    pems.sort();
    for path in &pems {
        println!("cargo:rerun-if-changed={}", path.display());
        let file = path.file_name().unwrap().to_string_lossy().into_owned();
        let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{file}: {e}"));

        let der = pem::read_one_certificate(&text).unwrap_or_else(|e| panic!("{file}: {e}"));
        let hash = subject_hash(&der).unwrap_or_else(|e| panic!("{file}: subject hash: {e}"));

        let tbs = der::tbs_certificate(&der).unwrap_or_else(|e| panic!("{file}: {e}"));
        let subject = der::tbs_field(tbs, der::TbsField::Subject)
            .unwrap_or_else(|e| panic!("{file}: subject: {e}"))
            .to_vec();

        // Keep roots/ honest: a committed cert that fails a machine-checkable
        // rule is surfaced as a build warning (CI runs `verify` to hard-fail).
        if let Err(reason) = curation::check_cert(&der, now) {
            println!("cargo:warning=roots/{file}: {reason}");
        }

        let label = parse_label(&text).unwrap_or_else(|| stem(&file));
        entries.push(Entry {
            subject_hash: hash,
            der,
            subject,
            label,
        });
    }

    assert!(!entries.is_empty(), "no roots found in {}", roots.display());

    // Sort by (subject_hash, subject, der) for a stable, content-addressed order
    // so the generated table — and the collision sequence numbers — are
    // deterministic regardless of filesystem iteration order.
    entries.sort_by(|a, b| {
        (a.subject_hash, &a.subject, &a.der).cmp(&(b.subject_hash, &b.subject, &b.der))
    });

    // Assign collision sequence numbers within each subject-hash group.
    let mut seqs = vec![0u8; entries.len()];
    for i in 0..entries.len() {
        if i > 0 && entries[i].subject_hash == entries[i - 1].subject_hash {
            seqs[i] = seqs[i - 1] + 1;
        }
    }

    // Write DER blobs and the generated table.
    let der_dir = Path::new(&out_dir).join("der");
    std::fs::create_dir_all(&der_dir).expect("create der dir");

    let mut src = String::new();
    src.push_str("// @generated by build.rs from roots/*.pem — do not edit.\n");
    writeln!(src, "pub(crate) static CERTS: &[Cert] = &[").unwrap();
    for (i, e) in entries.iter().enumerate() {
        let der_path = der_dir.join(format!("{i:04}.der"));
        std::fs::write(&der_path, &e.der).expect("write der blob");
        writeln!(
            src,
            "    // {} ({:08x}.{})",
            e.label, e.subject_hash, seqs[i]
        )
        .unwrap();
        writeln!(
            src,
            "    Cert::new(0x{:08x}, {}, include_bytes!(concat!(env!(\"OUT_DIR\"), \"/der/{:04}.der\")), &{:?}, {:?}),",
            e.subject_hash, seqs[i], i, e.subject, e.label
        )
        .unwrap();
    }
    writeln!(src, "];").unwrap();

    std::fs::write(Path::new(&out_dir).join("certs.rs"), src).expect("write certs.rs");
}

/// Recursively collect every `*.pem` under `dir` (roots are nested one folder
/// per company). Emits `rerun-if-changed` for each directory so that adding or
/// removing a root triggers a rebuild.
fn collect_pems(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
    println!("cargo:rerun-if-changed={}", dir.display());
    let entries =
        std::fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()));
    for ent in entries {
        let path = ent.expect("dir entry").path();
        if path.is_dir() {
            collect_pems(&path, out);
        } else if path.extension().and_then(|s| s.to_str()) == Some("pem") {
            // Only *.pem is compiled in. A retired root is renamed to
            // `*.disabled` so it stays in the repo (audit trail) but is excluded
            // from the store — see CURATION.md "Disabling a root".
            out.push(path);
        }
    }
}

/// Pull the friendly name out of the `# Label: ...` PEM header comment.
fn parse_label(text: &str) -> Option<String> {
    text.lines()
        .find_map(|l| l.trim_start().strip_prefix("# Label:"))
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}

fn stem(file: &str) -> String {
    file.strip_suffix(".pem").unwrap_or(file).to_string()
}