entropyfs 0.7.15

Entropy-native Linux filesystem: persist irreducible state, materialize structure, preserve exact bytes.
//! Phase 12E.4: the historical golden-store compatibility court.
//!
//! # What this court proves
//!
//! Every sealed historical store under `testdata/golden/<era>/` — written
//! by HISTORICAL EntropyFS binaries (v0.3.0, v0.5.2, v0.6.3; never
//! regenerated by the current encoder) — must, on the CURRENT build:
//!
//! ```text
//! open (Store::open)                     succeeds
//! feature compatibility decision         Ok (every fixture bit supported)
//! fsck                                   clean
//! enumeration                            every manifest file resolves
//! materialization                        byte-exact (BLAKE3 == manifest)
//! snapshot visibility                    n/a for these eras (v1 has no
//!                                        committed snapshots in fixtures)
//! ```
//!
//! A future release that cannot decode a supported golden fixture FAILS
//! this test — that is the CI gate. The fixture bytes are additionally
//! pinned by `store_dir_hash`, so an accidental regeneration or bit rot
//! is caught, not silently accepted.
//!
//! # Manifest format (line-based, written by the historical test itself)
//!
//! ```text
//! era = v0.3.0
//! creator_version = 0.3.0
//! creator_revision = <git rev>          (appended by the driver)
//! format_major = 1
//! format_minor = 0
//! incompat = 0x…                        (the era's self-report)
//! ro_compat = 0x…
//! compat = 0x…
//! file = <name> <size> <blake3hex>      (logical fixture manifest)
//! store_dir_hash = <sha256-lines>       (appended by the driver)
//! ```
//!
//! The `store_dir_hash` algorithm (pinned to the driver's shell
//! computation, which uses `sha256sum`): for the store dir's files
//! sorted by relative path (excluding the flock `lock` file), the
//! per-file `sha256` hex + two spaces + relative path, joined by
//! newlines, then `sha256` of those bytes, hex.
//!
//! # Unsupported-eras policy
//!
//! Only v1-era stores are supported (the format has been v1 since the
//! first commit). Pre-store and pre-v1 experimental versions never had a
//! persistent format to decode; this is a deliberate, documented
//! decision, not accidental bit rot.

#![forbid(unsafe_code)]

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use sha2::Digest;

use crate::store::{Store, StoreConfig};

/// Parse the line-based fixture manifest.
fn parse_manifest(text: &str) -> BTreeMap<String, String> {
    let mut m = BTreeMap::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((k, v)) = line.split_once('=') {
            m.insert(k.trim().to_string(), v.trim().to_string());
        }
    }
    m
}

/// The fixture's logical files: name → (size, blake3 hex).
fn fixture_files(m: &BTreeMap<String, String>) -> Vec<(String, usize, String)> {
    let mut out = Vec::new();
    for (k, v) in m {
        if let Some(rest) = k.strip_prefix("file ") {
            // Manifest lines are `file = name size hash`; the parser above
            // splits on the FIRST '=' so the key is "file " and the value
            // is "name size hash".
            let _ = rest;
        }
        if k == "file" {
            let parts: Vec<&str> = v.split_whitespace().collect();
            if parts.len() == 3 {
                out.push((
                    parts[0].to_string(),
                    parts[1].parse::<usize>().expect("size"),
                    parts[2].to_string(),
                ));
            }
        }
    }
    out.sort();
    out
}

/// Recompute the store-dir hash exactly as the driver's shell pipeline
/// produced it: sorted relative paths (excluding `lock`), per-file
/// `sha256` hex + two spaces + path, newline-joined, then sha256.
fn store_dir_hash(store_dir: &Path) -> String {
    let mut rels: Vec<PathBuf> = Vec::new();
    let mut stack = vec![store_dir.to_path_buf()];
    while let Some(dir) = stack.pop() {
        for entry in std::fs::read_dir(&dir).expect("read store dir") {
            let path = entry.expect("entry").path();
            if path.is_dir() {
                stack.push(path);
            } else if path.file_name().and_then(|n| n.to_str()) != Some("lock") {
                rels.push(path);
            }
        }
    }
    rels.sort();
    let mut lines = String::new();
    for p in rels {
        let bytes = std::fs::read(&p).expect("read file");
        let h = hex_str(&sha2::Sha256::digest(&bytes));
        let rel = format!(
            "./{}",
            p.strip_prefix(store_dir)
                .expect("under store dir")
                .to_string_lossy()
                .replace('\\', "/")
        );
        lines.push_str(&format!("{h}  {rel}\n"));
    }
    hex_str(&sha2::Sha256::digest(lines.as_bytes()))
}

fn hex_str(b: &[u8]) -> String {
    b.iter().map(|x| format!("{x:02x}")).collect()
}

