sui-compat 0.1.21

Clean-room Nix format and protocol compatibility layer (NAR, store paths, ATerm, derivations)
Documentation
//! Layer 2: store-path parity on real `.drv` files.
//!
//! Reads the first N `.drv` files under `/nix/store` (lex-sorted, so
//! runs are reproducible), parses each with `sui_compat::derivation::
//! Derivation::parse`, and verifies three things:
//!
//! 1. **ATerm round-trip is byte-identical.** The bytes we serialize
//!    after parsing must equal the original file's bytes exactly. This
//!    is the deepest regression net we have for the derivation parser
//!    and serializer.
//!
//! 2. **Computed `.drv` path matches the filename** — *for pure
//!    derivations only* (those with no `input_derivations`). CppNix
//!    computes drv paths for non-pure derivations using the "drv hash
//!    modulo" algorithm (recursively substitute each input drv's own
//!    mod-hash, then SHA-256 the modified bytes). sui-compat's
//!    `compute_drv_path` implements the text:sha256 scheme that only
//!    applies to pure derivations, so we gate the assertion.
//!
//! 3. **Computed fixed-output `out` path matches the declared output**
//!    — for derivations with a non-empty `hash_algo`/`hash` on their
//!    `out` output. These are source derivations, fetchurl/fetchgit
//!    outputs, etc.
//!
//! Every failure is collected into a Vec and summarized at the end so
//! a single broken real-world file doesn't hide the rest.
//!
//! Offline test — no oracle needed, just reads `/nix/store`.

use std::path::PathBuf;
use sui_compat::derivation::Derivation;
use sui_compat::store_path::{
    compute_drv_path_with_refs, compute_fixed_output_hash, compute_output_path, StorePath,
};

/// How many `.drv` files to sample from `/nix/store`.
const DRV_SAMPLE_SIZE: usize = 500;

fn nix_store_root() -> PathBuf {
    std::env::var("NIX_STORE_ROOT")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("/nix/store"))
}

fn sample_drv_files(n: usize) -> Vec<PathBuf> {
    let root = nix_store_root();
    if !root.is_dir() {
        return Vec::new();
    }
    let mut out: Vec<PathBuf> = match std::fs::read_dir(&root) {
        Ok(it) => it
            .filter_map(|e| e.ok())
            .map(|e| e.path())
            .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("drv"))
            .collect(),
        Err(_) => return Vec::new(),
    };
    out.sort();
    out.truncate(n);
    out
}

fn basename_of(p: &PathBuf) -> String {
    p.file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_string()
}

/// Strip the trailing `.drv` and the hash prefix from a .drv filename,
/// returning just the "human" name component. For example
/// `abc123-openssh-10.2p1.drv` → `openssh-10.2p1`.
fn drv_human_name(basename: &str) -> Option<String> {
    let stripped = basename.strip_suffix(".drv")?;
    // StorePath::from_basename expects <hash>-<name>.
    let sp = StorePath::from_basename(stripped).ok()?;
    Some(sp.name)
}

#[test]
fn aterm_round_trip_byte_identical() {
    let corpus = sample_drv_files(DRV_SAMPLE_SIZE);
    if corpus.is_empty() {
        eprintln!("skip aterm_round_trip_byte_identical: /nix/store has no .drv files");
        return;
    }

    let mut parsed_count = 0;
    let mut roundtrip_mismatches: Vec<(PathBuf, String)> = Vec::new();
    let mut parse_failures: Vec<(PathBuf, String)> = Vec::new();

    for path in &corpus {
        let bytes = match std::fs::read(path) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let parsed = match Derivation::parse(&bytes) {
            Ok(d) => d,
            Err(e) => {
                parse_failures.push((path.clone(), format!("{e}")));
                continue;
            }
        };
        parsed_count += 1;
        let round = parsed.serialize();
        if round.as_bytes() != bytes.as_slice() {
            // Capture just enough context to debug the first few.
            let first_diff = first_byte_difference(&bytes, round.as_bytes());
            roundtrip_mismatches.push((
                path.clone(),
                format!(
                    "orig {} bytes, round {} bytes, first diff at {:?}",
                    bytes.len(),
                    round.len(),
                    first_diff
                ),
            ));
        }
    }

    eprintln!(
        "aterm_round_trip: parsed {} / {} (parse_failures={}, roundtrip_mismatches={})",
        parsed_count,
        corpus.len(),
        parse_failures.len(),
        roundtrip_mismatches.len()
    );

    if !parse_failures.is_empty() {
        let summary: String = parse_failures
            .iter()
            .take(5)
            .map(|(p, why)| format!("  {}\n    {why}", p.display()))
            .collect::<Vec<_>>()
            .join("\n");
        panic!(
            "{} .drv files failed to parse. First {}:\n{}",
            parse_failures.len(),
            parse_failures.len().min(5),
            summary
        );
    }

    if !roundtrip_mismatches.is_empty() {
        let summary: String = roundtrip_mismatches
            .iter()
            .take(5)
            .map(|(p, why)| format!("  {}\n    {why}", p.display()))
            .collect::<Vec<_>>()
            .join("\n");
        panic!(
            "{} .drv files failed ATerm round-trip. First {}:\n{}",
            roundtrip_mismatches.len(),
            roundtrip_mismatches.len().min(5),
            summary
        );
    }
}

