From 3ce633d220e6acfdefd6f33a8fd8b9b8eef3bd5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 16:58:17 +0900 Subject: [PATCH 1/7] test(persistence): replace permission-based fault injection with a deterministic seam Fixes #74. `load_surfaces_state_rewrite_failures` manufactured `persist_state` write/rename failures via `chmod 0o500` on the state directory. That's environment-dependent: a root or DAC-ignoring test runner (some CI container images run tests as root) writes straight through a read-only directory, so the test either silently exercised nothing or, without the early-return guard some environments need, panicked on an `unwrap()` of a success result -- the flake described in the issue. Replace it with `persist_fault`, a `#[cfg(test)]`-only fault-injection seam inside `persist_state` itself: a `thread_local!` flag checked at each of the two failure points (temp-file write, atomic rename). No new public runtime configuration -- the whole module is compiled out of production builds. Thread-local rather than a global lock: the default `#[tokio::test]` flavor is `current_thread`, so one test's entire async call tree runs on a single OS thread, which the harness already gives each test exclusively -- no cross-test synchronization needed, and no risk of one test's injected fault leaking into another's. (First pass used a global `tokio::sync::Mutex` held for each test's whole body, which self-deadlocked: `persist_state`'s own internal check tried to reacquire the same non-reentrant lock the test already held. Second pass split value/lock but still leaked the fault across concurrently running unrelated tests, since persist_state reads it unconditionally. The thread-local design in this commit has neither problem and needs no lock at all.) `load_surfaces_state_rewrite_failures` becomes two focused tests (`load_surfaces_injected_write_temp_failure`, `load_surfaces_injected_rename_failure`), each asserting the exact operator-visible error text, deterministically, on every environment (unprivileged or root) -- not conditionally skipped on any of them. `persists_management_upserts_to_state_file` and `loads_missing_state_file_from_seed_and_persists_it` remain as the real-filesystem coverage of normal atomic persistence. Documented the seam and why POSIX DAC isn't a deterministic failure injector in CLAUDE.md's Tests section. cargo fmt --check, cargo test --locked --workspace, and cargo clippy --locked --workspace --all-targets -D warnings are all clean (the one workspace test failure observed locally, binary_serves_then_shuts_down_on_sigterm, is an unrelated pre-existing local-sandbox SIGTERM-timing flake, not touched by this change, and unaffected across repeated runs of everything else). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 1 + src/lib.rs | 104 +++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f6a0a676..5d29376e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ The core stays an in-repo workspace crate on purpose (no git submodule) until it ## Tests - In-crate HTTP tests: `#[cfg(test)]` module in `src/lib.rs` (uses `tower::ServiceExt` to drive the Axum app). Tests that mutate env vars serialize on `ENV_GUARD`. +- `persist_state`'s write-temp and atomic-rename failure paths are covered by deterministic fault injection (`persist_fault` module, `#[cfg(test)]`-only, `src/lib.rs`), not POSIX file permissions: a root or DAC-ignoring test runner writes straight through a `chmod 0o500` directory, so permission-based injection only exercised the error path on some CI users/filesystems and silently skipped it on others. The injected fault is a `thread_local!`, not a global lock, relying on the default `#[tokio::test]` `current_thread` flavor running each test's whole async call tree on one dedicated OS thread. - E2E binary test: `tests/binary.rs`. - Property-test mirrors of the fuzz invariants (run on stable in normal CI): `tests/fuzz_invariants.rs` and `crates/waf-ids-core/tests/fuzz_invariants.rs` (proptest). - External smoke: `scripts/smoke.sh`. diff --git a/src/lib.rs b/src/lib.rs index c84737fc..cf965969 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -297,6 +297,34 @@ async fn load_or_seed_state(path: &Path) -> Result { } } +/// Test-only deterministic fault injection for [`persist_state`]'s two +/// failure points. POSIX file permissions are not a reliable failure +/// injector for this: a root or DAC-ignoring test runner writes through +/// `0o500` directories, turning the intended error-path regression into a +/// flake (or a silent non-test) depending on the CI user. Injecting the +/// exact failure instead makes the regression deterministic on every +/// environment. See `CLAUDE.md`'s Tests section. +#[cfg(test)] +pub(crate) mod persist_fault { + use std::cell::Cell; + + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + pub(crate) enum Fault { + WriteTemp, + Rename, + } + + thread_local! { + /// Thread-local, not global: the default `#[tokio::test]` flavor is + /// `current_thread`, so one test's entire async call tree (including + /// every nested `persist_state` call) runs on the one OS thread the + /// test harness assigned to that test function. A thread-local flag + /// therefore can't leak into, or be clobbered by, a concurrently + /// running test on another thread -- no cross-test lock needed. + pub(crate) static ACTIVE: Cell> = const { Cell::new(None) }; + } +} + async fn persist_state(path: &Path, data: &AppData) -> Result<(), String> { if let Some(parent) = path .parent() @@ -312,12 +340,27 @@ async fn persist_state(path: &Path, data: &AppData) -> Result<(), String> { let json = serde_json::to_vec_pretty(data).expect("AppData contains only JSON-serializable fields"); let temp_path = temporary_state_path(path); + #[cfg(test)] + if persist_fault::ACTIVE.with(std::cell::Cell::get) == Some(persist_fault::Fault::WriteTemp) { + return Err(format!( + "failed to write temporary state file {}: injected fault", + temp_path.display() + )); + } fs::write(&temp_path, json).await.map_err(|error| { format!( "failed to write temporary state file {}: {error}", temp_path.display() ) })?; + #[cfg(test)] + if persist_fault::ACTIVE.with(std::cell::Cell::get) == Some(persist_fault::Fault::Rename) { + let _ = fs::remove_file(&temp_path).await; + return Err(format!( + "failed to replace state file {}: injected fault", + path.display() + )); + } if let Err(error) = fs::rename(&temp_path, path).await { let _ = fs::remove_file(&temp_path).await; return Err(format!( @@ -6352,25 +6395,38 @@ mod tests { let _ = fs::remove_dir_all(write_dir).await; } - #[cfg(unix)] - #[tokio::test] - async fn load_surfaces_state_rewrite_failures() { - use std::os::unix::fs::PermissionsExt; + /// Injects `fault` into every `persist_state` call made from this test's + /// thread for as long as the returned guard lives, and resets it back + /// to "no fault" when the guard drops (including on an assertion panic + /// mid-test). Thread-local (see `persist_fault::ACTIVE`'s doc comment), + /// so this never needs to coordinate with any other test. + /// + /// Deterministic replacement for permission-based fault injection + /// (`chmod 0o500`): a root or DAC-ignoring test runner writes straight + /// through a read-only directory, so the previous approach was + /// environment-dependent -- it exercised the intended + /// `persist_state` error path only on some CI users/filesystems and + /// silently didn't on others. See issue #74 and `CLAUDE.md`'s Tests + /// section. + struct FaultGuard; + + impl Drop for FaultGuard { + fn drop(&mut self) { + persist_fault::ACTIVE.with(|cell| cell.set(None)); + } + } - let read_only_parent = temp_state_path("read-only-parent"); - fs::create_dir_all(&read_only_parent).await.unwrap(); - let read_only_file = read_only_parent.join("state.json"); - fs::write( - &read_only_file, - serde_json::to_vec_pretty(&AppData::seeded()).unwrap(), - ) - .await - .unwrap(); - std::fs::set_permissions(&read_only_parent, std::fs::Permissions::from_mode(0o500)) - .unwrap(); + fn inject_persist_fault(fault: persist_fault::Fault) -> FaultGuard { + persist_fault::ACTIVE.with(|cell| cell.set(Some(fault))); + FaultGuard + } + + #[tokio::test] + async fn load_surfaces_injected_write_temp_failure() { + let _fault = inject_persist_fault(persist_fault::Fault::WriteTemp); let result = AppState::load(AppConfig { admin_token: None, - state_path: Some(read_only_file.clone()), + state_path: Some(temp_state_path("write-temp-fault").join("state.json")), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, }) @@ -6381,16 +6437,14 @@ mod tests { .unwrap() .contains("failed to write temporary state file") ); - std::fs::set_permissions(&read_only_parent, std::fs::Permissions::from_mode(0o700)) - .unwrap(); - let _ = fs::remove_dir_all(read_only_parent).await; + } - let read_only_dir = temp_state_path("read-only-dir"); - fs::create_dir_all(&read_only_dir).await.unwrap(); - std::fs::set_permissions(&read_only_dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + #[tokio::test] + async fn load_surfaces_injected_rename_failure() { + let _fault = inject_persist_fault(persist_fault::Fault::Rename); let result = AppState::load(AppConfig { admin_token: None, - state_path: Some(read_only_dir.join("state.json")), + state_path: Some(temp_state_path("rename-fault").join("state.json")), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, }) @@ -6399,10 +6453,8 @@ mod tests { result .err() .unwrap() - .contains("failed to write temporary state file") + .contains("failed to replace state file") ); - std::fs::set_permissions(&read_only_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); - let _ = fs::remove_dir_all(read_only_dir).await; } #[tokio::test] From 5902c6d9f9ccff70c65b42fd7d31c823f40ced14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:04:34 +0900 Subject: [PATCH 2/7] test: clean up temp state directories in the new fault-injection tests Addresses Devin's review comment on #93: both new tests (load_surfaces_injected_write_temp_failure, load_surfaces_injected_rename_failure) join a filename onto temp_state_path(...), so persist_state's fs::create_dir_all creates that parent directory before the injected fault fires -- but neither test removed it afterward, unlike the permission-based tests they replaced. PIDs+nanos keep names collision-free across runs, so the only effect was stray empty directories accumulating in the OS temp dir. Added the same fs::remove_dir_all(...).await cleanup used elsewhere in this test module. cargo fmt --check, cargo test --locked -p waf-ids-ai-soc --lib (102 passed), and cargo clippy --locked --workspace --all-targets -D warnings all clean. Co-Authored-By: Claude Sonnet 5 --- src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cf965969..1e62ebf8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6424,9 +6424,10 @@ mod tests { #[tokio::test] async fn load_surfaces_injected_write_temp_failure() { let _fault = inject_persist_fault(persist_fault::Fault::WriteTemp); + let state_dir = temp_state_path("write-temp-fault"); let result = AppState::load(AppConfig { admin_token: None, - state_path: Some(temp_state_path("write-temp-fault").join("state.json")), + state_path: Some(state_dir.join("state.json")), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, }) @@ -6437,14 +6438,16 @@ mod tests { .unwrap() .contains("failed to write temporary state file") ); + let _ = fs::remove_dir_all(state_dir).await; } #[tokio::test] async fn load_surfaces_injected_rename_failure() { let _fault = inject_persist_fault(persist_fault::Fault::Rename); + let state_dir = temp_state_path("rename-fault"); let result = AppState::load(AppConfig { admin_token: None, - state_path: Some(temp_state_path("rename-fault").join("state.json")), + state_path: Some(state_dir.join("state.json")), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, }) @@ -6455,6 +6458,7 @@ mod tests { .unwrap() .contains("failed to replace state file") ); + let _ = fs::remove_dir_all(state_dir).await; } #[tokio::test] From f77eb69748ec6e52db1b3f7e1a707bef33a67278 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:49:52 +0000 Subject: [PATCH 3/7] guard persist_fault injection against multi_thread runtime flavor The thread-local ACTIVE flag only works when the test task runs on the same OS thread as inject_persist_fault. In a multi_thread Tokio runtime, persist_state would run on a worker thread and silently miss the injected failure. Assert the runtime is current_thread at injection time and pin the fault tests to that flavor explicitly. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/lib.rs | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 1e62ebf8..e146d024 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -307,6 +307,7 @@ async fn load_or_seed_state(path: &Path) -> Result { #[cfg(test)] pub(crate) mod persist_fault { use std::cell::Cell; + use tokio::runtime::{Handle, RuntimeFlavor}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Fault { @@ -315,14 +316,37 @@ pub(crate) mod persist_fault { } thread_local! { - /// Thread-local, not global: the default `#[tokio::test]` flavor is - /// `current_thread`, so one test's entire async call tree (including - /// every nested `persist_state` call) runs on the one OS thread the + /// Thread-local, not global: the `#[tokio::test(flavor = "current_thread")]` + /// tests this crate uses run each test's entire async call tree + /// (including every nested `persist_state` call) on the one OS thread the /// test harness assigned to that test function. A thread-local flag /// therefore can't leak into, or be clobbered by, a concurrently /// running test on another thread -- no cross-test lock needed. + /// + /// Fault injection is guarded by a runtime-flavor check in + /// `inject_persist_fault`; a `multi_thread` runtime would silently drop + /// the fault because the worker thread running `persist_state` differs + /// from the test thread, so the guard panics instead of silently + /// skipping coverage. pub(crate) static ACTIVE: Cell> = const { Cell::new(None) }; } + + /// Panic if the current Tokio runtime is not `current_thread`. + /// + /// The thread-local `ACTIVE` flag is only visible on the OS thread that set + /// it. In a `multi_thread` runtime, `persist_state` may run on a different + /// worker thread, which would see `None` and silently miss the injected + /// failure. Guarding at call time turns that silent coverage loss into a + /// loud test failure. + pub(crate) fn assert_current_thread_runtime() { + let handle = Handle::current(); + assert_eq!( + handle.runtime_flavor(), + RuntimeFlavor::CurrentThread, + "persist_fault injection requires a `#[tokio::test(flavor = \"current_thread\")]` runtime; \ + a multi-thread runtime would drop the injected fault on a worker thread" + ); + } } async fn persist_state(path: &Path, data: &AppData) -> Result<(), String> { @@ -6401,6 +6425,10 @@ mod tests { /// mid-test). Thread-local (see `persist_fault::ACTIVE`'s doc comment), /// so this never needs to coordinate with any other test. /// + /// Panics if the test runtime is `multi_thread`, because the thread-local + /// flag would not be visible to the worker thread that actually runs + /// `persist_state`. + /// /// Deterministic replacement for permission-based fault injection /// (`chmod 0o500`): a root or DAC-ignoring test runner writes straight /// through a read-only directory, so the previous approach was @@ -6417,11 +6445,12 @@ mod tests { } fn inject_persist_fault(fault: persist_fault::Fault) -> FaultGuard { + persist_fault::assert_current_thread_runtime(); persist_fault::ACTIVE.with(|cell| cell.set(Some(fault))); FaultGuard } - #[tokio::test] + #[tokio::test(flavor = "current_thread")] async fn load_surfaces_injected_write_temp_failure() { let _fault = inject_persist_fault(persist_fault::Fault::WriteTemp); let state_dir = temp_state_path("write-temp-fault"); @@ -6441,7 +6470,7 @@ mod tests { let _ = fs::remove_dir_all(state_dir).await; } - #[tokio::test] + #[tokio::test(flavor = "current_thread")] async fn load_surfaces_injected_rename_failure() { let _fault = inject_persist_fault(persist_fault::Fault::Rename); let state_dir = temp_state_path("rename-fault"); From 3663c57373df4f3edcd0c1cfdf1deb18f461b91c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:39:21 +0900 Subject: [PATCH 4/7] docs(test): ground deterministic fault injection --- CLAUDE.md | 2 ++ src/lib.rs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5d29376e..663dcfa3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,8 @@ The core stays an in-repo workspace crate on purpose (no git submodule) until it - In-crate HTTP tests: `#[cfg(test)]` module in `src/lib.rs` (uses `tower::ServiceExt` to drive the Axum app). Tests that mutate env vars serialize on `ENV_GUARD`. - `persist_state`'s write-temp and atomic-rename failure paths are covered by deterministic fault injection (`persist_fault` module, `#[cfg(test)]`-only, `src/lib.rs`), not POSIX file permissions: a root or DAC-ignoring test runner writes straight through a `chmod 0o500` directory, so permission-based injection only exercised the error path on some CI users/filesystems and silently skipped it on others. The injected fault is a `thread_local!`, not a global lock, relying on the default `#[tokio::test]` `current_thread` flavor running each test's whole async call tree on one dedicated OS thread. + - Research basis: Zhou et al. describe FoundationDB's randomized, deterministic simulation framework as a way to exercise specific faults reproducibly and make failure paths routinely testable. Wardnet adopts only that narrow deterministic-fault principle for its two local persistence boundaries; it does not claim FoundationDB-scale simulation coverage. Zhou, J., Xu, M., Shraer, A., Namasivayam, B., Miller, A., Tschannen, E., Atherton, S., Beamon, A. J., Sears, R., Leach, J., Rosenthal, D., Dong, X., Wilson, W., Collins, B., Scherer, D., Grieser, A., Liu, Y., Moore, A., Muppana, B., Su, X., & Yadav, V. (2021). FoundationDB: A distributed unbundled transactional key value store. *Proceedings of the 2021 International Conference on Management of Data*, 2653–2666. https://doi.org/10.1145/3448016.3457559 + - The publicly hosted PDF was not committed because its copyright notice permits personal/classroom copying but requires permission for repository redistribution; the DOI and author-hosted reading link are retained instead: https://www.foundationdb.org/files/fdb-paper.pdf - E2E binary test: `tests/binary.rs`. - Property-test mirrors of the fuzz invariants (run on stable in normal CI): `tests/fuzz_invariants.rs` and `crates/waf-ids-core/tests/fuzz_invariants.rs` (proptest). - External smoke: `scripts/smoke.sh`. diff --git a/src/lib.rs b/src/lib.rs index a240c6a5..b0090526 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -303,7 +303,8 @@ async fn load_or_seed_state(path: &Path) -> Result { /// `0o500` directories, turning the intended error-path regression into a /// flake (or a silent non-test) depending on the CI user. Injecting the /// exact failure instead makes the regression deterministic on every -/// environment. See `CLAUDE.md`'s Tests section. +/// environment. See `CLAUDE.md`'s Tests section for the deterministic-testing +/// research basis and its applicability boundary. #[cfg(test)] pub(crate) mod persist_fault { use std::cell::Cell; From 0ad88c13d452beea126540effc41b8528be977c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:19:54 +0900 Subject: [PATCH 5/7] test(persistence): verify injected error contracts --- src/lib.rs | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b0090526..f229db8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6456,18 +6456,29 @@ mod tests { async fn load_surfaces_injected_write_temp_failure() { let _fault = inject_persist_fault(persist_fault::Fault::WriteTemp); let state_dir = temp_state_path("write-temp-fault"); + let state_path = state_dir.join("state.json"); let result = AppState::load(AppConfig { admin_token: None, - state_path: Some(state_dir.join("state.json")), + state_path: Some(state_path), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, }) .await; + let error = result + .err() + .expect("injected write fault must fail loading"); + let prefix = format!( + "failed to write temporary state file {}/.state.json.tmp-{}-", + state_dir.display(), + std::process::id() + ); + let unique = error + .strip_prefix(&prefix) + .and_then(|suffix| suffix.strip_suffix(": injected fault")) + .expect("write fault must report the exact temporary sibling path and cause"); assert!( - result - .err() - .unwrap() - .contains("failed to write temporary state file") + !unique.is_empty() && unique.bytes().all(|byte| byte.is_ascii_digit()), + "temporary sibling suffix must be a nanosecond timestamp: {error}" ); let _ = fs::remove_dir_all(state_dir).await; } @@ -6476,18 +6487,22 @@ mod tests { async fn load_surfaces_injected_rename_failure() { let _fault = inject_persist_fault(persist_fault::Fault::Rename); let state_dir = temp_state_path("rename-fault"); + let state_path = state_dir.join("state.json"); let result = AppState::load(AppConfig { admin_token: None, - state_path: Some(state_dir.join("state.json")), + state_path: Some(state_path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, }) .await; - assert!( + assert_eq!( result .err() - .unwrap() - .contains("failed to replace state file") + .expect("injected rename fault must fail loading"), + format!( + "failed to replace state file {}: injected fault", + state_path.display() + ) ); let _ = fs::remove_dir_all(state_dir).await; } From b38feb94894dc3419a7b31e492d3bba002c1b526 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 04:24:46 +0900 Subject: [PATCH 6/7] test(persistence): use native temp path separators --- src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f229db8b..84c2e9c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6467,10 +6467,10 @@ mod tests { let error = result .err() .expect("injected write fault must fail loading"); + let temp_prefix = state_dir.join(format!(".state.json.tmp-{}-", std::process::id())); let prefix = format!( - "failed to write temporary state file {}/.state.json.tmp-{}-", - state_dir.display(), - std::process::id() + "failed to write temporary state file {}", + temp_prefix.display() ); let unique = error .strip_prefix(&prefix) From b0f4e1c4f2df6263cbb40966a386e5f6f44166b8 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Mon, 31 Aug 2026 11:52:05 +0900 Subject: [PATCH 7/7] test(persistence): cover rewrite fault injection path --- src/lib.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 3169ff77..95291063 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7501,6 +7501,47 @@ mod tests { let _ = fs::remove_dir_all(state_dir).await; } + #[tokio::test(flavor = "current_thread")] + async fn load_surfaces_injected_write_temp_failure_when_rewriting_existing_state() { + let state_dir = temp_state_path("write-temp-rewrite-fault"); + let state_path = state_dir.join("state.json"); + fs::create_dir_all(&state_dir) + .await + .expect("state directory should be created"); + fs::write( + &state_path, + serde_json::to_vec_pretty(&AppData::seeded()).expect("seeded state serializes"), + ) + .await + .expect("seed state should be written"); + + let _fault = inject_persist_fault(persist_fault::Fault::WriteTemp); + let result = AppState::load(AppConfig { + admin_token: None, + state_path: Some(state_path), + dnsbl_origin: "dnsbl.example".to_string(), + event_limit: 10, + }) + .await; + let error = result + .err() + .expect("injected write fault must fail rewriting existing state"); + let temp_prefix = state_dir.join(format!(".state.json.tmp-{}-", std::process::id())); + let prefix = format!( + "failed to write temporary state file {}", + temp_prefix.display() + ); + let unique = error + .strip_prefix(&prefix) + .and_then(|suffix| suffix.strip_suffix(": injected fault")) + .expect("write fault must report the exact temporary sibling path and cause"); + assert!( + !unique.is_empty() && unique.bytes().all(|byte| byte.is_ascii_digit()), + "temporary sibling suffix must be a nanosecond timestamp: {error}" + ); + let _ = fs::remove_dir_all(state_dir).await; + } + #[tokio::test(flavor = "current_thread")] async fn load_surfaces_injected_rename_failure() { let _fault = inject_persist_fault(persist_fault::Fault::Rename);