crtx-ledger 0.1.1

Append-only event log, hash chain, trace assembly, and audit records.
Documentation
//! Build-time validation helpers for the embedded Sigstore trusted-root
//! snapshot. Factored out of `build.rs` so the logic is unit-testable
//! against synthetic directory layouts (see
//! `tests/build_validation.rs`).
//!
//! Scope of this module:
//!
//! 1. Enumerate `sigstore_trusted_root_YYYY-MM-DD.json` candidates in a
//!    directory.
//! 2. Refuse symlinked candidates outright — closes Red Team v2 F2-S1.
//!    An attacker with checkout-time write access could otherwise drop a
//!    name-friendly symlink pointing at attacker-controlled JSON; the
//!    "exactly one match" gate would pass and `include_bytes!` would
//!    silently follow the link.
//! 3. Validate that the `YYYY-MM-DD` segment is a real calendar date via
//!    `chrono::NaiveDate::parse_from_str` — closes Code Review v2 F3.
//!    Catches `…_2026-02-30.json` at build time instead of at runtime
//!    inside `TrustRootStalenessAnchor::resolve`.
//!
//! The module is `#[path]`-included by both `build.rs` and the
//! integration test crate; it MUST stay self-contained (std + chrono
//! only) and side-effect free (no `println!("cargo:…")`).

use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};

use chrono::NaiveDate;

/// Canonical filename prefix for the embedded snapshot.
pub const FILENAME_PREFIX: &str = "sigstore_trusted_root_";
/// Canonical filename suffix for the embedded snapshot.
pub const FILENAME_SUFFIX: &str = ".json";

/// A single accepted candidate after symlink + date validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbeddedSnapshot {
    /// `YYYY-MM-DD` date extracted from the filename, already validated
    /// as a real calendar date.
    pub date: String,
    /// Bare filename (no directory component) as observed on disk.
    pub filename: String,
}

/// Errors that can prevent a clean build-time discovery.
#[derive(Debug)]
pub enum DiscoveryError {
    /// `fs::read_dir` itself failed (e.g. directory missing).
    ReadDir { dir: PathBuf, err: std::io::Error },
    /// Iterating an entry yielded an I/O error.
    Entry { dir: PathBuf, err: std::io::Error },
    /// `entry.file_type()` failed — typically a TOCTOU race or
    /// missing-file mid-scan.
    FileType { path: PathBuf, err: std::io::Error },
    /// A candidate whose name matches the pattern is a symlink. Refused
    /// to defend against checkout-time link planting (Red Team v2 F2-S1).
    Symlink { path: PathBuf },
    /// The filename's date segment failed `chrono::NaiveDate` parsing
    /// (e.g. `2026-02-30`). Includes both the offending filename and the
    /// raw segment so the build error is actionable.
    InvalidCalendarDate {
        filename: String,
        date_segment: String,
        err: chrono::ParseError,
    },
    /// Zero candidates remained after filtering — the embedded floor is
    /// missing.
    NoCandidates { dir: PathBuf },
    /// Two or more candidates remained — operator intent is ambiguous.
    MultipleCandidates {
        dir: PathBuf,
        filenames: Vec<String>,
    },
}

impl std::fmt::Display for DiscoveryError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ReadDir { dir, err } => write!(
                f,
                "cannot read `{}` to discover the embedded Sigstore trusted_root.json snapshot: {err}",
                dir.display()
            ),
            Self::Entry { dir, err } => {
                write!(f, "I/O error iterating `{}`: {err}", dir.display())
            }
            Self::FileType { path, err } => write!(
                f,
                "cannot determine file type for `{}`: {err}",
                path.display()
            ),
            Self::Symlink { path } => write!(
                f,
                "refusing to follow symlinked snapshot candidate `{}` — \
the embedded TUF trust-root floor MUST be a plain regular file in the \
source tree (Red Team v2 F2-S1: a name-friendly symlink planted at \
checkout time could redirect `include_bytes!` to attacker-controlled \
JSON). Replace the symlink with the actual `sigstore_trusted_root_\
YYYY-MM-DD.json` payload or delete it.",
                path.display()
            ),
            Self::InvalidCalendarDate {
                filename,
                date_segment,
                err,
            } => write!(
                f,
                "embedded snapshot `{filename}` has a syntactically \
`YYYY-MM-DD`-shaped but calendar-invalid date `{date_segment}` \
({err}) — refresh-trust authors MUST use a real date (e.g. \
`2026-02-28`, not `2026-02-30`); the runtime \
`TrustRootStalenessAnchor::resolve` `NaiveDate::parse_from_str` would \
otherwise fail at first call."
            ),
            Self::NoCandidates { dir } => write!(
                f,
                "found zero `sigstore_trusted_root_YYYY-MM-DD.json` \
snapshots under `{}` — the embedded TUF trust-root floor cannot be \
empty (ADR 0013 Mechanism C)",
                dir.display()
            ),
            Self::MultipleCandidates { dir, filenames } => write!(
                f,
                "found {n} `sigstore_trusted_root_YYYY-MM-DD.json` \
snapshots under `{}` ({}) — operator intent is ambiguous; remove all \
but the active snapshot before building",
                dir.display(),
                filenames.join(", "),
                n = filenames.len()
            ),
        }
    }
}