/// Recompute every sampled `.drv` path via
/// `compute_drv_path_with_refs` (refs = input_derivations.keys() ∪
/// input_sources) and assert the resulting basename matches the
/// real filename. Real Nix's text-path scheme folds the references
/// into the fingerprint; without them every real .drv mismatches.
#[test]
fn drv_path_matches_filename() {
    let corpus = sample_drv_files(DRV_SAMPLE_SIZE);
    if corpus.is_empty() {
        eprintln!("skip drv_path_matches_filename: no corpus");
        return;
    }

    let mut checked = 0;
    let mut mismatches: Vec<(PathBuf, String)> = Vec::new();

    for path in &corpus {
        let bytes = match std::fs::read(path) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let parsed = match Derivation::parse(&bytes) {
            Ok(d) => d,
            Err(_) => continue,
        };
        let Some(name) = drv_human_name(&basename_of(path)) else {
            continue;
        };
        // refs = every input derivation path + every input source.
        // CppNix sorts these lexicographically before splicing into
        // the fingerprint; `compute_drv_path_with_refs` does that
        // sort internally.
        let mut refs: Vec<String> = parsed.input_derivations.keys().cloned().collect();
        refs.extend(parsed.input_sources.iter().cloned());
        let computed = compute_drv_path_with_refs(&bytes, &name, &refs);
        let computed_basename = computed
            .rsplit_once('/')
            .map(|(_, b)| b.to_string())
            .unwrap_or(computed.clone());
        let actual_basename = basename_of(path);
        if computed_basename != actual_basename {
            mismatches.push((
                path.clone(),
                format!("expected {actual_basename}, got {computed_basename}"),
            ));
        }
        checked += 1;
    }

    eprintln!(
        "drv_path_matches_filename: checked {}, mismatches {}",
        checked,
        mismatches.len()
    );

    if !mismatches.is_empty() {
        let summary: String = mismatches
            .iter()
            .take(5)
            .map(|(p, why)| format!("  {}\n    {why}", p.display()))
            .collect::<Vec<_>>()
            .join("\n");
        panic!(
            "{} / {} .drv files had drvPath mismatch. First {}:\n{}",
            mismatches.len(),
            checked,
            mismatches.len().min(5),
            summary
        );
    }
}

