cmn-hypha 0.3.0

CMN CLI tool — spawn, grow, release, taste, bond, and absorb spores on the Code Mycelial Network
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use tempfile::TempDir;

use super::*;

fn keypair(seed: u8) -> ([u8; 32], String) {
    let private_key = [seed; 32];
    let signing_key = ed25519_dalek::SigningKey::from_bytes(&private_key);
    let public_key = substrate::format_key(
        substrate::KeyAlgorithm::Ed25519,
        &signing_key.verifying_key().to_bytes(),
    );
    (private_key, public_key)
}

fn cmn_entry(serial: u64, key: String) -> substrate::CmnEntry {
    substrate::CmnEntry::new(vec![substrate::CmnCapsuleEntry {
        uri: "cmn://example.com".to_string(),
        serial,
        key,
        history: vec![],
        endpoints: vec![substrate::CmnEndpoint {
            kind: "taste".to_string(),
            url: "https://example.com/cmn/taste/{hash}.json".to_string(),
            hash: String::new(),
            hashes: vec![],
            format: None,
            delta_url: None,
        }],
    }])
}

fn rotation_entry(
    from_private: &[u8; 32],
    from_key: &str,
    to_key: &str,
    serial: u64,
) -> substrate::KeyHistoryEntry {
    let retired_at_epoch_ms = 1_710_000_000_000;
    let statement = substrate::build_key_rotation_statement(
        "example.com",
        from_key,
        to_key,
        serial,
        retired_at_epoch_ms,
    );
    let rotation_signature = substrate::compute_signature(
        &statement,
        substrate::SignatureAlgorithm::Ed25519,
        from_private,
    )
    .unwrap();
    substrate::KeyHistoryEntry {
        key: from_key.to_string(),
        status: substrate::KeyHistoryStatus::Retired,
        retired_at_epoch_ms,
        replaced_by: Some(to_key.to_string()),
        effective_serial: Some(serial),
        rotation_signature: Some(rotation_signature),
        revoked_at_epoch_ms: None,
    }
}

#[test]
fn test_fetch_status_success() {
    let status = FetchStatus::success();
    assert!(status.fetched_at_epoch_ms.is_some());
    assert!(status.failed_at_epoch_ms.is_none());
    assert_eq!(status.retry_count, 0);
}

#[test]
fn test_fetch_status_failure() {
    let status = FetchStatus::failure("connection timeout", None);
    assert!(status.failed_at_epoch_ms.is_some());
    assert_eq!(status.retry_count, 1);
    assert_eq!(status.error, Some("connection timeout".to_string()));
}

#[test]
fn test_fetch_status_retry() {
    let first = FetchStatus::failure("error 1", None);
    let second = FetchStatus::failure("error 2", Some(&first));
    assert_eq!(second.retry_count, 2);
}

#[test]
fn test_domain_cache_paths() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());

    let domain = cache.domain("example.com");
    assert!(domain.cmn_path().ends_with("mycelium/cmn.json"));
    assert!(domain.status_path().ends_with("mycelium/status.json"));
}

#[test]
fn test_spore_path_new_structure() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());

    let path = cache.spore_path("example.com", "b3.3yMR7vZQ9hL");
    assert!(path.to_string_lossy().contains("spore/b3.3yMR7vZQ9hL"));
}

#[test]
fn test_cache_dir_default_ttl_values() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    assert_eq!(cache.cmn_ttl_ms, 300 * 1000);
    assert_eq!(cache.spore_max_download_bytes, 1024 * 1024 * 1024);
    assert_eq!(cache.spore_max_extract_bytes, 512 * 1024 * 1024);
    assert_eq!(cache.spore_max_extract_files, 100_000);
    assert_eq!(cache.spore_max_extract_file_bytes, 256 * 1024 * 1024);
    assert_eq!(
        cache.spore_reject_path_components,
        vec![".git".to_string(), ".cmn".to_string()]
    );
}

#[test]
fn test_cache_dir_custom_ttl() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir {
        root: temp.path().to_path_buf(),
        cmn_ttl_ms: 10_000,
        spore_max_download_bytes: 1024 * 1024 * 1024,
        spore_max_extract_bytes: 512 * 1024 * 1024,
        spore_max_extract_files: 100_000,
        spore_max_extract_file_bytes: 256 * 1024 * 1024,
        spore_reject_path_components: vec![".git".to_string(), ".cmn".to_string()],
    };
    assert_eq!(cache.cmn_ttl_ms, 10_000);
}

#[test]
fn test_cache_dir_from_config_file() {
    let _lock = crate::config::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    let hypha_dir = dir.path().join("hypha");
    std::fs::create_dir_all(&hypha_dir).unwrap();
    std::fs::write(hypha_dir.join("config.toml"), "[cache]\ncmn_ttl_s = 30\n").unwrap();

    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
    let cache = CacheDir::new().unwrap();
    std::env::remove_var("CMN_HOME");

    assert_eq!(cache.cmn_ttl_ms, 30 * 1000);
}

#[test]
fn test_cache_dir_from_config_custom_path() {
    let _lock = crate::config::ENV_LOCK.lock().unwrap();
    let dir = tempfile::tempdir().unwrap();
    let custom_cache = dir.path().join("my-custom-cache");
    let hypha_dir = dir.path().join("hypha");
    std::fs::create_dir_all(&hypha_dir).unwrap();
    std::fs::write(
        hypha_dir.join("config.toml"),
        format!("[cache]\npath = \"{}\"\n", custom_cache.display()),
    )
    .unwrap();

    std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
    let cache = CacheDir::new().unwrap();
    std::env::remove_var("CMN_HOME");

    assert_eq!(cache.root, custom_cache);
}

