forjar 1.4.2

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
use super::hasher::*;
use proptest::prelude::*;
use std::path::Path;

#[test]
fn test_fj014_hash_file() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("test.txt");
    std::fs::write(&path, "hello world").unwrap();
    let h = hash_file(&path).unwrap();
    assert!(h.starts_with("blake3:"));
    assert_eq!(h.len(), 7 + 64); // "blake3:" + 64 hex chars
}

#[test]
fn test_fj014_hash_file_deterministic() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("det.txt");
    std::fs::write(&path, "deterministic").unwrap();
    let h1 = hash_file(&path).unwrap();
    let h2 = hash_file(&path).unwrap();
    assert_eq!(h1, h2);
}

#[test]
fn test_fj014_hash_string() {
    let h1 = hash_string("hello");
    let h2 = hash_string("hello");
    let h3 = hash_string("world");
    assert_eq!(h1, h2);
    assert_ne!(h1, h3);
    assert!(h1.starts_with("blake3:"));
}

#[test]
fn test_fj014_hash_directory() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("a.txt"), "aaa").unwrap();
    std::fs::write(dir.path().join("b.txt"), "bbb").unwrap();
    let h = hash_directory(dir.path()).unwrap();
    assert!(h.starts_with("blake3:"));
}

#[test]
fn test_fj014_hash_directory_order_independent_of_creation() {
    // Same files, deterministic hash regardless of creation order
    let d1 = tempfile::tempdir().unwrap();
    std::fs::write(d1.path().join("b.txt"), "bbb").unwrap();
    std::fs::write(d1.path().join("a.txt"), "aaa").unwrap();

    let d2 = tempfile::tempdir().unwrap();
    std::fs::write(d2.path().join("a.txt"), "aaa").unwrap();
    std::fs::write(d2.path().join("b.txt"), "bbb").unwrap();

    assert_eq!(
        hash_directory(d1.path()).unwrap(),
        hash_directory(d2.path()).unwrap()
    );
}

#[test]
fn test_fj014_composite_hash() {
    let h = composite_hash(&["blake3:aaa", "blake3:bbb"]);
    assert!(h.starts_with("blake3:"));
    // Different inputs → different hash
    let h2 = composite_hash(&["blake3:bbb", "blake3:aaa"]);
    assert_ne!(h, h2);
}

#[test]
fn test_fj014_hash_file_not_found() {
    let result = hash_file(Path::new("/nonexistent/file.txt"));
    assert!(result.is_err());
}

#[test]
fn test_fj014_hash_directory_with_symlink_and_subdirs() {
    let dir = tempfile::tempdir().unwrap();
    // File in root
    std::fs::write(dir.path().join("root.txt"), "root").unwrap();
    // Subdirectory with file
    std::fs::create_dir(dir.path().join("sub")).unwrap();
    std::fs::write(dir.path().join("sub").join("nested.txt"), "nested").unwrap();
    // Symlink — should be skipped
    #[cfg(unix)]
    std::os::unix::fs::symlink(dir.path().join("root.txt"), dir.path().join("link.txt")).unwrap();

    let h = hash_directory(dir.path()).unwrap();
    assert!(h.starts_with("blake3:"));

    // Verify symlink doesn't affect hash: remove symlink, hash should be same
    #[cfg(unix)]
    {
        let h_with_link = h.clone();
        std::fs::remove_file(dir.path().join("link.txt")).unwrap();
        let h_without_link = hash_directory(dir.path()).unwrap();
        assert_eq!(
            h_with_link, h_without_link,
            "symlink should not affect hash"
        );
    }
}

#[test]
fn test_fj014_hash_empty_file() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("empty.txt");
    std::fs::write(&path, "").unwrap();
    let h = hash_file(&path).unwrap();
    assert!(h.starts_with("blake3:"));
    assert_eq!(h.len(), 71); // prefix + 64 hex
}

/// STRONG contract: `hash_string` rejects empty input.
/// Documents the `blake3-state-v1` precondition `!input.is_empty()`.
#[test]
#[should_panic(expected = "precondition violated")]
fn test_fj014_hash_empty_string() {
    // aprender-contracts blake3-state-v1 precondition forbids empty input.
    // Callers that legitimately need to track an "empty" sentinel must handle
    // the empty case before calling `hash_string`.
    let _ = hash_string("");
}

#[test]
fn test_fj014_hash_empty_directory() {
    let dir = tempfile::tempdir().unwrap();
    let h = hash_directory(dir.path()).unwrap();
    assert!(h.starts_with("blake3:"));
    // Empty dir should have a consistent hash
    let h2 = hash_directory(dir.path()).unwrap();
    assert_eq!(h, h2);
}

#[test]
fn test_fj014_hash_file_vs_string_consistency() {
    // Hashing a file should produce the same result as hashing its content string
    let dir = tempfile::tempdir().unwrap();
    let content = "test content for consistency check";
    let path = dir.path().join("consistency.txt");
    std::fs::write(&path, content).unwrap();
    let file_hash = hash_file(&path).unwrap();
    let string_hash = hash_string(content);
    assert_eq!(
        file_hash, string_hash,
        "file hash should equal string hash of same content"
    );
}