/// Court over one era fixture.
fn court_era(era: &str) {
    let base = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("testdata")
        .join("golden")
        .join(era);
    let manifest_text = std::fs::read_to_string(base.join("manifest.txt"))
        .unwrap_or_else(|e| panic!("{era}: missing manifest.txt: {e}"));
    let m = parse_manifest(&manifest_text);
    let store_dir = base.join("store");

    // --- manifest self-consistency ---
    assert_eq!(
        m.get("era").map(|s| s.as_str()),
        Some(era),
        "{era}: era mismatch"
    );
    let creator: &str = m.get("creator_version").map(|s| s.as_str()).unwrap_or("?");
    let expected_creator = match era {
        "v0.3.0" => "0.3.0",
        "v0.5.2" => "0.5.2",
        "v0.6.3" => "0.6.3",
        _ => panic!("{era}: unknown era"),
    };
    assert_eq!(creator, expected_creator, "{era}: creator_version mismatch");
    assert!(
        m.contains_key("creator_revision"),
        "{era}: missing creator_revision"
    );

    // --- open + compatibility decision ---
    let config = StoreConfig::default();
    let store = Store::open(&store_dir, &config)
        .unwrap_or_else(|e| panic!("{era}: Store::open failed: {e}"));
    let root = store.current_root();
    let fmaj: u16 = m
        .get("format_major")
        .expect("format_major")
        .parse()
        .expect("major");
    let fmin: u16 = m
        .get("format_minor")
        .expect("format_minor")
        .parse()
        .expect("minor");
    assert_eq!(root.format_major, fmaj, "{era}: format major");
    assert_eq!(root.format_minor, fmin, "{era}: format minor");
    // Every bit the fixture carries must be supported by this build (a
    // future release that drops a fixture's representation fails HERE).
    let bits = store.feature_bits();
    match crate::format::features::check_with_version(bits, true, fmaj, fmin) {
        crate::format::features::Compatibility::Ok => {}
        other => panic!("{era}: compatibility decision must be Ok, got {other:?}"),
    }

    // --- fsck clean ---
    let report = crate::fsck::fsck(&store_dir, &crate::fsck::FsckOptions::default())
        .unwrap_or_else(|e| panic!("{era}: fsck failed: {e}"));
    assert!(
        report.is_clean(),
        "{era}: fsck must be clean: {:?}",
        report.issues
    );

    // --- enumeration + materialization + byte-exact output ---
    for (name, size, hash) in fixture_files(&m) {
        let entry = store
            .dir_lookup(1, name.as_bytes())
            .unwrap_or_else(|e| panic!("{era}: lookup {name}: {e}"))
            .unwrap_or_else(|| panic!("{era}: file {name} missing from root"));
        let inode = store
            .get_inode(entry.ino)
            .expect("inode")
            .expect("inode exists");
        assert_eq!(inode.size as usize, size, "{era}: {name} size");
        let bytes = store
            .read_file(entry.ino, 0, size as u64)
            .unwrap_or_else(|e| panic!("{era}: read {name}: {e}"));
        assert_eq!(bytes.len(), size, "{era}: {name} read length");
        let got = hex_str(blake3::hash(&bytes).as_bytes());
        assert_eq!(
            got, hash,
            "{era}: {name} materialized bytes do not match the manifest hash"
        );
    }

    // --- snapshot visibility ---
    // Format v1 fixtures predate committed snapshot support in stores;
    // the snapshot tree of a fresh store is empty by construction. This
    // is recorded, not skipped: the era manifests carry no snapshots.
    let snapshots = store.list_snapshots().expect("list_snapshots");
    assert!(
        snapshots.is_empty(),
        "{era}: fixtures must carry no snapshots"
    );

    drop(store);

    // --- immutability: the store-dir hash must match the sealed value ---
    let sealed = m.get("store_dir_hash").expect("store_dir_hash").to_string();
    let got = store_dir_hash(&store_dir);
    assert_eq!(
        got, sealed,
        "{era}: store-dir hash changed — the fixture was regenerated or \
         corrupted; fixtures are immutable historical bytes"
    );
}

#[test]
fn golden_store_compatibility_court() {
    let golden = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("testdata")
        .join("golden");
    let mut eras: Vec<String> = std::fs::read_dir(&golden)
        .expect("golden dir")
        .filter_map(|e| {
            let e = e.expect("entry");
            if e.path().join("manifest.txt").is_file() {
                Some(e.file_name().to_string_lossy().to_string())
            } else {
                None
            }
        })
        .collect();
    eras.sort();
    assert!(
        !eras.is_empty(),
        "no golden fixtures found under testdata/golden — run tools/make-golden-fixtures.sh"
    );
    for era in eras {
        court_era(&era);
    }
}