diff --git a/crates/credentials-core/Cargo.toml b/crates/credentials-core/Cargo.toml index a8bd86b..d278c2d 100644 --- a/crates/credentials-core/Cargo.toml +++ b/crates/credentials-core/Cargo.toml @@ -7,6 +7,9 @@ license = "MIT" description = "Credential custody logic for the claustrum subc module: typed VaultRecord, canonical OAuthCredential, value-level encryption envelope, bounded refresh adapters, crash-safe refresh state machine, master-key resolution. Pure logic, wire-agnostic." [features] +# Exposes test fixtures to integration tests without linking them into normal release builds. +test-support = [] + # Compiles a test-only pre-commit seam INTO the refresh engine: a hook that fires # after the intent is durably committed and the new tokens are staged, but BEFORE # the commit transaction. The kill-9 conformance harness uses it to park a helper @@ -14,14 +17,14 @@ description = "Credential custody logic for the claustrum subc module: typed Vau # reconciliation resolves the interrupted refresh to needs_reauth. OFF by default — # the release vault contains no block-before-commit code path at all (zero release # surface for a security boundary). -kill9-test-seam = [] +kill9-test-seam = ["test-support"] # Gates ONLY the vault-native-login crash-cut helper binary out of release builds. # Like rotate-test-seam this compiles NO code into the library: the login write is a # SINGLE atomic fenced transaction (no intent log, no txn1/txn2 window), so the helper # just brackets that one public store call with park points using public API — there is # no block-before-commit seam because there is no mid-operation durable state to catch. -login-test-seam = [] +login-test-seam = ["test-support"] # Gates ONLY the master-key-rotation crash-cut helper binary out of release builds. # Unlike kill9-test-seam this compiles NO code into the library: the rotation @@ -29,7 +32,7 @@ login-test-seam = [] # (stage_next -> DB rewrap -> promote), each individually atomic, so the helper just # parks after a chosen step using public API — no block-before-commit seam in the # vault. The feature exists solely to keep the test binary out of a normal build. -rotate-test-seam = [] +rotate-test-seam = ["test-support"] # Gates the module-rename ceremony one-shots (ck_key_move, ck_key_verify) out of # normal builds. Compiles NO code into the library — both drive the shipped resolver @@ -42,7 +45,7 @@ rotate-test-seam = [] # run roughly once per rename against two directory arguments, and a normal build # putting it on PATH is how a wrong-argument run becomes reachable years after anyone # remembers what it does. -migration-tools = [] +migration-tools = ["test-support"] # The two rename-ceremony one-shots. Gated together because they are used at the same # moment and never afterwards, and they WRITE master-key material -- so they must not diff --git a/crates/credentials-core/src/contract.rs b/crates/credentials-core/src/contract.rs index 14a7626..8c6b1b8 100644 --- a/crates/credentials-core/src/contract.rs +++ b/crates/credentials-core/src/contract.rs @@ -157,11 +157,10 @@ fn canonical_path_bytes(path: &Path) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::test_support::TestTempDir; - fn tmp_dir(label: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("ck-contract-{label}-{}", std::process::id())); - std::fs::create_dir_all(&d).unwrap(); - d + fn tmp_dir(label: &str) -> TestTempDir { + TestTempDir::new(format!("ck-contract-{label}-{}", std::process::id())) } #[test] @@ -194,7 +193,7 @@ mod tests { ); let with_trailing = { - let mut s = dir.clone().into_os_string(); + let mut s = dir.path().to_path_buf().into_os_string(); s.push("/"); std::path::PathBuf::from(s) }; diff --git a/crates/credentials-core/src/engine_tests.rs b/crates/credentials-core/src/engine_tests.rs index 4299b2c..1e35898 100644 --- a/crates/credentials-core/src/engine_tests.rs +++ b/crates/credentials-core/src/engine_tests.rs @@ -20,6 +20,7 @@ use crate::refresh_adapters::{ HttpTransport, RefreshAdapter, RefreshError, RefreshedTokens, ValidityOutcome, }; use crate::store::{AuthObservation, EncryptedStore, StoreOpError}; +use crate::test_support::TestTempDir; use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor}; /// A stub adapter that counts refresh calls and returns a fixed rotated token, with @@ -123,10 +124,10 @@ fn now_ms() -> i64 { .unwrap_or(0) } -fn tmp_descriptor() -> (std::path::PathBuf, StorageDescriptor) { +fn tmp_descriptor() -> (TestTempDir, StorageDescriptor) { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-cred-engine-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) diff --git a/crates/credentials-core/src/lib.rs b/crates/credentials-core/src/lib.rs index 306e316..49ffd7b 100644 --- a/crates/credentials-core/src/lib.rs +++ b/crates/credentials-core/src/lib.rs @@ -42,6 +42,8 @@ pub mod resolver; pub mod secret; pub mod signing; pub mod store; +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; pub mod usable; pub use admin_auth::{ diff --git a/crates/credentials-core/src/refresh_adapters/kimi.rs b/crates/credentials-core/src/refresh_adapters/kimi.rs index 13fedf3..4fa9233 100644 --- a/crates/credentials-core/src/refresh_adapters/kimi.rs +++ b/crates/credentials-core/src/refresh_adapters/kimi.rs @@ -220,6 +220,7 @@ fn now_ms() -> i64 { mod tests { use super::*; use crate::refresh_adapters::fixture::FixtureTransport; + use crate::test_support::TestTempDir; fn cred() -> OAuthCredential { OAuthCredential { @@ -290,8 +291,7 @@ mod tests { #[test] fn device_id_file_is_mode_0600_and_stable() { - let root = std::env::temp_dir().join(format!("ck-kimi-device-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); + let root = TestTempDir::new(format!("ck-kimi-device-{}", std::process::id())); let path = device_id_path(&root); let first = ensure_device_id(&path).unwrap(); let second = ensure_device_id(&path).unwrap(); @@ -305,6 +305,5 @@ mod tests { 0o600 ); } - let _ = std::fs::remove_dir_all(root); } } diff --git a/crates/credentials-core/src/resolver.rs b/crates/credentials-core/src/resolver.rs index 8885b6d..8da28b0 100644 --- a/crates/credentials-core/src/resolver.rs +++ b/crates/credentials-core/src/resolver.rs @@ -1096,6 +1096,7 @@ mod tests { ); } use super::*; + use crate::test_support::TestTempDir; /// A platform with no keychain must SAY SO, and say what to do instead. /// @@ -1129,17 +1130,15 @@ mod tests { ); } - fn tmp_dir(tag: &str) -> PathBuf { + fn tmp_dir(tag: &str) -> TestTempDir { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); - let d = std::env::temp_dir().join(format!( + TestTempDir::new(format!( "ck-cred-resolver-{}-{}-{}", std::process::id(), tag, SEQ.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&d).unwrap(); - d + )) } /// A directory fsync that fails for a REAL I/O reason must surface, while a platform diff --git a/crates/credentials-core/src/store.rs b/crates/credentials-core/src/store.rs index 17be935..5b16d9c 100644 --- a/crates/credentials-core/src/store.rs +++ b/crates/credentials-core/src/store.rs @@ -3824,6 +3824,7 @@ fn row_to_intent(row: &rusqlite::Row<'_>) -> rusqlite::Result { #[cfg(test)] mod tests { + use crate::test_support::TestTempDir; /// The scrub shape leaves a WRITABLE store, which was the one claim in the restore /// contract I had only read at source rather than exercised. @@ -3918,17 +3919,17 @@ mod tests { /// A scratch database path under the same temp-dir idiom the rest of this module /// uses: pid plus a counter, so parallel test threads cannot collide and a recycled /// pid on windows cannot inherit a previous run's directory. - fn scratch_db(label: &str) -> std::path::PathBuf { + fn scratch_db(label: &str) -> (TestTempDir, std::path::PathBuf) { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-restore-{}-{}-{}", label, std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) )); - std::fs::create_dir(&root).expect("scratch dir"); - root.join("store.db") + let path = root.join("store.db"); + (root, path) } /// The restore report answers on a file the house read-only form CANNOT open. @@ -3940,7 +3941,7 @@ mod tests { /// store and never exercise the reason the function opens differently. #[test] fn the_restore_report_reads_a_wal_header_with_no_sidecar() { - let path = scratch_db("restored"); + let (_root, path) = scratch_db("restored"); { let c = rusqlite::Connection::open(&path).expect("create"); c.pragma_update(None, "journal_mode", "WAL").expect("wal"); @@ -4025,7 +4026,7 @@ mod tests { /// store, which this repo already fixed once by omitting the counts instead. #[test] fn a_missing_fence_table_is_absent_rather_than_zero() { - let path = scratch_db("nofence"); + let (_root, path) = scratch_db("nofence"); { let c = rusqlite::Connection::open(&path).expect("create"); c.execute_batch( @@ -4062,7 +4063,7 @@ mod tests { fn the_online_grant_listing_keeps_read_and_sign_separate_and_orders_by_prefix() { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-grantorder-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) @@ -4131,7 +4132,7 @@ mod tests { fn a_store_ahead_of_this_binary_refuses_to_migrate() { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-cred-ahead-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) @@ -4175,10 +4176,10 @@ mod tests { use crate::oauth::OAuthCredential; use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor}; - fn tmp_store(seed: u8) -> (std::path::PathBuf, EncryptedStore) { + fn tmp_store(seed: u8) -> (TestTempDir, EncryptedStore) { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-cred-store-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) diff --git a/crates/credentials-core/src/test_support.rs b/crates/credentials-core/src/test_support.rs new file mode 100644 index 0000000..637fe0a --- /dev/null +++ b/crates/credentials-core/src/test_support.rs @@ -0,0 +1,82 @@ +use std::{ + ops::Deref, + path::{Path, PathBuf}, +}; + +/// A test-owned directory that is removed when its owner leaves scope. +#[derive(Debug)] +pub struct TestTempDir { + path: Option, +} + +impl TestTempDir { + pub fn new(name: impl AsRef) -> Self { + let path = std::env::temp_dir().join(name.as_ref()); + Self::from_path(path) + } + + pub fn from_path(path: PathBuf) -> Self { + // A reused PID can collide with an orphaned test directory; refuse rather than + // silently inherit a vault whose store and key material may not belong together. + std::fs::create_dir(&path) + .unwrap_or_else(|e| panic!("create test temp directory {}: {e}", path.display())); + Self { path: Some(path) } + } + + pub fn path(&self) -> &Path { + self.path.as_deref().expect("test temp directory was kept") + } + + /// Preserve evidence only when it must outlive this guard; current crash-cut tests do not. + pub fn keep(mut self) -> PathBuf { + self.path + .take() + .expect("test temp directory was already kept") + } +} + +impl Deref for TestTempDir { + type Target = Path; + + fn deref(&self) -> &Self::Target { + self.path() + } +} + +impl AsRef for TestTempDir { + fn as_ref(&self) -> &Path { + self.path() + } +} + +impl Drop for TestTempDir { + fn drop(&mut self) { + if let Some(path) = self.path.take() { + let _ = std::fs::remove_dir_all(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::TestTempDir; + + #[test] + fn keep_disarms_removal() { + // The crash-cut suites still pass if keep() is broken because they inspect before + // their guards drop. This focused test is the only coverage that detects a clone + // here instead of take(), which would leave Drop armed and erase kept evidence. + let path = TestTempDir::new("ck-cred-test-support-keep").keep(); + assert!(path.exists(), "keep must leave crash-cut evidence behind"); + std::fs::remove_dir_all(path).expect("remove kept test directory"); + } + + #[test] + fn drop_removes_directory() { + let path = { + let dir = TestTempDir::new("ck-cred-test-support-drop"); + dir.path().to_path_buf() + }; + assert!(!path.exists(), "drop must remove the test directory"); + } +} diff --git a/crates/credentials-core/tests/key_verify_takes_nothing.rs b/crates/credentials-core/tests/key_verify_takes_nothing.rs index c9085b4..e5aff0d 100644 --- a/crates/credentials-core/tests/key_verify_takes_nothing.rs +++ b/crates/credentials-core/tests/key_verify_takes_nothing.rs @@ -46,22 +46,20 @@ // defect class as a suite that cannot see the file it claims to verify. #![cfg(feature = "migration-tools")] -use std::path::PathBuf; - use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor}; use credentials_core::audit::AuditOp; use credentials_core::record::{CredentialKind, VaultRecord}; use credentials_core::store::EncryptedStore; +use credentials_core::test_support::TestTempDir; -fn rig() -> PathBuf { +fn rig() -> TestTempDir { use std::sync::atomic::{AtomicU32, Ordering}; static SEQ: AtomicU32 = AtomicU32::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-key-verify-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) )); - let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("data")).unwrap(); std::fs::create_dir_all(root.join("secrets")).unwrap(); root diff --git a/crates/credentials-core/tests/kill9_mid_refresh.rs b/crates/credentials-core/tests/kill9_mid_refresh.rs index 80780d5..5daf656 100644 --- a/crates/credentials-core/tests/kill9_mid_refresh.rs +++ b/crates/credentials-core/tests/kill9_mid_refresh.rs @@ -28,6 +28,7 @@ use credentials_core::refresh_adapters::{ HttpResponse, HttpTransport, RefreshAdapter, RefreshError, RefreshedTokens, }; use credentials_core::store::{EncryptedStore, StoreOpError}; +use credentials_core::test_support::TestTempDir; mod common; @@ -63,8 +64,11 @@ impl HttpTransport for NoHttp { #[tokio::test] async fn kill9_between_response_and_commit_resolves_to_needs_reauth() { - let root = std::env::temp_dir().join(format!("ck-cred-kill9-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); + // The SIGKILL'd helper's evidence is read below, inside this scope, so the guard is still + // alive at every inspection. Holding the GUARD rather than calling keep() means the + // directory is also removed when this test PANICS -- which is when a crash-cut test is + // most likely to leave one behind, and exactly the leak this change exists to close. + let root = TestTempDir::new(format!("ck-cred-kill9-{}", std::process::id())); let db_path = root.join("store.db"); let marker_path = root.join("ready.marker"); @@ -170,5 +174,5 @@ async fn kill9_between_response_and_commit_resolves_to_needs_reauth() { "intent cleared by reconciliation" ); - let _ = std::fs::remove_dir_all(&root); + // No explicit removal: the guard owns it and removes on every exit path, panic included. } diff --git a/crates/credentials-core/tests/login_crash_cut.rs b/crates/credentials-core/tests/login_crash_cut.rs index 4077581..6918516 100644 --- a/crates/credentials-core/tests/login_crash_cut.rs +++ b/crates/credentials-core/tests/login_crash_cut.rs @@ -23,12 +23,13 @@ #![cfg(all(unix, feature = "login-test-seam"))] use std::os::unix::process::ExitStatusExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::{Duration, Instant}; use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor}; use credentials_core::resolver::{self, KeySource, ResolverConfig}; use credentials_core::store::EncryptedStore; +use credentials_core::test_support::TestTempDir; mod common; @@ -37,10 +38,8 @@ const NEW_REFRESH: &str = "NEW-INDEPENDENT-REFRESH-TOKEN"; /// Spawn the helper at one cut point, wait for it to park, SIGKILL it, and return the /// rig dir so the caller can re-open the vault from the killed-at-cut state. -fn kill_at_cut(cut: &str) -> PathBuf { - let root = - std::env::temp_dir().join(format!("ck-cred-login-cut-{}-{}", cut, std::process::id())); - let _ = std::fs::remove_dir_all(&root); +fn kill_at_cut(cut: &str) -> TestTempDir { + let root = TestTempDir::new(format!("ck-cred-login-cut-{}-{}", cut, std::process::id())); let data_dir = root.join("data"); let key_dir = root.join("secrets"); std::fs::create_dir_all(&data_dir).unwrap(); @@ -160,8 +159,6 @@ fn crash_before_login_write_leaves_old_credential_intact_and_refreshable() { !audit_has_op(&store, "login"), "no dangling Login audit entry before the write committed" ); - - let _ = std::fs::remove_dir_all(&root); } #[test] @@ -194,6 +191,4 @@ fn crash_after_login_write_commits_new_credential_and_keeps_handle() { audit_has_op(&store, "login"), "a distinct login audit op was recorded" ); - - let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/credentials-core/tests/rotate_crash_cut.rs b/crates/credentials-core/tests/rotate_crash_cut.rs index f7ced0f..24604ac 100644 --- a/crates/credentials-core/tests/rotate_crash_cut.rs +++ b/crates/credentials-core/tests/rotate_crash_cut.rs @@ -20,7 +20,7 @@ #![cfg(all(unix, feature = "rotate-test-seam"))] use std::os::unix::process::ExitStatusExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{Duration, Instant}; @@ -28,25 +28,25 @@ use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor} use credentials_core::key::MasterKey; use credentials_core::resolver::{self, KeySlot, KeySource, MasterKeyError, ResolverConfig}; use credentials_core::store::{EncryptedStore, StoreOpError}; +use credentials_core::test_support::TestTempDir; mod common; /// Spawn the helper at one cut point, wait for it to park, SIGKILL it, and return /// the rig dir so the caller can re-open the vault from the killed-at-cut state. -fn kill_at_cut(cut: &str) -> PathBuf { +fn kill_at_cut(cut: &str) -> TestTempDir { // Unique per CALL, not per cut. Keying on the cut name alone collided the moment a // second test reused a cut: both rigs resolved to one directory, the second helper // found a provisioned key slot and panicked, and the failure surfaced in whichever // test lost the race rather than in the one that was added. The counter makes the // rig private to a call the way the test reads as if it already were. static RIG_SEQ: AtomicU32 = AtomicU32::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-cred-rotate-cut-{}-{}-{}", cut, std::process::id(), RIG_SEQ.fetch_add(1, Ordering::Relaxed) )); - let _ = std::fs::remove_dir_all(&root); let data_dir = root.join("data"); let key_dir = root.join("secrets"); std::fs::create_dir_all(&data_dir).unwrap(); @@ -248,7 +248,6 @@ fn crash_after_stage_resolves_to_current_and_never_bricks() { "the crash happened AFTER staging, so k2 must be sitting in the next slot" ); assert_wrong_key_fails_closed(&root); - let _ = std::fs::remove_dir_all(&root); } #[test] @@ -280,7 +279,6 @@ fn crash_after_rewrap_resolves_to_next_and_never_bricks() { "the database's key is reachable only via next at this cut" ); assert_wrong_key_fails_closed(&root); - let _ = std::fs::remove_dir_all(&root); } #[test] @@ -312,7 +310,6 @@ fn crash_after_promote_resolves_to_current_and_never_bricks() { "promotion clears next, freeing it for the next rotation" ); assert_wrong_key_fails_closed(&root); - let _ = std::fs::remove_dir_all(&root); } #[test] @@ -353,7 +350,6 @@ fn crash_during_a_resumed_second_rotation_never_bricks() { "the second rotation's key occupies next" ); assert_wrong_key_fails_closed(&root); - let _ = std::fs::remove_dir_all(&root); } /// The read-only usable-scan must read a vault the daemon can still open. @@ -406,5 +402,4 @@ fn the_usable_scan_reads_a_vault_left_mid_rotation() { 1, "the scan must decrypt and report the record, not merely avoid the error: {rows:?}" ); - let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/credentials-core/tests/security_conformance.rs b/crates/credentials-core/tests/security_conformance.rs index 9d1621b..be5b730 100644 --- a/crates/credentials-core/tests/security_conformance.rs +++ b/crates/credentials-core/tests/security_conformance.rs @@ -16,17 +16,16 @@ use credentials_core::audit::{AuditCtx, AuditOp}; use credentials_core::key::{MasterKey, MASTER_KEY_LEN}; use credentials_core::record::{CredentialKind, VaultRecord}; use credentials_core::store::{payload_hash, EncryptedStore, StoreOpError}; +use credentials_core::test_support::TestTempDir; -fn tmp_root(tag: &str) -> std::path::PathBuf { +fn tmp_root(tag: &str) -> TestTempDir { static SEQ: AtomicU64 = AtomicU64::new(0); - let d = std::env::temp_dir().join(format!( + TestTempDir::new(format!( "ck-cred-conf-{}-{}-{}", tag, std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&d).unwrap(); - d + )) } fn descriptor(root: &std::path::Path) -> StorageDescriptor { diff --git a/crates/credentials-module/Cargo.toml b/crates/credentials-module/Cargo.toml index 5dcdae5..bc32e67 100644 --- a/crates/credentials-module/Cargo.toml +++ b/crates/credentials-module/Cargo.toml @@ -47,6 +47,7 @@ chrono = { workspace = true } dialoguer = { version = "0.12", features = ["fuzzy-select"] } [dev-dependencies] +credentials-core = { path = "../credentials-core", features = ["test-support"] } subc-core = { workspace = true } # Read the audit_log directly (after stopping the daemon to release the lease) so # the on-the-wire malicious-client conformance test can assert the durable alarm diff --git a/crates/credentials-module/src/admin_surface.rs b/crates/credentials-module/src/admin_surface.rs index 21b3099..fc77529 100644 --- a/crates/credentials-module/src/admin_surface.rs +++ b/crates/credentials-module/src/admin_surface.rs @@ -361,11 +361,13 @@ mod tests { use credentials_core::key::{MasterKey, MASTER_KEY_LEN}; use credentials_core::record::{CredentialKind, RecordIdentity, VaultRecord}; use credentials_core::store::{mint_handle, EncryptedStore}; + use credentials_core::test_support::TestTempDir; use credentials_core::vault_id_for; /// A test rig: the AdminSurface plus everything a caller-side signer needs /// (the same MAC key derivation the CLI would perform from the keychain key). struct Rig { + _root: TestTempDir, admin: AdminSurface, store: Arc, caller_mac: AdminMacKey, @@ -376,12 +378,11 @@ mod tests { fn rig(seed: u8) -> Rig { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-admin-surface-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) )); - std::fs::create_dir_all(&root).expect("mkdir"); let descriptor = StorageDescriptor { module_id: "cortexkit-credentials".into(), storage_namespace: "default".into(), @@ -401,6 +402,7 @@ mod tests { let http = Arc::new(crate::test_support::NoHttp); let engine = Arc::new(RefreshEngine::new(Arc::clone(&store), Vec::new(), http)); Rig { + _root: root, admin: AdminSurface::new(engine, mac_key, vault_id, key_id), store, caller_mac, diff --git a/crates/credentials-module/src/main.rs b/crates/credentials-module/src/main.rs index 0ed731e..1e427ab 100644 --- a/crates/credentials-module/src/main.rs +++ b/crates/credentials-module/src/main.rs @@ -1702,10 +1702,12 @@ mod tests { use credentials_core::oauth::OAuthCredential; use credentials_core::record::{CredentialKind, VaultRecord}; use credentials_core::store::{GrantOperation, RecordState}; + use credentials_core::test_support::TestTempDir; use read_surface::ReadSurface; - fn tmp_surface(seed: u8) -> Arc { - tmp_surface_with_store(seed).0 + fn tmp_surface(seed: u8) -> (Arc, TestTempDir) { + let (surface, _, _, root) = tmp_surface_with_store(seed); + (surface, root) } /// Boot reconciliation's REASON survives as a durable row. @@ -1721,7 +1723,7 @@ mod tests { /// engine -- it was at the call site. #[tokio::test] async fn boot_reconciliation_records_why_a_credential_needs_reauth() { - let (_, store, _) = tmp_surface_with_store(71); + let (_, store, _, _root) = tmp_surface_with_store(71); let record = VaultRecord::new_oauth( "test", "stub", @@ -1765,8 +1767,14 @@ mod tests { /// A test AdminSurface over the same engine/store shape as tmp_surface, with a /// known master key (seed) so tests can derive the same MAC key caller-side. - fn tmp_admin(seed: u8) -> (Arc, Arc) { - let (_, store, db_path) = tmp_surface_with_store(seed); + fn tmp_admin( + seed: u8, + ) -> ( + Arc, + Arc, + TestTempDir, + ) { + let (_, store, db_path, root) = tmp_surface_with_store(seed); let http = Arc::new(crate::test_support::NoHttp); let engine = Arc::new(RefreshEngine::new(Arc::clone(&store), Vec::new(), http)); let key = MasterKey::from_bytes([seed; MASTER_KEY_LEN]); @@ -1779,20 +1787,24 @@ mod tests { vault_id, key.key_id(), )); - (admin, store) + (admin, store, root) } fn tmp_surface_with_store( seed: u8, - ) -> (Arc, Arc, std::path::PathBuf) { + ) -> ( + Arc, + Arc, + std::path::PathBuf, + TestTempDir, + ) { use std::sync::atomic::{AtomicU64, Ordering}; static SEQ: AtomicU64 = AtomicU64::new(0); - let root = std::env::temp_dir().join(format!( + let root = TestTempDir::new(format!( "ck-cred-health-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed) )); - std::fs::create_dir_all(&root).expect("mkdir"); let db_path = root.join("store.db"); let descriptor = StorageDescriptor { module_id: "cortexkit-credentials".into(), @@ -1825,7 +1837,7 @@ mod tests { let http = Arc::new(crate::test_support::NoHttp); let engine = Arc::new(RefreshEngine::new(Arc::clone(&store), Vec::new(), http)); let surface = Arc::new(ReadSurface::new(engine, FetchLimiter::new(Caps::default()))); - (surface, store, db_path) + (surface, store, db_path, root) } /// A deterministic refresh adapter for minimum-TTL read tests. Its counter proves @@ -1903,7 +1915,7 @@ mod tests { Arc, Arc, ) { - let (_unused_surface, store, _db_path) = tmp_surface_with_store(seed); + let (_unused_surface, store, _db_path, _root) = tmp_surface_with_store(seed); let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let adapter = TtlFixtureAdapter { calls: Arc::clone(&calls), @@ -2159,7 +2171,7 @@ mod tests { Arc, Arc, ) { - let (surface, store, db_path) = tmp_surface_with_store(seed); + let (surface, store, db_path, _root) = tmp_surface_with_store(seed); let http = Arc::new(crate::test_support::NoHttp); let engine = Arc::new(RefreshEngine::new(Arc::clone(&store), Vec::new(), http)); let key = MasterKey::from_bytes([seed; MASTER_KEY_LEN]); @@ -3284,7 +3296,7 @@ mod tests { #[tokio::test] async fn get_through_a_resolving_handle_returns_its_bound_credential_id() { - let (surface, store, _db) = tmp_surface_with_store(93); + let (surface, store, _db, _root) = tmp_surface_with_store(93); let credential_id = "apikey:get-binding-proof"; store .create( @@ -3323,7 +3335,7 @@ mod tests { #[tokio::test] async fn status_through_a_resolving_handle_returns_its_bound_credential_id() { - let (surface, store, _db) = tmp_surface_with_store(94); + let (surface, store, _db, _root) = tmp_surface_with_store(94); let credential_id = "apikey:status-binding-proof"; store .create( @@ -3359,7 +3371,7 @@ mod tests { #[tokio::test] async fn unaddressed_status_omits_credential_id_instead_of_sending_null() { - let (surface, _store, _db) = tmp_surface_with_store(95); + let (surface, _store, _db, _root) = tmp_surface_with_store(95); let encoded = serde_json::to_value( surface .status( @@ -3399,8 +3411,8 @@ mod tests { } } - let (surface, store, _db) = tmp_surface_with_store(96); - let (admin, _admin_store) = tmp_admin(96); + let (surface, store, _db, _root) = tmp_surface_with_store(96); + let (admin, _admin_store, _admin_root) = tmp_admin(96); let credential_id = "apikey:revoked-binding-proof"; store .create( @@ -3527,8 +3539,8 @@ mod tests { ); } - let (surface, store, _db) = tmp_surface_with_store(97); - let (admin, _admin_store) = tmp_admin(97); + let (surface, store, _db, _root) = tmp_surface_with_store(97); + let (admin, _admin_store, _admin_root) = tmp_admin(97); let populated_id = "antigravity:get-wire-contract"; let populated_record = VaultRecord::new_oauth( "test", @@ -3718,8 +3730,8 @@ mod tests { ); } - let (surface, store, _db) = tmp_surface_with_store(92); - let (admin, _admin_store) = tmp_admin(92); + let (surface, store, _db, _root) = tmp_surface_with_store(92); + let (admin, _admin_store, _admin_root) = tmp_admin(92); let handle = credentials_core::store::mint_handle().expect("mint handle"); store .put_handle_hash( @@ -3798,7 +3810,7 @@ mod tests { #[tokio::test] async fn health_check_control_request_returns_domain_report() { - let surface = tmp_surface(7); + let (surface, _surface_root) = tmp_surface(7); let (tx, mut rx) = mpsc::channel::(4); let request = ModuleControlRequest::HealthCheck {}; @@ -3813,7 +3825,7 @@ mod tests { ) .unwrap(); - let (admin, _admin_store) = tmp_admin(7); + let (admin, _admin_store, _admin_root) = tmp_admin(7); let routes = Arc::new(RouteEpochs::default()); handle_control_request(frame, &tx, &surface, &admin, &routes) .await @@ -3849,7 +3861,7 @@ mod tests { /// convenient sequence count. #[tokio::test] async fn health_snapshot_audit_tip_matches_store_tip_pair() { - let (surface, store, _db) = tmp_surface_with_store(10); + let (surface, store, _db, _root) = tmp_surface_with_store(10); let (expected_seq, expected_mac) = store .audit_tip() .expect("read audit tip") @@ -3868,7 +3880,7 @@ mod tests { /// change even though the store itself has advanced. #[tokio::test] async fn health_refresh_recomputes_audit_tip_after_append() { - let (surface, store, _db) = tmp_surface_with_store(12); + let (surface, store, _db, _root) = tmp_surface_with_store(12); let before = surface.health_snapshot(); store .append_audit(&AuditRecord { @@ -3902,7 +3914,7 @@ mod tests { /// immediately; the cached one must not. #[tokio::test] async fn health_probe_serves_cached_snapshot_not_a_live_read() { - let (surface, store, _db) = tmp_surface_with_store(11); + let (surface, store, _db, _root) = tmp_surface_with_store(11); // Initial snapshot (computed at construction): 1 active + 1 needs_reauth. let before = surface.health_snapshot(); @@ -3941,7 +3953,7 @@ mod tests { /// staleness gate can drive it to Failing here. #[tokio::test] async fn a_stalled_refresher_fails_the_probe_closed() { - let surface = tmp_surface(13); + let (surface, _surface_root) = tmp_surface(13); // Fresh snapshot: healthy store, refresher just ran → not Failing. let fresh = surface.health_snapshot(); assert_ne!( @@ -4157,7 +4169,7 @@ mod tests { /// the same probe flips both. #[tokio::test] async fn status_reflects_fenced_out_lease_loss() { - let (surface, store, db_path) = tmp_surface_with_store(14); + let (surface, store, db_path, _root) = tmp_surface_with_store(14); // Mint a handle for the active credential so a per-handle status has a target. let handle = credentials_core::store::mint_handle().expect("mint handle"); store @@ -4220,7 +4232,7 @@ mod tests { /// credential as healthy. #[tokio::test] async fn status_names_the_state_of_each_credential() { - let (surface, store, _db) = tmp_surface_with_store(16); + let (surface, store, _db, _root) = tmp_surface_with_store(16); // The rig seeds apikey:active (Active) and apikey:dead (NeedsReauth). Add a // corrupt row so all three arms of the mapping are exercised in one run. @@ -4510,7 +4522,7 @@ mod tests { /// signing-key refusal associated with the following input. #[tokio::test] async fn get_many_delegates_signing_key_refusal_without_blocking_other_items() { - let (surface, store, _db) = tmp_surface_with_store(85); + let (surface, store, _db, _root) = tmp_surface_with_store(85); let pem = test_ed25519_pem(); store .create( @@ -5044,7 +5056,7 @@ mod tests { #[tokio::test] async fn signing_is_fenced_to_signing_key_records() { use credentials_core::record::CredentialKind; - let (surface, store, _db) = tmp_surface_with_store(31); + let (surface, store, _db, _root) = tmp_surface_with_store(31); // One PEM, deposited twice under different kinds. Same bytes, so the ONLY // difference between the two arms is the kind. @@ -5167,7 +5179,7 @@ mod tests { } } - let (surface, store, _db) = tmp_surface_with_store(32); + let (surface, store, _db, _root) = tmp_surface_with_store(32); let pem = test_ed25519_pem(); store .create( @@ -5292,7 +5304,7 @@ mod tests { /// bumped it would be writing a record it can no longer open. #[tokio::test] async fn a_reactivate_repair_moves_ready_and_leaves_the_version_alone() { - let (surface, store, _db) = tmp_surface_with_store(29); + let (surface, store, _db, _root) = tmp_surface_with_store(29); let record = VaultRecord::new_static( credentials_core::record::CredentialKind::ApiKey, "test", @@ -5360,7 +5372,7 @@ mod tests { /// Metadata-only status answers normally. #[tokio::test] async fn status_does_not_consult_the_refresh_path() { - let (surface, store, _db) = tmp_surface_with_store(23); + let (surface, store, _db, _root) = tmp_surface_with_store(23); // Stale: an OAuth record whose access token expired long ago. Reaching the // refresh path with no adapter registered cannot succeed. let oauth = credentials_core::oauth::OAuthCredential { @@ -5422,7 +5434,7 @@ mod tests { /// that is always Some(1) would satisfy a presence check and be useless. #[tokio::test] async fn status_carries_a_record_version_that_moves_on_replace() { - let (surface, store, _db) = tmp_surface_with_store(16); + let (surface, store, _db, _root) = tmp_surface_with_store(16); let handle = credentials_core::store::mint_handle().expect("mint handle"); store .put_handle_hash( @@ -5523,7 +5535,7 @@ mod tests { /// a hand-staged copy of the mark with no assertion behind it. #[tokio::test] async fn status_publishes_the_stale_mark_without_calling_the_credential_unhealthy() { - let (surface, store, _db) = tmp_surface_with_store(16); + let (surface, store, _db, _root) = tmp_surface_with_store(16); store .create( "oauth:stub", @@ -5673,7 +5685,7 @@ mod tests { // The surface's engine must HOLD the failing adapter -- `tmp_surface_with_store` // builds one with an empty adapter list, and a forced refresh against it answers // `refresh_unsupported` without ever reaching a provider. - let (_unused, store, _db) = tmp_surface_with_store(17); + let (_unused, store, _db, _root) = tmp_surface_with_store(17); let surface = Arc::new(ReadSurface::new( Arc::new(RefreshEngine::new( Arc::clone(&store), @@ -5836,7 +5848,7 @@ mod tests { #[tokio::test] async fn status_handle_probe_runs_the_limiter() { - let (surface, store, _db) = tmp_surface_with_store(15); + let (surface, store, _db, _root) = tmp_surface_with_store(15); // Sweep more distinct unknown handles than the distinct ceiling (16) on ONE // connection, all via status (not get). None resolve — the probe itself is the // signal — so this must still trip the anomaly. @@ -5868,8 +5880,8 @@ mod tests { /// epoch check, not a broken dispatch path. #[tokio::test] async fn stale_epoch_route_frames_are_dropped_before_dispatch() { - let surface = tmp_surface(21); - let (admin, _admin_store) = tmp_admin(21); + let (surface, _surface_root) = tmp_surface(21); + let (admin, _admin_store, _admin_root) = tmp_admin(21); let (control_tx, _control_rx) = mpsc::channel::(8); let (route_tx, mut route_rx) = mpsc::channel::(8); let egress = Egress { @@ -5961,7 +5973,7 @@ mod tests { /// preserve every byte rather than treating its separators or spaces as structure. #[tokio::test] async fn cookie_record_round_trips_byte_exact_through_seal_and_serve() { - let (surface, store, _db) = tmp_surface_with_store(74); + let (surface, store, _db, _root) = tmp_surface_with_store(74); let payload = b" session=abc=123; preference=space value; ending=%".to_vec(); store .create( @@ -6012,7 +6024,7 @@ mod tests { async fn get_quarantines_an_empty_nonrefreshable_record() { use credentials_core::store::RecordState; - let (surface, store, _db) = tmp_surface_with_store(20); + let (surface, store, _db, _root) = tmp_surface_with_store(20); let mut legacy = VaultRecord::new_oauth( "legacy-import", "legacy", @@ -6067,7 +6079,7 @@ mod tests { /// for the admin surface, not a recovery branch for consumers. #[tokio::test] async fn retired_reads_use_the_same_auth_required_refusal_as_needs_reauth() { - let (surface, store, _db) = tmp_surface_with_store(21); + let (surface, store, _db, _root) = tmp_surface_with_store(21); store .create( "apikey:retired", @@ -6148,7 +6160,7 @@ mod tests { async fn report_auth_failure_invalidates_only_on_auth_status_at_the_served_version() { use credentials_core::store::RecordState; - let (surface, store, _db) = tmp_surface_with_store(31); + let (surface, store, _db, _root) = tmp_surface_with_store(31); let raw = credentials_core::store::mint_handle().expect("mint"); store .put_handle_hash( @@ -6293,7 +6305,7 @@ mod tests { use credentials_core::oauth::OAuthCredential; use credentials_core::store::RecordState; - let (surface, store, _db) = tmp_surface_with_store(85); + let (surface, store, _db, _root) = tmp_surface_with_store(85); store .create( "oauth:stub", @@ -6359,7 +6371,7 @@ mod tests { async fn report_on_a_static_oauth_shaped_id_latches_on_the_next_get() { use credentials_core::store::RecordState; - let (surface, store, _db) = tmp_surface_with_store(86); + let (surface, store, _db, _root) = tmp_surface_with_store(86); store .create( "oauth:anthropic", @@ -6429,7 +6441,7 @@ mod tests { async fn get_many_serves_at_the_cap_and_refuses_whole_past_it() { use crate::limiter::GET_MANY_MAX; - let (surface, store, _db) = tmp_surface_with_store(24); + let (surface, store, _db, _root) = tmp_surface_with_store(24); let mut handles = Vec::new(); for i in 0..GET_MANY_MAX { let id = format!("apikey:batch-{i}"); @@ -6506,7 +6518,7 @@ mod tests { async fn get_surfaces_account_id_for_chatgpt_openai_and_none_otherwise() { use credentials_core::oauth::OAuthCredential; - let (surface, store, _db) = tmp_surface_with_store(21); + let (surface, store, _db, _root) = tmp_surface_with_store(21); // A faithful OpenAI access-token JWT carrying the nested claim path // "https://api.openai.com/auth"."chatgpt_account_id" = "acct-e2e-7". Unsigned @@ -6597,7 +6609,7 @@ mod tests { use credentials_core::oauth::OAuthCredential; use credentials_core::record::RecordIdentity; - let (surface, store, _db) = tmp_surface_with_store(22); + let (surface, store, _db, _root) = tmp_surface_with_store(22); let oauth = OAuthCredential { // Opaque (non-JWT) access token — the live claim parse yields nothing, @@ -6694,7 +6706,7 @@ mod tests { use credentials_core::oauth::OAuthCredential; use credentials_core::record::RecordIdentity; - let (surface, store, _db) = tmp_surface_with_store(31); + let (surface, store, _db, _root) = tmp_surface_with_store(31); let oauth = OAuthCredential { access_token: "opaque-access".to_string().into(), refresh_token: "refresh-secret".to_string().into(), diff --git a/crates/credentials-module/tests/cli_admin.rs b/crates/credentials-module/tests/cli_admin.rs index 57a4f76..ca064c9 100644 --- a/crates/credentials-module/tests/cli_admin.rs +++ b/crates/credentials-module/tests/cli_admin.rs @@ -335,7 +335,7 @@ fn a_global_flag_before_the_verb_reaches_the_same_vault_as_one_after_it() { } struct GrantCliVault { - root: PathBuf, + root: credentials_core::test_support::TestTempDir, data_dir: PathBuf, key_path: PathBuf, } diff --git a/crates/credentials-module/tests/cli_opencode.rs b/crates/credentials-module/tests/cli_opencode.rs index 9823429..c419e35 100644 --- a/crates/credentials-module/tests/cli_opencode.rs +++ b/crates/credentials-module/tests/cli_opencode.rs @@ -640,7 +640,7 @@ fn spawn_route_daemon( } struct MigrationRig { - root: PathBuf, + root: credentials_core::test_support::TestTempDir, vault: PathBuf, key: PathBuf, auth: PathBuf, diff --git a/crates/credentials-module/tests/common/mod.rs b/crates/credentials-module/tests/common/mod.rs index 0682c41..35153d5 100644 --- a/crates/credentials-module/tests/common/mod.rs +++ b/crates/credentials-module/tests/common/mod.rs @@ -7,12 +7,13 @@ //! ai-provider-quota consumer driver; only the module id and ops differ. use std::{ - path::{Path, PathBuf}, + path::Path, process, sync::atomic::{AtomicU64, Ordering}, time::Duration, }; +use credentials_core::test_support::TestTempDir; use serde_json::Value; use subc_core::{read_frame, write_frame, Frame}; use subc_protocol::{BindIdentity, Flags, FrameType, Priority, RouteTarget}; @@ -51,26 +52,17 @@ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); /// than re-derived from a symptom three layers downstream. If the antigravity flake /// recurs WITHOUT this firing, the hypothesis is wrong and the next investigation /// starts somewhere genuinely different. -pub fn tmp_root(tag: &str) -> PathBuf { +pub fn tmp_root(tag: &str) -> TestTempDir { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); - let d = std::env::temp_dir().join(format!( + TestTempDir::new(format!( "ck-cred-cli-{}-{}-{}-{nanos:09}", process::id(), tag, TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir(&d).unwrap_or_else(|e| { - panic!( - "temp root {} could not be created fresh ({e}). AlreadyExists here means a \ - path collision across processes, and reusing it would hand this test a \ - stale vault whose store and key file disagree.", - d.display() - ) - }); - d + )) } /// A temp path unique across PROCESSES, not merely within one. @@ -86,13 +78,13 @@ pub fn tmp_root(tag: &str) -> PathBuf { /// /// Callers here create the directory themselves, so this cannot refuse a collision the /// way `tmp_root` does; the nanosecond component only makes one unlikely. -pub fn unique_temp_dir(label: &str) -> PathBuf { +pub fn unique_temp_dir(label: &str) -> TestTempDir { let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.subsec_nanos()) .unwrap_or(0); - std::env::temp_dir().join(format!("{label}-{}-{n}-{nanos:09}", process::id())) + TestTempDir::new(format!("{label}-{}-{n}-{nanos:09}", process::id())) } /// Connect to a daemon from its connection file and complete the client HMAC diff --git a/crates/credentials-module/tests/real_daemon_e2e.rs b/crates/credentials-module/tests/real_daemon_e2e.rs index e9b51d5..46dc91b 100644 --- a/crates/credentials-module/tests/real_daemon_e2e.rs +++ b/crates/credentials-module/tests/real_daemon_e2e.rs @@ -38,20 +38,20 @@ use common::{ use cortexkit_store::{open_sqlite, Isolation, StorageBackend, StorageDescriptor}; use credentials_core::resolver::{KeySource, ResolverConfig}; use credentials_core::store::EncryptedStore; +use credentials_core::test_support::TestTempDir; const SUBCONSCIOUS_REL: &str = "../../../subconscious"; /// A real `ck-subc` daemon process plus its isolated rig dir; killed on drop. struct RealDaemon { child: Child, - rig: PathBuf, + rig: TestTempDir, connection_file: PathBuf, } impl Drop for RealDaemon { fn drop(&mut self) { let _ = self.child.start_kill(); - let _ = std::fs::remove_dir_all(&self.rig); } } diff --git a/scripts/gate.sh b/scripts/gate.sh index c5220b5..0f04c4e 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -120,10 +120,10 @@ run_check "bun build" "$BUN" run build run_check "bun tests (hermetic)" "$BUN" run test:hermetic run_check "clippy" \ - cargo clippy --locked --workspace --all-targets -- -D warnings + cargo clippy --locked --workspace --all-targets --features credentials-core/test-support -- -D warnings run_check "clippy (seam features)" \ cargo clippy --locked --workspace --all-targets \ - --features kill9-test-seam,rotate-test-seam,login-test-seam,migration-tools -- -D warnings + --features credentials-core/test-support,kill9-test-seam,rotate-test-seam,login-test-seam,migration-tools -- -D warnings # Run a cargo invocation and require at least `min` tests to have PASSED, and that # no arm announced a skip. @@ -235,17 +235,17 @@ stream and pass the arm without ever seeing it skip." # follows it), and any gap between the floor and the real count is how many can go # before anyone is told. Measured 402 across the workspace's suites at the time of # writing; an earlier floor of 200 left a third of them free to disappear. -# The current measured total is 578 (debug profile, the same +# The current measured total is 580 (debug profile, the same # `cargo test --locked --workspace` this arm runs). The latest five tests pin the resolved # credential id on get/status, its omission on unresolved shapes, and the get response key -# set in both directions. +# set in both directions. 2026-09-11: the RAII temp-dir lifecycle added two tests. # Do not measure it with `--release`: one login test deliberately fails there and the # pipeline still prints a number, 32 short of the truth. # # Raise this when tests are added. A failure here is normally that, not a defect -- # but it should be a deliberate edit rather than a number nobody revisits. -run_expect 578 "workspace unit + integration" \ - cargo test --locked --workspace +run_expect 580 "workspace unit + integration" \ + cargo test --locked --workspace --features credentials-core/test-support # Two independent defences, because each catches what the other misses: # - CRED_REQUIRE_DAEMON=1 turns an unreachable sibling ck-subc into a failure at