#[test]
fn test_fj014_hash_large_content() {
    // Test streaming hash with content larger than STREAM_BUF_SIZE (64KB)
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("large.bin");
    let content = "x".repeat(100_000); // 100KB > 64KB buffer
    std::fs::write(&path, &content).unwrap();
    let h = hash_file(&path).unwrap();
    assert!(h.starts_with("blake3:"));
    // Verify determinism for large files
    let h2 = hash_file(&path).unwrap();
    assert_eq!(h, h2);
}

/// STRONG contract: `composite_hash` rejects empty component lists.
/// Documents the `blake3-state-v1` precondition `parts.len() > 0`.
#[test]
#[should_panic(expected = "precondition violated")]
fn test_fj014_composite_hash_empty() {
    // aprender-contracts blake3-state-v1 precondition forbids empty component list.
    // Callers that legitimately produce an empty component list must handle
    // the empty case before calling `composite_hash`.
    let _ = composite_hash(&[]);
}

#[test]
fn test_fj014_composite_hash_single() {
    let h = composite_hash(&["only-one"]);
    assert!(h.starts_with("blake3:"));
}

#[test]
fn test_fj014_hash_directory_content_change() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "original").unwrap();
    let h1 = hash_directory(dir.path()).unwrap();
    std::fs::write(dir.path().join("f.txt"), "modified").unwrap();
    let h2 = hash_directory(dir.path()).unwrap();
    assert_ne!(
        h1, h2,
        "directory hash should change when file content changes"
    );
}

#[test]
fn test_fj014_hash_directory_file_added() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("a.txt"), "aaa").unwrap();
    let h1 = hash_directory(dir.path()).unwrap();
    std::fs::write(dir.path().join("b.txt"), "bbb").unwrap();
    let h2 = hash_directory(dir.path()).unwrap();
    assert_ne!(h1, h2, "directory hash should change when file is added");
}

#[test]
fn test_fj014_hash_directory_not_found() {
    let result = hash_directory(Path::new("/nonexistent/directory"));
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("cannot read dir"));
}

#[test]
fn test_fj014_hash_file_exact_buffer_size() {
    // Test file size exactly at STREAM_BUF_SIZE boundary
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("exact.bin");
    let content = "x".repeat(STREAM_BUF_SIZE);
    std::fs::write(&path, &content).unwrap();
    let h = hash_file(&path).unwrap();
    assert!(h.starts_with("blake3:"));
    assert_eq!(h.len(), 71);
}

#[test]
fn test_fj014_hash_directory_deep_nesting() {
    let dir = tempfile::tempdir().unwrap();
    let deep = dir.path().join("a").join("b").join("c");
    std::fs::create_dir_all(&deep).unwrap();
    std::fs::write(deep.join("deep.txt"), "deep content").unwrap();
    let h = hash_directory(dir.path()).unwrap();
    assert!(h.starts_with("blake3:"));
    // Hash should differ from empty dir
    let empty = tempfile::tempdir().unwrap();
    let h_empty = hash_directory(empty.path()).unwrap();
    assert_ne!(h, h_empty);
}

#[test]
fn test_fj014_composite_hash_deterministic() {
    let components = &["a", "b", "c"];
    let h1 = composite_hash(components);
    let h2 = composite_hash(components);
    assert_eq!(h1, h2, "composite_hash must be deterministic");
}

#[test]
fn test_fj014_hash_string_differs_by_single_char() {
    let h1 = hash_string("abc");
    let h2 = hash_string("abd");
    assert_ne!(h1, h2, "single char difference must produce different hash");
}

// ── Falsification tests (BLAKE3 State Contract) ─────────────

proptest! {
    /// FALSIFY-B3-001: hash_string always produces "blake3:" prefix + 64 hex chars.
    /// Regex `".+"` respects the STRONG `!input.is_empty()` precondition.
    #[test]
    fn falsify_b3_001_hash_string_prefix_format(s in ".+") {
        let h = hash_string(&s);
        prop_assert!(h.starts_with("blake3:"), "missing blake3: prefix");
        prop_assert_eq!(h.len(), 71, "expected 7 prefix + 64 hex = 71 chars");
    }

    /// FALSIFY-B3-002: hash_string is deterministic.
    /// Regex `".+"` respects the STRONG `!input.is_empty()` precondition.
    #[test]
    fn falsify_b3_002_hash_string_determinism(s in ".+") {
        let h1 = hash_string(&s);
        let h2 = hash_string(&s);
        prop_assert_eq!(h1, h2, "hash_string must be deterministic");
    }

    /// FALSIFY-B3-003: composite_hash is order-sensitive.
    #[test]
    fn falsify_b3_003_composite_order_sensitivity(a in "[a-z]{1,8}", b in "[a-z]{1,8}") {
        prop_assume!(a != b);
        let h_ab = composite_hash(&[&a, &b]);
        let h_ba = composite_hash(&[&b, &a]);
        prop_assert_ne!(h_ab, h_ba, "composite_hash must be order-sensitive");
    }
}