/// For fixed-output derivations (those whose `out` output declares a
/// non-empty `hash_algo`/`hash`), recompute the output path via
/// `compute_fixed_output_hash` and assert it equals the declared path.
#[test]
fn fixed_output_path_matches_declared() {
    let corpus = sample_drv_files(DRV_SAMPLE_SIZE);
    if corpus.is_empty() {
        eprintln!("skip fixed_output_path_matches_declared: no corpus");
        return;
    }

    let mut checked = 0;
    let mut mismatches: Vec<(PathBuf, String)> = Vec::new();

    for path in &corpus {
        let bytes = match std::fs::read(path) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let parsed = match Derivation::parse(&bytes) {
            Ok(d) => d,
            Err(_) => continue,
        };
        let Some(out) = parsed.outputs.get("out") else {
            continue;
        };
        if out.hash_algo.is_empty() || out.hash.is_empty() {
            continue; // not fixed-output
        }

        // Derivation's `name` for output-path computation is its
        // human name, with the hash prefix and `.drv` suffix stripped.
        let Some(name) = drv_human_name(&basename_of(path)) else {
            continue;
        };

        // hash_algo is either "sha256" or "r:sha256" (recursive/NAR).
        let is_recursive = out.hash_algo.starts_with("r:");
        let algo = out.hash_algo.trim_start_matches("r:");
        if algo != "sha256" {
            // compute_fixed_output_hash today only handles sha256;
            // skip md5/sha1/sha512 rather than false-fail.
            continue;
        }

        let computed = compute_fixed_output_hash(algo, &out.hash, is_recursive, &name);
        if computed != out.path {
            mismatches.push((
                path.clone(),
                format!(
                    "name={name} algo={} recursive={is_recursive}\n    expected {}\n    got      {}",
                    out.hash_algo, out.path, computed
                ),
            ));
        }
        checked += 1;
    }

    eprintln!(
        "fixed_output_path_matches_declared: checked {}, mismatches {}",
        checked,
        mismatches.len()
    );

    if !mismatches.is_empty() {
        let summary: String = mismatches
            .iter()
            .take(5)
            .map(|(p, why)| format!("  {}\n    {why}", p.display()))
            .collect::<Vec<_>>()
            .join("\n");
        panic!(
            "{} / {} fixed-output .drv files had output path mismatch. First {}:\n{}",
            mismatches.len(),
            checked,
            mismatches.len().min(5),
            summary
        );
    }
}

/// For *standard* (non-fixed, with input_derivations) derivations we
/// can't fully reproduce the drvPath algorithm yet, but we can at
/// least assert that every non-fixed output has a store path of the
/// right *shape* and that `compute_output_path` accepts a reasonable
/// inner hash without panicking.
#[test]
fn standard_output_paths_have_valid_shape() {
    let corpus = sample_drv_files(DRV_SAMPLE_SIZE);
    if corpus.is_empty() {
        eprintln!("skip standard_output_paths_have_valid_shape: no corpus");
        return;
    }

    let mut checked = 0;
    let mut bad_shape: Vec<(PathBuf, String)> = Vec::new();

    for path in &corpus {
        let bytes = match std::fs::read(path) {
            Ok(b) => b,
            Err(_) => continue,
        };
        let parsed = match Derivation::parse(&bytes) {
            Ok(d) => d,
            Err(_) => continue,
        };
        for (name, output) in &parsed.outputs {
            if !output.hash_algo.is_empty() {
                continue; // fixed output, covered elsewhere
            }
            if StorePath::from_absolute_path(&output.path).is_err() {
                bad_shape.push((
                    path.clone(),
                    format!("output {name} has invalid shape: {}", output.path),
                ));
            }
            checked += 1;
        }
    }

    eprintln!(
        "standard_output_paths_have_valid_shape: checked {}, bad_shape {}",
        checked,
        bad_shape.len()
    );

    if !bad_shape.is_empty() {
        let summary: String = bad_shape
            .iter()
            .take(5)
            .map(|(p, why)| format!("  {}\n    {why}", p.display()))
            .collect::<Vec<_>>()
            .join("\n");
        panic!(
            "{} outputs had invalid store-path shape. First {}:\n{}",
            bad_shape.len(),
            bad_shape.len().min(5),
            summary
        );
    }
}

/// Sanity: `compute_output_path` accepts a 64-char hex inner hash and
/// produces a path parseable by `StorePath::from_absolute_path`.
#[test]
fn compute_output_path_shape_sanity() {
    let inner = "a".repeat(64);
    let out = compute_output_path(&inner, "out", "hello-1.0");
    let parsed = StorePath::from_absolute_path(&out).unwrap();
    assert_eq!(parsed.name, "hello-1.0");
}

fn first_byte_difference(a: &[u8], b: &[u8]) -> Option<usize> {
    for i in 0..a.len().min(b.len()) {
        if a[i] != b[i] {
            return Some(i);
        }
    }
    if a.len() != b.len() {
        Some(a.len().min(b.len()))
    } else {
        None
    }
}