#[test]
fn test_fetch_status_is_fresh_respects_ttl() {
    let status = FetchStatus::success();
    assert!(status.is_fresh(1000));
    assert!(status.is_fresh(3_600_000));
    assert!(!status.is_fresh(0));
}

#[test]
fn test_taste_verdict_roundtrip() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");

    let verdict = TasteVerdictCache {
        verdict: substrate::TasteVerdict::Safe,
        notes: Some("Reviewed source code".to_string()),
        tasted_at_epoch_ms: 1700000000000,
    };

    domain.save_taste("b3.3yMR7vZQ9hL", &verdict).unwrap();
    let loaded = domain.load_taste("b3.3yMR7vZQ9hL").unwrap();

    assert_eq!(loaded.verdict, substrate::TasteVerdict::Safe);
    assert_eq!(loaded.notes, Some("Reviewed source code".to_string()));
    assert_eq!(loaded.tasted_at_epoch_ms, 1700000000000);
}

#[test]
fn test_taste_verdict_not_found() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");

    assert!(domain.load_taste("b3.nonexistent").is_none());
}

#[test]
fn test_status_update() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");

    domain.update_cmn_status(false, Some("404 not found"));
    let status = domain.load_status();
    assert!(status.cmn.failed_at_epoch_ms.is_some());
    assert_eq!(status.cmn.error, Some("404 not found".to_string()));
}

#[test]
fn test_key_trust_retirement_cutoff() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");
    let retired_at = 1_710_000_000_000;

    domain
        .save_key_trust_with_retirement("ed25519.previous", Some(retired_at))
        .unwrap();

    assert!(domain.is_key_trusted_for_time("ed25519.previous", retired_at, 60_000, 0));
    assert!(domain.is_key_trusted_for_time("ed25519.previous", retired_at - 1, 60_000, 0));
    assert!(!domain.is_key_trusted_for_time("ed25519.previous", retired_at + 1, 60_000, 0));
}

#[test]
fn test_domain_state_pins_first_verified_cmn() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");
    let (_, key) = keypair(1);
    let entry = cmn_entry(1, key.clone());

    domain.validate_and_pin_cmn_state(&entry).unwrap();
    let pin = domain.load_domain_state().unwrap();
    assert_eq!(pin.serial, 1);
    assert_eq!(pin.current_key, key);
    assert_eq!(pin.capsules_digest, entry.capsules_digest().unwrap());
}

#[test]
fn test_domain_state_rejects_rollback() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");
    let (_, key) = keypair(2);

    domain
        .validate_and_pin_cmn_state(&cmn_entry(2, key.clone()))
        .unwrap();
    let err = domain
        .validate_and_pin_cmn_state(&cmn_entry(1, key))
        .unwrap_err();
    assert_eq!(err.code, "domain_state_rollback");
}

#[test]
fn test_domain_state_rejects_same_serial_equivocation() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");
    let (_, key) = keypair(3);
    let entry = cmn_entry(1, key.clone());
    domain.validate_and_pin_cmn_state(&entry).unwrap();

    let mut fork = cmn_entry(1, key);
    fork.capsules[0].endpoints[0].url = "https://fork.example/cmn/taste/{hash}.json".to_string();
    let err = domain.validate_and_pin_cmn_state(&fork).unwrap_err();
    assert_eq!(err.code, "domain_state_equivocation");
}

#[test]
fn test_domain_state_rejects_large_forward_jump() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");
    let (_, key) = keypair(4);

    domain
        .validate_and_pin_cmn_state(&cmn_entry(1, key.clone()))
        .unwrap();
    let err = domain
        .validate_and_pin_cmn_state(&cmn_entry(1 + DOMAIN_STATE_JUMP_THRESHOLD + 1, key))
        .unwrap_err();
    assert_eq!(err.code, "domain_state_jump");
}

#[test]
fn test_domain_state_allows_proven_key_rotation() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");
    let (old_private, old_key) = keypair(5);
    let (_, new_key) = keypair(6);

    domain
        .validate_and_pin_cmn_state(&cmn_entry(1, old_key.clone()))
        .unwrap();
    let mut rotated = cmn_entry(2, new_key.clone());
    rotated.capsules[0].history = vec![rotation_entry(&old_private, &old_key, &new_key, 2)];
    domain.validate_and_pin_cmn_state(&rotated).unwrap();
    assert_eq!(domain.load_domain_state().unwrap().current_key, new_key);
}

#[test]
fn test_domain_state_rejects_unproven_key_rotation() {
    let temp = TempDir::new().unwrap();
    let cache = CacheDir::with_root(temp.path().to_path_buf());
    let domain = cache.domain("example.com");
    let (_, old_key) = keypair(7);
    let (_, new_key) = keypair(8);

    domain
        .validate_and_pin_cmn_state(&cmn_entry(1, old_key))
        .unwrap();
    let err = domain
        .validate_and_pin_cmn_state(&cmn_entry(2, new_key))
        .unwrap_err();
    assert_eq!(err.code, "domain_key_rotation_unproven");
}