use std::io::Write;
use std::process::{Command, Stdio};
use tempfile::TempDir;
fn lifeloop_bin() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_BIN_EXE_lifeloop"))
}
fn run_with_stdin(args: &[&str], stdin: &[u8], root: &std::path::Path) -> (i32, Vec<u8>, String) {
let mut child = Command::new(lifeloop_bin())
.args(args)
.env("LIFELOOP_CONTINUATION_ROOT", root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn lifeloop");
match child.stdin.as_mut().expect("stdin").write_all(stdin) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(e) => panic!("write stdin: {e}"),
}
let out = child.wait_with_output().expect("wait");
(
out.status.code().unwrap_or(-1),
out.stdout,
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn run(args: &[&str], root: &std::path::Path) -> (i32, String, String) {
let (code, stdout, stderr) = run_with_stdin(args, &[], root);
(code, String::from_utf8_lossy(&stdout).into_owned(), stderr)
}
#[test]
fn put_then_get_roundtrips_blob_and_meta() {
let dir = TempDir::new().unwrap();
let blob = b"hello continuation store \x00\x01\xff binary content";
let (code, _stdout, stderr) = run_with_stdin(
&[
"continuation",
"put",
"--thread",
"thr-1",
"--key",
"renewal-state",
"--client-id",
"ccd",
],
blob,
dir.path(),
);
assert_eq!(code, 0, "put failed: stderr={stderr}");
let (code, stdout, stderr) = run_with_stdin(
&[
"continuation",
"get",
"--thread",
"thr-1",
"--key",
"renewal-state",
],
&[],
dir.path(),
);
assert_eq!(code, 0, "get failed: stderr={stderr}");
assert_eq!(stdout, blob, "blob round-trip mismatch");
let meta: serde_json::Value = serde_json::from_str(stderr.trim()).expect("meta JSON");
assert_eq!(meta["client_id"], "ccd");
assert!(meta["written_at_epoch_s"].is_number());
assert!(
meta.get("schema_version").is_none(),
"stderr meta must NOT include schema_version per spec (4-field on-disk Meta is internal)"
);
assert!(
meta.get("ttl_s").is_some(),
"ttl_s key must be present (null when unset) per spec"
);
assert!(meta["ttl_s"].is_null(), "ttl_s unset → null on stderr");
}
#[test]
fn put_with_no_client_id_defaults_to_unknown() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k"],
b"data",
dir.path(),
);
let (_, _stdout, stderr) = run_with_stdin(
&["continuation", "get", "--thread", "t", "--key", "k"],
&[],
dir.path(),
);
let meta: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(meta["client_id"], "unknown");
}
#[test]
fn put_then_drop_then_get_returns_not_found() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k"],
b"data",
dir.path(),
);
let (code, stdout, _) = run(
&["continuation", "drop", "--thread", "t", "--key", "k"],
dir.path(),
);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
let (code, _stdout, stderr) = run(
&["continuation", "get", "--thread", "t", "--key", "k"],
dir.path(),
);
assert_ne!(code, 0, "get on absent should fail");
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "not_found");
}
#[test]
fn drop_is_idempotent_when_absent() {
let dir = TempDir::new().unwrap();
let (code, stdout, _) = run(
&["continuation", "drop", "--thread", "t", "--key", "k"],
dir.path(),
);
assert_eq!(code, 0, "drop should be idempotent");
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
}
#[test]
fn list_enumerates_keys_for_thread() {
let dir = TempDir::new().unwrap();
for key in &["renewal-state", "checkpoint-state", "snapshot"] {
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", key],
b"data",
dir.path(),
);
}
let (code, stdout, _) = run(&["continuation", "list", "--thread", "t"], dir.path());
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["thread"], "t");
let keys = parsed["keys"].as_array().unwrap();
assert_eq!(keys.len(), 3);
let key_names: std::collections::HashSet<&str> =
keys.iter().map(|k| k["key"].as_str().unwrap()).collect();
assert!(key_names.contains("renewal-state"));
assert!(key_names.contains("checkpoint-state"));
assert!(key_names.contains("snapshot"));
}
#[test]
fn list_returns_empty_for_unknown_thread() {
let dir = TempDir::new().unwrap();
let (code, stdout, _) = run(&["continuation", "list", "--thread", "ghost"], dir.path());
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["keys"].as_array().unwrap().len(), 0);
}
#[test]
fn drop_thread_deletes_all_keys() {
let dir = TempDir::new().unwrap();
for key in &["k1", "k2", "k3"] {
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", key],
b"data",
dir.path(),
);
}
let (code, stdout, _) = run(
&["continuation", "drop-thread", "--thread", "t"],
dir.path(),
);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["dropped_count"], 3);
let (_, stdout, _) = run(&["continuation", "list", "--thread", "t"], dir.path());
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["keys"].as_array().unwrap().len(), 0);
}
#[test]
fn invalid_thread_with_path_separator_rejected() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin(
&["continuation", "put", "--thread", "foo/bar", "--key", "k"],
b"data",
dir.path(),
);
assert_ne!(code, 0);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
assert_eq!(err["field"], "thread");
}
#[test]
fn invalid_key_with_leading_dot_rejected() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", ".hidden"],
b"data",
dir.path(),
);
assert_ne!(code, 0);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
assert_eq!(err["field"], "key");
}
#[test]
fn invalid_thread_traversal_attempt_rejected() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin(
&["continuation", "put", "--thread", "../escape", "--key", "k"],
b"data",
dir.path(),
);
assert_ne!(code, 0);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
}
#[test]
fn identifier_with_space_rejected_by_strict_allow_list() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin(
&[
"continuation",
"put",
"--thread",
"with space",
"--key",
"k",
],
b"data",
dir.path(),
);
assert_ne!(code, 0, "space in identifier must be rejected");
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
}
#[test]
fn identifier_with_unicode_rejected_by_strict_allow_list() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin(
&["continuation", "put", "--thread", "naïve", "--key", "k"],
b"data",
dir.path(),
);
assert_ne!(
code, 0,
"unicode in identifier must be rejected (non-portable)"
);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
}
#[test]
fn identifier_with_windows_reserved_char_rejected() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin(
&["continuation", "put", "--thread", "foo<bar", "--key", "k"],
b"data",
dir.path(),
);
assert_ne!(
code, 0,
"Windows-reserved char must be rejected (non-portable)"
);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
}
#[test]
fn put_rejects_oversized_client_id() {
let dir = TempDir::new().unwrap();
let huge_client_id = "a".repeat(200); let (code, _stdout, stderr) = run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
&huge_client_id,
],
b"data",
dir.path(),
);
assert_ne!(
code, 0,
"oversized --client-id must be rejected to preserve bounded-read invariant"
);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
assert_eq!(err["field"], "client_id");
}
#[test]
fn put_rejects_client_id_with_path_separator() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
"client/with/slash",
],
b"data",
dir.path(),
);
assert_ne!(code, 0);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
assert_eq!(err["field"], "client_id");
}
#[test]
fn get_rejects_oversized_require_client_id() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k"],
b"data",
dir.path(),
);
let huge = "x".repeat(200);
let (code, _stdout, stderr) = run(
&[
"continuation",
"get",
"--thread",
"t",
"--key",
"k",
"--require-client-id",
&huge,
],
dir.path(),
);
assert_ne!(
code, 0,
"oversized --require-client-id must be rejected symmetrically with --client-id"
);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "invalid_identifier");
}
#[test]
fn identifier_with_full_allowed_set_accepted() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin(
&[
"continuation",
"put",
"--thread",
"abc_DEF-123.tag",
"--key",
"key.name_v2-final",
],
b"data",
dir.path(),
);
assert_eq!(
code, 0,
"legal identifier must be accepted: stderr={stderr}"
);
}
#[test]
fn blob_too_large_rejected_with_spec_error_shape() {
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin_extra_env(
&["continuation", "put", "--thread", "t", "--key", "k"],
&[b'x'; 200],
dir.path(),
&[("LIFELOOP_CONTINUATION_MAX_BYTES", "100")],
);
assert_ne!(code, 0);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "blob_too_large");
assert_eq!(err["max_bytes"], 100);
}
fn run_with_stdin_extra_env(
args: &[&str],
stdin: &[u8],
root: &std::path::Path,
extra_env: &[(&str, &str)],
) -> (i32, Vec<u8>, String) {
let mut cmd = Command::new(lifeloop_bin());
cmd.args(args)
.env("LIFELOOP_CONTINUATION_ROOT", root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in extra_env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn lifeloop");
match child.stdin.as_mut().expect("stdin").write_all(stdin) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(e) => panic!("write stdin: {e}"),
}
let out = child.wait_with_output().expect("wait");
(
out.status.code().unwrap_or(-1),
out.stdout,
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn ttl_expiry_treats_entry_as_not_found() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--ttl-s",
"1",
],
b"data",
dir.path(),
);
std::thread::sleep(std::time::Duration::from_secs(2));
let (code, _stdout, stderr) = run(
&["continuation", "get", "--thread", "t", "--key", "k"],
dir.path(),
);
assert_ne!(code, 0);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "not_found");
}
#[test]
fn ttl_expired_entry_omitted_from_list() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--ttl-s",
"1",
],
b"data",
dir.path(),
);
std::thread::sleep(std::time::Duration::from_secs(2));
let (code, stdout, _) = run(&["continuation", "list", "--thread", "t"], dir.path());
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["keys"].as_array().unwrap().len(), 0);
}
#[test]
fn ttl_overflow_treated_as_expired_not_panic() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--ttl-s",
&u64::MAX.to_string(),
],
b"data",
dir.path(),
);
let (code, _stdout, stderr) = run(
&["continuation", "get", "--thread", "t", "--key", "k"],
dir.path(),
);
assert_ne!(code, 0, "get on TTL-overflow entry must fail (not panic)");
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(
err["error"], "not_found",
"TTL overflow must be treated as expired (failure-closed for adversarial data)"
);
let (code, stdout, _) = run(&["continuation", "list", "--thread", "t"], dir.path());
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
parsed["keys"].as_array().unwrap().len(),
0,
"list must omit TTL-overflow entry"
);
}
#[test]
fn require_client_id_mismatch_returns_specific_error() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
"ccd",
],
b"data",
dir.path(),
);
let (code, _stdout, stderr) = run(
&[
"continuation",
"get",
"--thread",
"t",
"--key",
"k",
"--require-client-id",
"other",
],
dir.path(),
);
assert_ne!(code, 0);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "client_id_mismatch");
assert_eq!(err["required"], "other");
assert_eq!(err["actual"], "ccd");
}
#[test]
fn require_client_id_match_succeeds() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
"ccd",
],
b"data",
dir.path(),
);
let (code, stdout, _) = run_with_stdin(
&[
"continuation",
"get",
"--thread",
"t",
"--key",
"k",
"--require-client-id",
"ccd",
],
&[],
dir.path(),
);
assert_eq!(code, 0);
assert_eq!(stdout, b"data");
}
#[test]
fn require_client_id_blocks_drop_on_mismatch() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
"ccd",
],
b"data",
dir.path(),
);
let (code, _stdout, stderr) = run(
&[
"continuation",
"drop",
"--thread",
"t",
"--key",
"k",
"--require-client-id",
"other",
],
dir.path(),
);
assert_ne!(code, 0);
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "client_id_mismatch");
let (code, _, _) = run(
&["continuation", "get", "--thread", "t", "--key", "k"],
dir.path(),
);
assert_eq!(code, 0);
}
#[test]
fn overwrite_preserves_prior_pair_on_clean_writes() {
let dir = TempDir::new().unwrap();
for (i, payload) in ["first", "second", "third"].iter().enumerate() {
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k"],
payload.as_bytes(),
dir.path(),
);
let (code, stdout, _) = run_with_stdin(
&["continuation", "get", "--thread", "t", "--key", "k"],
&[],
dir.path(),
);
assert_eq!(code, 0);
assert_eq!(stdout, payload.as_bytes(), "iteration {i}");
}
}
#[test]
fn concurrent_puts_last_writer_wins_no_torn_writes() {
use std::sync::Arc;
use std::thread;
let dir = Arc::new(TempDir::new().unwrap());
let root = dir.path().to_path_buf();
let handles: Vec<_> = (0..2)
.map(|i| {
let root = root.clone();
thread::spawn(move || {
let payload = format!("payload-from-thread-{i}");
let (code, _, _) = run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k"],
payload.as_bytes(),
&root,
);
assert_eq!(code, 0);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let (code, stdout, _) = run_with_stdin(
&["continuation", "get", "--thread", "t", "--key", "k"],
&[],
&root,
);
assert_eq!(code, 0);
let stdout_str = String::from_utf8_lossy(&stdout);
assert!(
stdout_str == "payload-from-thread-0" || stdout_str == "payload-from-thread-1",
"got unexpected stdout: {stdout_str:?}",
);
}
#[cfg(unix)]
#[test]
fn stored_files_are_owner_only_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let (code, _stdout, stderr) = run_with_stdin_in_child_umask(
&["continuation", "put", "--thread", "t", "--key", "k"],
b"secret-blob",
dir.path(),
0o022,
);
assert_eq!(
code, 0,
"put failed under permissive umask: stderr={stderr}"
);
let entry_path = dir.path().join("t").join("k");
let meta = std::fs::metadata(&entry_path).expect("entry exists");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode,
0o600,
"blob file at {} has mode {:o} (expected 0o600 — child umask was 0o022, so default-perm would have produced 0o644)",
entry_path.display(),
mode,
);
let parent = dir.path().join("t");
let parent_meta = std::fs::metadata(&parent).expect("thread dir exists");
let parent_mode = parent_meta.permissions().mode() & 0o777;
assert_eq!(
parent_mode,
0o700,
"thread dir at {} has mode {:o} (expected 0o700)",
parent.display(),
parent_mode,
);
}
#[cfg(unix)]
fn run_with_stdin_in_child_umask(
args: &[&str],
stdin: &[u8],
root: &std::path::Path,
umask_value: u32,
) -> (i32, Vec<u8>, String) {
use std::os::unix::process::CommandExt;
let mut cmd = Command::new(lifeloop_bin());
cmd.args(args)
.env("LIFELOOP_CONTINUATION_ROOT", root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
unsafe {
CommandExt::pre_exec(&mut cmd, move || {
unsafe extern "C" {
fn umask(mask: u32) -> u32;
}
umask(umask_value);
Ok(())
});
}
let mut child = cmd.spawn().expect("spawn lifeloop");
match child.stdin.as_mut().expect("stdin").write_all(stdin) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(e) => panic!("write stdin: {e}"),
}
let out = child.wait_with_output().expect("wait");
(
out.status.code().unwrap_or(-1),
out.stdout,
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
#[test]
fn guarded_drop_preserves_foreign_client_entry_under_race() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
"ccd",
],
b"original-ccd-data",
dir.path(),
);
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
"other-client",
],
b"foreign-data",
dir.path(),
);
let (code, _stdout, stderr) = run(
&[
"continuation",
"drop",
"--thread",
"t",
"--key",
"k",
"--require-client-id",
"ccd",
],
dir.path(),
);
assert_ne!(code, 0, "guarded drop on foreign entry must fail");
let err: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap();
assert_eq!(err["error"], "client_id_mismatch");
let (code, stdout, _stderr) = run_with_stdin(
&[
"continuation",
"get",
"--thread",
"t",
"--key",
"k",
"--require-client-id",
"other-client",
],
&[],
dir.path(),
);
assert_eq!(code, 0);
assert_eq!(stdout, b"foreign-data");
}
#[test]
fn client_id_mismatch_envelope_has_only_spec_fields() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
"client-a",
],
b"data",
dir.path(),
);
let (code, _stdout, stderr) = run(
&[
"continuation",
"drop",
"--thread",
"t",
"--key",
"k",
"--require-client-id",
"client-b",
],
dir.path(),
);
assert_ne!(code, 0);
let envelope_line = stderr
.lines()
.find(|line| line.contains("\"error\""))
.expect("error envelope present");
let err: serde_json::Value = serde_json::from_str(envelope_line.trim()).unwrap();
let obj = err.as_object().expect("envelope is JSON object");
let mut keys: Vec<&str> = obj.keys().map(|s| s.as_str()).collect();
keys.sort();
assert_eq!(
keys,
vec!["actual", "error", "required"],
"envelope must have exactly {{error, required, actual}} keys — extra fields would break deny_unknown_fields decoders"
);
assert_eq!(obj["error"], "client_id_mismatch");
assert_eq!(obj["required"], "client-b");
assert_eq!(obj["actual"], "client-a");
}
#[test]
fn guarded_drop_uses_bounded_read_for_initial_validate() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&[
"continuation",
"put",
"--thread",
"t",
"--key",
"k",
"--client-id",
"ccd",
],
b"small-blob",
dir.path(),
);
let entry_path = dir.path().join("t").join("k");
{
use std::io::Write as _;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&entry_path)
.expect("open for append");
let chunk = vec![0u8; 1024 * 1024];
for _ in 0..17 {
f.write_all(&chunk).unwrap();
}
}
let mut cmd = Command::new(lifeloop_bin());
cmd.args([
"continuation",
"drop",
"--thread",
"t",
"--key",
"k",
"--require-client-id",
"ccd",
])
.env("LIFELOOP_CONTINUATION_ROOT", dir.path())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = cmd.output().expect("run guarded drop");
assert_ne!(out.status.code().unwrap_or(-1), 0, "expected non-zero exit");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("storage_failure"),
"expected storage_failure envelope, got stderr: {stderr}"
);
assert!(
entry_path.exists(),
"guarded drop must not unlink when bounded-read trips"
);
}
#[test]
fn drop_thread_skips_non_utf8_filename() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k1"],
b"data1",
dir.path(),
);
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k2"],
b"data2",
dir.path(),
);
let (code, stdout, _) = run(
&["continuation", "drop-thread", "--thread", "t"],
dir.path(),
);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["dropped_count"], 2);
assert!(parsed["dropped_count"].is_number());
}
#[test]
fn drop_thread_cleans_owned_tempfiles() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k"],
b"data",
dir.path(),
);
let thread_dir = dir.path().join("t");
let leftover_tmp = thread_dir.join(".k.tmp.99999-0-0");
std::fs::write(&leftover_tmp, b"partial-write-bytes-leaked").unwrap();
let leftover_claim = thread_dir.join(".k.delete-claim.99999-1-0");
std::fs::write(&leftover_claim, b"abandoned-claim-bytes").unwrap();
assert!(leftover_tmp.exists());
assert!(leftover_claim.exists());
let (code, stdout, _) = run(
&["continuation", "drop-thread", "--thread", "t"],
dir.path(),
);
assert_eq!(code, 0);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["dropped_count"], 1);
assert!(
!leftover_tmp.exists(),
"leftover tempfile must be cleaned by drop-thread"
);
assert!(
!leftover_claim.exists(),
"leftover delete-claim must be cleaned by drop-thread"
);
}
#[test]
fn drop_thread_preserves_foreign_hidden_files() {
let dir = TempDir::new().unwrap();
run_with_stdin(
&["continuation", "put", "--thread", "t", "--key", "k"],
b"data",
dir.path(),
);
let thread_dir = dir.path().join("t");
let foreign_hidden = thread_dir.join(".DS_Store");
std::fs::write(&foreign_hidden, b"macos-metadata").unwrap();
let (code, _stdout, _) = run(
&["continuation", "drop-thread", "--thread", "t"],
dir.path(),
);
assert_eq!(code, 0);
assert!(
foreign_hidden.exists(),
"foreign hidden file must NOT be deleted by drop-thread"
);
let content = std::fs::read(&foreign_hidden).unwrap();
assert_eq!(content, b"macos-metadata");
}