use super::*;
fn test_identity() -> GuardPolicyIdentity {
GuardPolicyIdentity {
build_identity: "abc".to_string(),
detector_digest: "def".to_string(),
suppression_digest: String::new(),
keyhogignore_digest: String::new(),
config_digest: "ghi".to_string(),
decode_policy_version: 1,
source_policy_digest: "jkl".to_string(),
guard_schema_version: 1,
report_semantics_version: 1,
}
}
fn test_fs_identity() -> FilesystemIdentity {
FilesystemIdentity {
device: 1,
inode: 2,
}
}
#[test]
fn runtime_starts_empty() {
let rt = GuardRuntime::new();
assert!(rt.is_empty());
assert_eq!(rt.root_count(), 0);
}
#[test]
fn add_root_creates_stopped_record() {
let rt = GuardRuntime::new();
let record = rt
.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
assert_eq!(record.state, GuardRootState::Stopped);
assert_eq!(rt.root_count(), 1);
}
#[test]
fn add_duplicate_root_fails() {
let rt = GuardRuntime::new();
rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
let result = rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
);
assert!(result.is_err());
}
#[test]
fn remove_root_works() {
let rt = GuardRuntime::new();
rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
assert_eq!(rt.root_count(), 1);
let removed = rt.remove_root(b"/work/project");
assert!(removed.is_some());
assert!(rt.is_empty());
}
#[test]
fn transition_root_stopped_to_indexing() {
let rt = GuardRuntime::new();
rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
let new_state = rt
.transition_root(b"/work/project", &GuardTransition::ReconciliationStarted)
.unwrap();
assert_eq!(new_state, GuardRootState::Indexing);
}
#[test]
fn transition_root_indexing_to_current() {
let rt = GuardRuntime::new();
rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
rt.transition_root(b"/work/project", &GuardTransition::ReconciliationStarted)
.unwrap();
let new_state = rt
.transition_root(b"/work/project", &GuardTransition::ReconciliationClean)
.unwrap();
assert_eq!(new_state, GuardRootState::Current);
}
#[test]
fn transition_illegal_returns_error() {
let rt = GuardRuntime::new();
rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
let result = rt.transition_root(b"/work/project", &GuardTransition::EventAccepted);
assert!(result.is_err());
}
#[test]
fn policy_identity_change_transitions_roots_to_stale() {
let rt = GuardRuntime::new();
rt.set_policy_identity(test_identity());
rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
rt.transition_root(b"/work/project", &GuardTransition::ReconciliationStarted)
.unwrap();
rt.transition_root(b"/work/project", &GuardTransition::ReconciliationClean)
.unwrap();
assert_eq!(
rt.root_state(b"/work/project"),
Some(GuardRootState::Current)
);
let mut new_id = test_identity();
new_id.detector_digest = "changed".to_string();
rt.set_policy_identity(new_id);
assert_eq!(
rt.root_state(b"/work/project"),
Some(GuardRootState::StalePolicy)
);
}
#[test]
fn transaction_ids_are_unique() {
let rt = GuardRuntime::new();
let id1 = rt.next_transaction_id();
let id2 = rt.next_transaction_id();
let id3 = rt.next_transaction_id();
assert_eq!(id1, 1);
assert_eq!(id2, 2);
assert_eq!(id3, 3);
}
#[test]
fn count_by_state() {
let rt = GuardRuntime::new();
rt.add_root(b"/a".to_vec(), test_fs_identity(), GuardRootMode::Repo)
.unwrap();
rt.add_root(b"/b".to_vec(), test_fs_identity(), GuardRootMode::Repo)
.unwrap();
assert_eq!(rt.count_by_state(GuardRootState::Stopped), 2);
assert_eq!(rt.count_by_state(GuardRootState::Current), 0);
rt.transition_root(b"/a", &GuardTransition::ReconciliationStarted)
.unwrap();
rt.transition_root(b"/a", &GuardTransition::ReconciliationClean)
.unwrap();
assert_eq!(rt.count_by_state(GuardRootState::Stopped), 1);
assert_eq!(rt.count_by_state(GuardRootState::Current), 1);
}
#[test]
fn list_roots_returns_all() {
let rt = GuardRuntime::new();
rt.add_root(b"/a".to_vec(), test_fs_identity(), GuardRootMode::Repo)
.unwrap();
rt.add_root(
b"/b".to_vec(),
test_fs_identity(),
GuardRootMode::Filesystem,
)
.unwrap();
let list = rt.list_roots();
assert_eq!(list.len(), 2);
}
#[test]
fn scanner_residency_is_resident_after_activity() {
let rt = GuardRuntime::new();
rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
assert_eq!(rt.scanner_residency(), "resident");
}
#[test]
fn scanner_residency_is_active_during_transaction() {
let rt = GuardRuntime::new();
rt.add_root(
b"/work/project".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
let txn = GuardTransaction {
transaction_id: rt.next_transaction_id(),
repo_path: "/work/project".to_string(),
index_fingerprint: "abc".to_string(),
hash_algorithm: GitHashAlgorithm::Sha1,
clean_hits: Vec::new(),
required_blob_oids: vec!["oid1".to_string()],
scanned_oids: Vec::new(),
bytes_scanned: 0,
bytes_requested: 0,
bytes_hit: 0,
findings_count: 0,
coverage_gaps: 0,
objects_skipped: 0,
started_at: Instant::now(),
policy_short_digest: "abc".to_string(),
};
rt.begin_transaction(txn);
assert_eq!(rt.scanner_residency(), "active");
rt.finish_transaction(1);
assert_eq!(rt.scanner_residency(), "resident");
}
#[test]
fn touch_activity_updates_residency() {
let rt = GuardRuntime::new();
assert_eq!(rt.scanner_residency(), "resident");
rt.touch_activity();
assert_eq!(rt.scanner_residency(), "resident");
}
#[test]
fn scanner_residency_uses_configured_timeout() {
let rt = GuardRuntime::new();
rt.set_scanner_idle_timeout(0);
rt.touch_activity();
assert_eq!(rt.scanner_residency(), "idle-unload");
}
#[test]
fn scanner_residency_respects_large_timeout() {
let rt = GuardRuntime::new();
rt.set_scanner_idle_timeout(999_999);
assert_eq!(rt.scanner_residency(), "resident");
}
#[test]
fn restore_root_preserves_metadata_but_resets_state() {
let rt = GuardRuntime::new();
let record = keyhog_core::guard_state::GuardRootRecord {
canonical_path: b"/restored/repo".to_vec(),
filesystem_identity: test_fs_identity(),
mode: GuardRootMode::Repo,
state: keyhog_core::guard_state::GuardRootState::Current,
terminal_sequence: 42,
accepted_event_sequence: 10,
completed_event_sequence: 8,
initial_reconciliation_time: Some(1000),
last_reconciliation_time: Some(2000),
backend_route_label: "scalar-cpu".to_string(),
last_receipt: None,
};
rt.restore_root(record.clone()).expect("restore root");
let loaded = rt.root_record(b"/restored/repo").expect("root exists");
assert_eq!(loaded.canonical_path, record.canonical_path);
assert_eq!(loaded.filesystem_identity, record.filesystem_identity);
assert_eq!(loaded.mode, record.mode);
assert_eq!(loaded.terminal_sequence, record.terminal_sequence);
assert_eq!(
loaded.state,
keyhog_core::guard_state::GuardRootState::Current
);
}
#[test]
fn restore_root_rejects_duplicate() {
let rt = GuardRuntime::new();
let record = keyhog_core::guard_state::GuardRootRecord {
canonical_path: b"/dup/repo".to_vec(),
filesystem_identity: test_fs_identity(),
mode: GuardRootMode::Repo,
state: keyhog_core::guard_state::GuardRootState::Stopped,
terminal_sequence: 0,
accepted_event_sequence: 0,
completed_event_sequence: 0,
initial_reconciliation_time: None,
last_reconciliation_time: None,
backend_route_label: String::new(),
last_receipt: None,
};
rt.restore_root(record.clone()).expect("first restore");
let result = rt.restore_root(record);
assert!(result.is_err(), "duplicate restore should fail");
}
#[test]
fn restore_root_then_reconcile_transitions_to_indexing() {
let rt = GuardRuntime::new();
let record = keyhog_core::guard_state::GuardRootRecord {
canonical_path: b"/restart/repo".to_vec(),
filesystem_identity: test_fs_identity(),
mode: GuardRootMode::Repo,
state: keyhog_core::guard_state::GuardRootState::Stopped,
terminal_sequence: 5,
accepted_event_sequence: 0,
completed_event_sequence: 0,
initial_reconciliation_time: None,
last_reconciliation_time: None,
backend_route_label: String::new(),
last_receipt: None,
};
rt.restore_root(record).expect("restore");
let transition = GuardTransition::ReconciliationStarted;
let result = rt.transition_root(b"/restart/repo", &transition);
assert!(result.is_ok(), "stopped root should transition to indexing");
assert_eq!(
rt.root_state(b"/restart/repo"),
Some(keyhog_core::guard_state::GuardRootState::Indexing)
);
}
#[test]
fn mutation_restore_current_directly_is_rejected_by_caller_contract() {
let rt = GuardRuntime::new();
let record = keyhog_core::guard_state::GuardRootRecord {
canonical_path: b"/mutation/current".to_vec(),
filesystem_identity: test_fs_identity(),
mode: GuardRootMode::Repo,
state: keyhog_core::guard_state::GuardRootState::Current,
terminal_sequence: 99,
accepted_event_sequence: 50,
completed_event_sequence: 48,
initial_reconciliation_time: Some(1000),
last_reconciliation_time: Some(2000),
backend_route_label: "scalar-cpu".to_string(),
last_receipt: None,
};
rt.restore_root(record).expect("restore");
assert_eq!(
rt.root_state(b"/mutation/current"),
Some(keyhog_core::guard_state::GuardRootState::Current)
);
}
#[test]
fn mutation_omit_policy_identity_field_invalidates_attestations() {
let rt = GuardRuntime::new();
let id1 = GuardPolicyIdentity {
build_identity: "build1".to_string(),
detector_digest: "det1".to_string(),
suppression_digest: String::new(),
keyhogignore_digest: String::new(),
config_digest: String::new(),
decode_policy_version: 1,
source_policy_digest: String::new(),
guard_schema_version: keyhog_core::guard_state::GUARD_SCHEMA_VERSION,
report_semantics_version: 1,
};
rt.set_policy_identity(id1);
let att = GitCleanAttestation {
hash_algorithm: GitHashAlgorithm::Sha1,
blob_oid: "oid1".to_string(),
object_size: 100,
policy_identity: rt.policy_identity().unwrap(),
last_seen_sequence: 1,
};
rt.insert_attestation(att);
let id2 = GuardPolicyIdentity {
build_identity: "build1".to_string(),
detector_digest: "det2".to_string(),
suppression_digest: String::new(),
keyhogignore_digest: String::new(),
config_digest: String::new(),
decode_policy_version: 1,
source_policy_digest: String::new(),
guard_schema_version: keyhog_core::guard_state::GUARD_SCHEMA_VERSION,
report_semantics_version: 1,
};
rt.set_policy_identity(id2);
let short = rt.policy_identity().unwrap().short_digest().unwrap();
let result = rt.lookup_attestation(GitHashAlgorithm::Sha1, "oid1", &short);
assert!(
result.is_none(),
"attestation should be invalidated after policy identity change"
);
}
#[test]
fn mutation_indexing_to_current_requires_clean_transition() {
let rt = GuardRuntime::new();
rt.add_root(
b"/mutation/transition".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
rt.transition_root(
b"/mutation/transition",
&GuardTransition::ReconciliationStarted,
)
.unwrap();
assert_eq!(
rt.root_state(b"/mutation/transition"),
Some(GuardRootState::Indexing)
);
let result = rt.transition_root(b"/mutation/transition", &GuardTransition::EventAccepted);
assert!(
result.is_err(),
"EventAccepted from Indexing should be illegal"
);
rt.transition_root(
b"/mutation/transition",
&GuardTransition::ReconciliationClean,
)
.unwrap();
assert_eq!(
rt.root_state(b"/mutation/transition"),
Some(GuardRootState::Current)
);
}
#[test]
fn coverage_lost_during_indexing_survives_until_taken() {
let rt = GuardRuntime::new();
rt.add_root(
b"/overflow/root".to_vec(),
test_fs_identity(),
GuardRootMode::Repo,
)
.unwrap();
rt.transition_root(b"/overflow/root", &GuardTransition::ReconciliationStarted)
.unwrap();
rt.mark_dirty_during_indexing(b"/overflow/root");
rt.mark_coverage_lost_during_indexing(b"/overflow/root");
assert_eq!(
rt.root_state(b"/overflow/root"),
Some(GuardRootState::Indexing)
);
assert!(rt.take_coverage_lost_during_indexing(b"/overflow/root"));
assert!(!rt.take_coverage_lost_during_indexing(b"/overflow/root"));
assert!(rt.take_dirty_during_indexing(b"/overflow/root"));
rt.transition_root(b"/overflow/root", &GuardTransition::ReconciliationDegraded)
.unwrap();
assert_eq!(
rt.root_state(b"/overflow/root"),
Some(GuardRootState::Degraded)
);
}
#[test]
fn remove_root_clears_indexing_event_flags() {
let rt = GuardRuntime::new();
rt.add_root(
b"/clear/flags".to_vec(),
test_fs_identity(),
GuardRootMode::Filesystem,
)
.unwrap();
rt.mark_dirty_during_indexing(b"/clear/flags");
rt.mark_coverage_lost_during_indexing(b"/clear/flags");
assert!(rt.remove_root(b"/clear/flags").is_some());
assert!(!rt.take_dirty_during_indexing(b"/clear/flags"));
assert!(!rt.take_coverage_lost_during_indexing(b"/clear/flags"));
}