Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions crates/credentials-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,32 @@ 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
# process at exactly that point so a parent test can SIGKILL it and prove
# 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
# handover's cut points are the boundaries between discrete PUBLIC operations
# (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
Expand All @@ -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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: migration-tools is a production one-shot feature (ck_key_move/ck_key_verify write master-key material), not an integration test, yet it now pulls in test-support, contradicting the stated goal that test fixtures stay out of normal builds. Neither migration bin uses test_support/TestTempDir and neither has unit tests, so the dependency is unnecessary and widens the test-code surface in a master-key-handling tool. Drop the test-support dependency and keep the feature empty.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/credentials-core/Cargo.toml, line 48:

<comment>`migration-tools` is a production one-shot feature (ck_key_move/ck_key_verify write master-key material), not an integration test, yet it now pulls in `test-support`, contradicting the stated goal that test fixtures stay out of normal builds. Neither migration bin uses `test_support`/`TestTempDir` and neither has unit tests, so the dependency is unnecessary and widens the test-code surface in a master-key-handling tool. Drop the `test-support` dependency and keep the feature empty.</comment>

<file context>
@@ -42,7 +45,7 @@ rotate-test-seam = []
 # 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
</file context>
Suggested change
migration-tools = ["test-support"]
migration-tools = []


# 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
Expand Down
9 changes: 4 additions & 5 deletions crates/credentials-core/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,10 @@ fn canonical_path_bytes(path: &Path) -> Vec<u8> {
#[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]
Expand Down Expand Up @@ -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)
};
Expand Down
5 changes: 3 additions & 2 deletions crates/credentials-core/src/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions crates/credentials-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
5 changes: 2 additions & 3 deletions crates/credentials-core/src/refresh_adapters/kimi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -305,6 +305,5 @@ mod tests {
0o600
);
}
let _ = std::fs::remove_dir_all(root);
}
}
9 changes: 4 additions & 5 deletions crates/credentials-core/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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
Expand Down
21 changes: 11 additions & 10 deletions crates/credentials-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3824,6 +3824,7 @@ fn row_to_intent(row: &rusqlite::Row<'_>) -> rusqlite::Result<RefreshIntent> {

#[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.
Expand Down Expand Up @@ -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!(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These two tests migrated to TestTempDir but kept the manual let _ = std::fs::remove_dir_all(root); at the end, which now undercuts the guard's purpose. Because remove_dir_all(root) moves (consumes) the TestTempDir, the guard's Drop never runs after the store drops, and at that point the store's rusqlite connection is still open, so on Windows the removal fails (open file handle) and the error is silently swallowed — the directory leaks, defeating this PR's goal. Rely on the guard instead: drop the manual remove_dir_all and let the store drop first (reverse declaration order closes the connection) before the guard removes the directory, which works on all platforms.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/credentials-core/src/store.rs, line 3925:

<comment>These two tests migrated to TestTempDir but kept the manual `let _ = std::fs::remove_dir_all(root);` at the end, which now undercuts the guard's purpose. Because `remove_dir_all(root)` moves (consumes) the TestTempDir, the guard's Drop never runs after the store drops, and at that point the store's rusqlite connection is still open, so on Windows the removal fails (open file handle) and the error is silently swallowed — the directory leaks, defeating this PR's goal. Rely on the guard instead: drop the manual `remove_dir_all` and let the store drop first (reverse declaration order closes the connection) before the guard removes the directory, which works on all platforms.</comment>

<file context>
@@ -3918,17 +3919,17 @@ mod tests {
         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,
</file context>

"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.
Expand All @@ -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");
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions crates/credentials-core/src/test_support.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>,
}

impl TestTempDir {
pub fn new(name: impl AsRef<str>) -> 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<Path> 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These unit tests use fixed temp names, so an interrupted keep_disarms_removal or concurrent test run makes create_dir return AlreadyExists and panics before the test. Include a process or sequence suffix in both names so the tests can be rerun safely.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/credentials-core/src/test_support.rs, line 69:

<comment>These unit tests use fixed temp names, so an interrupted `keep_disarms_removal` or concurrent test run makes `create_dir` return `AlreadyExists` and panics before the test. Include a process or sequence suffix in both names so the tests can be rerun safely.</comment>

<file context>
@@ -0,0 +1,82 @@
+        // 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");
</file context>

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");
}
}
8 changes: 3 additions & 5 deletions crates/credentials-core/tests/key_verify_takes_nothing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions crates/credentials-core/tests/kill9_mid_refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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.
}
Loading