impl std::error::Error for DiscoveryError {}

/// Result of a successful directory scan: the unique accepted snapshot
/// plus the bare filenames that triggered `rerun-if-changed` directives
/// at the call site.
#[derive(Debug, Clone)]
pub struct Discovery {
    pub snapshot: EmbeddedSnapshot,
    /// Every candidate whose filename matched the pattern AND was a
    /// regular file. In a successful build this is a single-element
    /// vector equal to `[snapshot.filename]`.
    pub matched_filenames: Vec<String>,
}

/// Enumerate `dir`, refusing symlinks and non-calendar dates. Returns
/// the unique accepted snapshot or a structured error.
///
/// **Symlink policy (Red Team v2 F2-S1):** any candidate whose
/// `file_type()` reports `is_symlink()` is refused immediately. This is
/// stricter than necessary — a symlink that resolves to a regular file
/// inside the source tree would technically work — but the build script
/// has no business resolving link targets across operator-controlled
/// paths, and accepting any symlink would re-open the attack surface.
///
/// **Date policy (Code Review v2 F3):** the `YYYY-MM-DD` segment is
/// parsed by `chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")`.
/// `%Y` requires four digits and chrono rejects non-existent calendar
/// dates such as `2026-02-30`.
pub fn discover_embedded_snapshot(dir: &Path) -> Result<Discovery, DiscoveryError> {
    let entries = fs::read_dir(dir).map_err(|err| DiscoveryError::ReadDir {
        dir: dir.to_path_buf(),
        err,
    })?;

    let mut matched: Vec<EmbeddedSnapshot> = Vec::new();

    for entry in entries {
        let entry = entry.map_err(|err| DiscoveryError::Entry {
            dir: dir.to_path_buf(),
            err,
        })?;
        let file_name: OsString = entry.file_name();
        let name_str = match file_name.to_str() {
            Some(s) => s,
            None => continue, // Non-UTF-8 names are never our snapshot.
        };
        let Some(date_and_suffix) = name_str.strip_prefix(FILENAME_PREFIX) else {
            continue;
        };
        let Some(date_segment) = date_and_suffix.strip_suffix(FILENAME_SUFFIX) else {
            continue;
        };

        // Symlink refusal happens BEFORE date validation so an attacker
        // cannot mask a link-planting attempt by also using a bogus
        // date segment.
        let file_type = entry.file_type().map_err(|err| DiscoveryError::FileType {
            path: entry.path(),
            err,
        })?;
        if file_type.is_symlink() {
            return Err(DiscoveryError::Symlink { path: entry.path() });
        }

        // Calendar-date validation. `%Y-%m-%d` requires exactly four
        // digits + two + two and rejects e.g. `2026-02-30`.
        if let Err(err) = NaiveDate::parse_from_str(date_segment, "%Y-%m-%d") {
            return Err(DiscoveryError::InvalidCalendarDate {
                filename: name_str.to_string(),
                date_segment: date_segment.to_string(),
                err,
            });
        }

        matched.push(EmbeddedSnapshot {
            date: date_segment.to_string(),
            filename: name_str.to_string(),
        });
    }

    match matched.len() {
        0 => Err(DiscoveryError::NoCandidates {
            dir: dir.to_path_buf(),
        }),
        1 => {
            let snapshot = matched.remove(0);
            let matched_filenames = vec![snapshot.filename.clone()];
            Ok(Discovery {
                snapshot,
                matched_filenames,
            })
        }
        _ => {
            let filenames = matched.into_iter().map(|m| m.filename).collect();
            Err(DiscoveryError::MultipleCandidates {
                dir: dir.to_path_buf(),
                filenames,
            })
        }
    }
}