use std::fs;
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::CliError;
const SCHEMA_VERSION: &str = "lifeloop.continuation.v0.1";
const DEFAULT_BLOB_MAX_BYTES: u64 = 16 * 1024 * 1024;
const MAX_IDENTIFIER_LEN: usize = 128;
pub fn run<I: Iterator<Item = String>>(mut args: I) -> Result<(), CliError> {
let action = args.next().ok_or_else(|| {
CliError::Usage(
"continuation requires a subcommand: put | get | drop | list | drop-thread".into(),
)
})?;
match action.as_str() {
"put" => run_put(args),
"get" => run_get(args),
"drop" => run_drop(args),
"list" => run_list(args),
"drop-thread" => run_drop_thread(args),
other => Err(CliError::Usage(format!(
"continuation: unknown subcommand `{other}`; want put|get|drop|list|drop-thread"
))),
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct Meta {
schema_version: String,
client_id: String,
written_at_epoch_s: u64,
#[serde(default)]
ttl_s: Option<u64>,
}
#[derive(Debug)]
struct CommonArgs {
thread: String,
key: String,
}
fn parse_thread_and_key<I: Iterator<Item = String>>(
args: &mut I,
subcommand: &str,
) -> Result<(CommonArgs, Vec<String>), CliError> {
let mut thread: Option<String> = None;
let mut key: Option<String> = None;
let mut leftover: Vec<String> = Vec::new();
while let Some(arg) = args.next() {
match arg.as_str() {
"--thread" => {
thread = Some(require_value(&arg, args.next())?);
}
"--key" => {
key = Some(require_value(&arg, args.next())?);
}
_ => leftover.push(arg),
}
}
let thread = thread.ok_or_else(|| {
CliError::Usage(format!(
"continuation {subcommand}: missing required --thread"
))
})?;
let key = key.ok_or_else(|| {
CliError::Usage(format!("continuation {subcommand}: missing required --key"))
})?;
Ok((CommonArgs { thread, key }, leftover))
}
fn require_value(flag: &str, value: Option<String>) -> Result<String, CliError> {
value.ok_or_else(|| CliError::Usage(format!("flag `{flag}` requires a value")))
}
fn parse_opt_string(leftover: &mut Vec<String>, flag: &str) -> Result<Option<String>, CliError> {
let mut found: Option<String> = None;
let mut consumed: Vec<usize> = Vec::new();
let mut i = 0;
while i < leftover.len() {
if leftover[i] == flag {
if i + 1 >= leftover.len() {
return Err(CliError::Usage(format!("flag `{flag}` requires a value")));
}
found = Some(leftover[i + 1].clone());
consumed.push(i);
consumed.push(i + 1);
i += 2;
} else {
i += 1;
}
}
for idx in consumed.into_iter().rev() {
leftover.remove(idx);
}
Ok(found)
}
fn parse_opt_u64(leftover: &mut Vec<String>, flag: &str) -> Result<Option<u64>, CliError> {
parse_opt_string(leftover, flag)?
.map(|v| {
v.parse::<u64>().map_err(|_| {
CliError::Usage(format!("flag `{flag}` requires a non-negative integer"))
})
})
.transpose()
}
fn reject_extra_args(leftover: &[String], subcommand: &str) -> Result<(), CliError> {
if let Some(extra) = leftover.first() {
return Err(CliError::Usage(format!(
"continuation {subcommand}: unexpected argument `{extra}`"
)));
}
Ok(())
}
fn validate_identifier(value: &str, field: &str) -> Result<(), CliError> {
if value.is_empty() {
return Err(invalid_identifier_error(field, "must not be empty"));
}
if value.len() > MAX_IDENTIFIER_LEN {
return Err(invalid_identifier_error(
field,
&format!("exceeds max length {MAX_IDENTIFIER_LEN}"),
));
}
if value.starts_with('.') {
return Err(invalid_identifier_error(
field,
"must not start with `.` (hidden-file convention)",
));
}
for ch in value.chars() {
if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.') {
return Err(invalid_identifier_error(
field,
"must contain only ASCII alphanumerics, `_`, `-`, or `.`",
));
}
}
Ok(())
}
fn invalid_identifier_error(field: &str, detail: &str) -> CliError {
let envelope = json!({
"error": "invalid_identifier",
"field": field,
"detail": detail,
});
CliError::Input(envelope.to_string())
}
fn storage_failure_error(reason: impl AsRef<str>) -> CliError {
let envelope = json!({
"error": "storage_failure",
"reason": reason.as_ref(),
});
CliError::Input(envelope.to_string())
}
fn continuation_root() -> Result<PathBuf, CliError> {
if let Some(override_path) = env_var_present("LIFELOOP_CONTINUATION_ROOT") {
return Ok(PathBuf::from(override_path));
}
if let Some(xdg) = env_var_present("XDG_STATE_HOME") {
return Ok(PathBuf::from(xdg).join("lifeloop").join("continuation"));
}
let home = env_var_present("HOME").ok_or_else(|| {
storage_failure_error("cannot resolve storage root — neither $XDG_STATE_HOME nor $HOME set")
})?;
Ok(PathBuf::from(home)
.join(".local")
.join("state")
.join("lifeloop")
.join("continuation"))
}
fn env_var_present(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.is_empty())
}
fn thread_dir(common: &CommonArgs) -> Result<PathBuf, CliError> {
Ok(continuation_root()?.join(&common.thread))
}
fn entry_path(common: &CommonArgs) -> Result<PathBuf, CliError> {
Ok(thread_dir(common)?.join(&common.key))
}
fn compose_framed(meta: &Meta, blob: &[u8]) -> Result<Vec<u8>, CliError> {
let meta_bytes = serde_json::to_vec(meta)
.map_err(|err| storage_failure_error(format!("serialize meta: {err}")))?;
let meta_len: u32 = meta_bytes.len().try_into().map_err(|_| {
storage_failure_error(format!(
"meta JSON exceeds u32 length ({} bytes)",
meta_bytes.len()
))
})?;
let mut framed = Vec::with_capacity(4 + meta_bytes.len() + blob.len());
framed.extend_from_slice(&meta_len.to_be_bytes());
framed.extend_from_slice(&meta_bytes);
framed.extend_from_slice(blob);
Ok(framed)
}
fn parse_framed(bytes: &[u8]) -> Result<(Meta, Vec<u8>), CliError> {
if bytes.len() < 4 {
return Err(storage_failure_error(format!(
"framed file is truncated ({} bytes; need at least 4 for header)",
bytes.len()
)));
}
let meta_len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
let meta_start = 4usize;
let blob_start = meta_start
.checked_add(meta_len)
.ok_or_else(|| storage_failure_error("meta_len overflow"))?;
if blob_start > bytes.len() {
return Err(storage_failure_error(format!(
"framed file is truncated (declared meta_len={meta_len} but file is {} bytes)",
bytes.len()
)));
}
let meta: Meta = serde_json::from_slice(&bytes[meta_start..blob_start])
.map_err(|err| storage_failure_error(format!("meta JSON parse: {err}")))?;
let blob = bytes[blob_start..].to_vec();
Ok((meta, blob))
}
fn atomic_write(target: &Path, contents: &[u8]) -> Result<(), CliError> {
let parent = target.parent().ok_or_else(|| {
storage_failure_error(format!("target path has no parent: {}", target.display()))
})?;
create_dir_all_owner_only(parent)?;
let temp_name = format!(
".{}.tmp.{}",
target
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("entry"),
unique_suffix()
);
let temp_path = parent.join(temp_name);
let open_result = {
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
opts.mode(0o600);
opts.open(&temp_path)
};
let mut f = open_result.map_err(|err| {
storage_failure_error(format!("create tempfile {}: {err}", temp_path.display()))
})?;
if let Err(err) = f.write_all(contents) {
let _ = fs::remove_file(&temp_path);
return Err(storage_failure_error(format!(
"write tempfile {}: {err}",
temp_path.display()
)));
}
if let Err(err) = f.sync_all() {
let _ = fs::remove_file(&temp_path);
return Err(storage_failure_error(format!(
"fsync tempfile {}: {err}",
temp_path.display()
)));
}
drop(f);
fs::rename(&temp_path, target).map_err(|err| {
let _ = fs::remove_file(&temp_path);
storage_failure_error(format!(
"atomic rename {} -> {}: {err}",
temp_path.display(),
target.display()
))
})?;
Ok(())
}
fn create_dir_all_owner_only(path: &Path) -> Result<(), CliError> {
#[cfg(unix)]
{
let mut builder = fs::DirBuilder::new();
builder.recursive(true).mode(0o700);
builder.create(path).map_err(|err| {
storage_failure_error(format!(
"create parent dir {} with 0o700: {err}",
path.display()
))
})
}
#[cfg(not(unix))]
{
fs::create_dir_all(path).map_err(|err| {
storage_failure_error(format!("create parent dir {}: {err}", path.display()))
})
}
}
fn unique_suffix() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let pid = std::process::id();
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{pid}-{seq}-{ts}")
}
fn is_owned_tempfile(name: &str) -> bool {
if !name.starts_with('.') {
return false;
}
let stem = &name[1..]; let suffix = if let Some(rest) = stem.rsplit_once(".tmp.").map(|(_, s)| s) {
rest
} else if let Some(rest) = stem.rsplit_once(".delete-claim.").map(|(_, s)| s) {
rest
} else {
return false;
};
let mut parts = suffix.split('-');
let p1 = parts.next();
let p2 = parts.next();
let p3 = parts.next();
if parts.next().is_some() {
return false; }
matches!(
(p1, p2, p3),
(Some(a), Some(b), Some(c))
if !a.is_empty()
&& !b.is_empty()
&& !c.is_empty()
&& a.bytes().all(|c| c.is_ascii_digit())
&& b.bytes().all(|c| c.is_ascii_digit())
&& c.bytes().all(|c| c.is_ascii_digit())
)
}
#[cfg(test)]
mod is_owned_tempfile_tests {
use super::is_owned_tempfile;
#[test]
fn accepts_our_tmp_pattern() {
assert!(is_owned_tempfile(".foo.tmp.99999-0-1234567890"));
assert!(is_owned_tempfile(".key.with.dots.tmp.1-2-3"));
}
#[test]
fn accepts_our_delete_claim_pattern() {
assert!(is_owned_tempfile(".foo.delete-claim.99999-0-1234567890"));
}
#[test]
fn rejects_vim_swapfile_lookalike() {
assert!(!is_owned_tempfile(".tmp.swp"));
assert!(!is_owned_tempfile(".foo.tmp.swp"));
}
#[test]
fn rejects_operator_backup_lookalike() {
assert!(!is_owned_tempfile(".foo.delete-claim.backup"));
}
#[test]
fn rejects_non_dotfiles() {
assert!(!is_owned_tempfile("foo.tmp.1-2-3"));
assert!(!is_owned_tempfile("plain_key"));
}
#[test]
fn rejects_partial_numeric_triples() {
assert!(!is_owned_tempfile(".foo.tmp.1-2")); assert!(!is_owned_tempfile(".foo.tmp.1-2-3-4")); assert!(!is_owned_tempfile(".foo.tmp.--")); assert!(!is_owned_tempfile(".foo.tmp.1-a-3")); }
}
fn run_put<I: Iterator<Item = String>>(args: I) -> Result<(), CliError> {
let mut iter = args.collect::<Vec<_>>().into_iter();
let (common, mut leftover) = parse_thread_and_key(&mut iter, "put")?;
let client_id =
parse_opt_string(&mut leftover, "--client-id")?.unwrap_or_else(|| "unknown".into());
let ttl_s = parse_opt_u64(&mut leftover, "--ttl-s")?;
reject_extra_args(&leftover, "put")?;
validate_identifier(&common.thread, "thread")?;
validate_identifier(&common.key, "key")?;
validate_identifier(&client_id, "client_id")?;
let max_bytes = configured_max_bytes()?;
let blob = read_bounded_stdin_bytes(max_bytes)?;
if blob.len() as u64 > max_bytes {
let envelope = json!({
"error": "blob_too_large",
"max_bytes": max_bytes,
});
return Err(CliError::Input(envelope.to_string()));
}
let now = epoch_s();
let meta = Meta {
schema_version: SCHEMA_VERSION.to_owned(),
client_id,
written_at_epoch_s: now,
ttl_s,
};
let framed = compose_framed(&meta, &blob)?;
let target = entry_path(&common)?;
atomic_write(&target, &framed)?;
println!("{}", json!({ "status": "ok", "written_at_epoch_s": now }));
Ok(())
}
fn read_bounded_stdin_bytes(max_bytes: u64) -> Result<Vec<u8>, CliError> {
let mut buf = Vec::new();
std::io::stdin()
.lock()
.take(max_bytes.saturating_add(1))
.read_to_end(&mut buf)
.map_err(|err| storage_failure_error(format!("put: read stdin: {err}")))?;
Ok(buf)
}
fn configured_max_bytes() -> Result<u64, CliError> {
if let Some(v) = env_var_present("LIFELOOP_CONTINUATION_MAX_BYTES") {
return v.parse::<u64>().map_err(|_| {
CliError::Usage(
"continuation: $LIFELOOP_CONTINUATION_MAX_BYTES must be a non-negative integer"
.into(),
)
});
}
Ok(DEFAULT_BLOB_MAX_BYTES)
}
fn run_get<I: Iterator<Item = String>>(args: I) -> Result<(), CliError> {
let mut iter = args.collect::<Vec<_>>().into_iter();
let (common, mut leftover) = parse_thread_and_key(&mut iter, "get")?;
let require_client_id = parse_opt_string(&mut leftover, "--require-client-id")?;
reject_extra_args(&leftover, "get")?;
validate_identifier(&common.thread, "thread")?;
validate_identifier(&common.key, "key")?;
if let Some(ref rc) = require_client_id {
validate_identifier(rc, "client_id")?;
}
let path = entry_path(&common)?;
let max_bytes = configured_max_bytes()?;
let max_file_bytes = max_bytes.saturating_add(MAX_META_OVERHEAD_BYTES);
let bytes = match read_bounded_file(&path, max_file_bytes) {
Ok(b) => b,
Err(BoundedReadError::NotFound) => return Err(not_found_error()),
Err(BoundedReadError::TooLarge { actual }) => {
return Err(storage_failure_error(format!(
"entry exceeds bounded-read cap ({actual} > {max_file_bytes} bytes): {}",
path.display()
)));
}
Err(BoundedReadError::Io(err)) => {
return Err(storage_failure_error(format!(
"read {}: {err}",
path.display()
)));
}
};
let (meta, blob) = parse_framed(&bytes)?;
if let Some(ttl) = meta.ttl_s
&& meta
.written_at_epoch_s
.checked_add(ttl)
.is_none_or(|exp| epoch_s() >= exp)
{
return Err(not_found_error());
}
if let Some(required) = require_client_id
&& meta.client_id != required
{
let envelope = json!({
"error": "client_id_mismatch",
"required": required,
"actual": meta.client_id,
});
return Err(CliError::Input(envelope.to_string()));
}
let stderr_meta = json!({
"client_id": meta.client_id,
"written_at_epoch_s": meta.written_at_epoch_s,
"ttl_s": meta.ttl_s,
});
eprintln!("{stderr_meta}");
std::io::stdout()
.lock()
.write_all(&blob)
.map_err(|err| storage_failure_error(format!("write blob to stdout: {err}")))?;
Ok(())
}
fn not_found_error() -> CliError {
CliError::Input(json!({ "error": "not_found" }).to_string())
}
const MAX_META_OVERHEAD_BYTES: u64 = 4 + 4 * 1024;
#[derive(Debug)]
enum BoundedReadError {
NotFound,
TooLarge { actual: u64 },
Io(std::io::Error),
}
fn read_bounded_file(path: &Path, max_bytes: u64) -> Result<Vec<u8>, BoundedReadError> {
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(BoundedReadError::NotFound);
}
Err(err) => return Err(BoundedReadError::Io(err)),
};
let len = meta.len();
if len > max_bytes {
return Err(BoundedReadError::TooLarge { actual: len });
}
let mut f = match fs::File::open(path) {
Ok(f) => f,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(BoundedReadError::NotFound);
}
Err(err) => return Err(BoundedReadError::Io(err)),
};
let cap_with_sentinel = max_bytes.saturating_add(1);
let mut buf = Vec::with_capacity(len as usize);
std::io::Read::take(&mut f, cap_with_sentinel)
.read_to_end(&mut buf)
.map_err(BoundedReadError::Io)?;
if buf.len() as u64 > max_bytes {
return Err(BoundedReadError::TooLarge {
actual: buf.len() as u64,
});
}
Ok(buf)
}
fn run_drop<I: Iterator<Item = String>>(args: I) -> Result<(), CliError> {
let mut iter = args.collect::<Vec<_>>().into_iter();
let (common, mut leftover) = parse_thread_and_key(&mut iter, "drop")?;
let require_client_id = parse_opt_string(&mut leftover, "--require-client-id")?;
reject_extra_args(&leftover, "drop")?;
validate_identifier(&common.thread, "thread")?;
validate_identifier(&common.key, "key")?;
if let Some(ref rc) = require_client_id {
validate_identifier(rc, "client_id")?;
}
let path = entry_path(&common)?;
if let Some(required) = require_client_id {
return run_drop_guarded(&path, required);
}
match fs::remove_file(&path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(storage_failure_error(format!(
"remove {}: {err}",
path.display()
)));
}
}
println!("{}", json!({ "status": "ok" }));
Ok(())
}
fn run_drop_guarded(path: &Path, required: String) -> Result<(), CliError> {
let max_bytes = configured_max_bytes()?;
let max_file_bytes = max_bytes.saturating_add(MAX_META_OVERHEAD_BYTES);
let initial_bytes = match read_bounded_file(path, max_file_bytes) {
Ok(bytes) => bytes,
Err(BoundedReadError::NotFound) => {
println!("{}", json!({ "status": "ok" }));
return Ok(());
}
Err(BoundedReadError::TooLarge { actual }) => {
return Err(storage_failure_error(format!(
"entry exceeds bounded-read cap ({actual} > {max_file_bytes} bytes): {}",
path.display()
)));
}
Err(BoundedReadError::Io(err)) => {
return Err(storage_failure_error(format!(
"read {}: {err}",
path.display()
)));
}
};
let (initial_meta, _) = parse_framed(&initial_bytes)?;
if let Some(ttl) = initial_meta.ttl_s
&& initial_meta
.written_at_epoch_s
.checked_add(ttl)
.is_none_or(|exp| epoch_s() >= exp)
{
println!("{}", json!({ "status": "ok" }));
return Ok(());
}
if initial_meta.client_id != required {
let envelope = json!({
"error": "client_id_mismatch",
"required": required,
"actual": initial_meta.client_id,
});
return Err(CliError::Input(envelope.to_string()));
}
let parent = path.parent().ok_or_else(|| {
storage_failure_error(format!("target has no parent: {}", path.display()))
})?;
let claim_name = format!(
".{}.delete-claim.{}",
path.file_name().and_then(|n| n.to_str()).unwrap_or("entry"),
unique_suffix()
);
let claim_path = parent.join(claim_name);
match fs::rename(path, &claim_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
println!("{}", json!({ "status": "ok" }));
return Ok(());
}
Err(err) => {
return Err(storage_failure_error(format!(
"claim rename {} -> {}: {err}",
path.display(),
claim_path.display()
)));
}
}
let claim_bytes = match read_bounded_file(&claim_path, max_file_bytes) {
Ok(bytes) => bytes,
Err(BoundedReadError::NotFound) => {
println!("{}", json!({ "status": "ok" }));
return Ok(());
}
Err(BoundedReadError::TooLarge { actual }) => {
return Err(storage_failure_error(format!(
"claim entry exceeds bounded-read cap ({actual} > {max_file_bytes} bytes): {}",
claim_path.display()
)));
}
Err(BoundedReadError::Io(err)) => {
return Err(storage_failure_error(format!(
"read claim {}: {err}",
claim_path.display()
)));
}
};
let (claim_meta, _) = parse_framed(&claim_bytes)?;
if claim_meta.client_id != required {
let link_result = fs::hard_link(&claim_path, path);
match link_result {
Ok(()) => {
if let Err(e) = fs::remove_file(&claim_path) {
eprintln!(
"{}",
json!({
"diagnostic": "foreign_entry_restored_with_residual_claim",
"path": path.display().to_string(),
"claim_path": claim_path.display().to_string(),
"residual_unlink_error": e.to_string(),
"reason": "hard_link succeeded but claim unlink failed; foreign entry is visible at path, claim_path is a hygiene leftover",
})
);
} else {
eprintln!(
"{}",
json!({
"diagnostic": "foreign_entry_restored",
"path": path.display().to_string(),
"reason": "concurrent foreign-client put raced between validate and claim; restored to expected path via hard_link",
})
);
}
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
eprintln!(
"{}",
json!({
"diagnostic": "claim_file_left_in_place",
"claim_path": claim_path.display().to_string(),
"reason": "3-way race — newer put landed at target path; foreign data orphaned at claim_path (newer entry preserved per last-writer-wins)",
})
);
}
Err(err) => {
eprintln!(
"{}",
json!({
"diagnostic": "claim_file_left_in_place",
"claim_path": claim_path.display().to_string(),
"hard_link_error": err.to_string(),
"reason": "hard_link failed for non-AlreadyExists reason; foreign data orphaned at claim_path",
})
);
}
}
let envelope = json!({
"error": "client_id_mismatch",
"required": required,
"actual": claim_meta.client_id,
});
return Err(CliError::Input(envelope.to_string()));
}
if let Err(err) = fs::remove_file(&claim_path) {
return Err(storage_failure_error(format!(
"unlink claim {}: {err}",
claim_path.display()
)));
}
println!("{}", json!({ "status": "ok" }));
Ok(())
}
fn run_list<I: Iterator<Item = String>>(args: I) -> Result<(), CliError> {
let mut iter = args.collect::<Vec<_>>().into_iter();
let mut thread: Option<String> = None;
while let Some(arg) = iter.next() {
match arg.as_str() {
"--thread" => thread = Some(require_value(&arg, iter.next())?),
other => {
return Err(CliError::Usage(format!(
"continuation list: unexpected argument `{other}`"
)));
}
}
}
let thread = thread
.ok_or_else(|| CliError::Usage("continuation list: missing required --thread".into()))?;
validate_identifier(&thread, "thread")?;
let dir = continuation_root()?.join(&thread);
let max_bytes = configured_max_bytes()?;
let max_file_bytes = max_bytes.saturating_add(MAX_META_OVERHEAD_BYTES);
let mut keys: Vec<serde_json::Value> = Vec::new();
match fs::read_dir(&dir) {
Ok(rd) => {
for entry in rd {
let entry = entry.map_err(|err| {
storage_failure_error(format!("iterate {}: {err}", dir.display()))
})?;
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue, };
if name_str.starts_with('.') {
continue;
}
let path = entry.path();
let bytes = match read_bounded_file(&path, max_file_bytes) {
Ok(b) => b,
Err(BoundedReadError::NotFound) => continue, Err(BoundedReadError::TooLarge { actual }) => {
eprintln!(
"{}",
json!({
"diagnostic": "list_skipped_entry",
"path": path.display().to_string(),
"reason": "bounded_read_too_large",
"actual_bytes": actual,
})
);
continue;
}
Err(BoundedReadError::Io(err)) => {
eprintln!(
"{}",
json!({
"diagnostic": "list_skipped_entry",
"path": path.display().to_string(),
"reason": "io_error",
"error": err.to_string(),
})
);
continue;
}
};
let (meta, _) = match parse_framed(&bytes) {
Ok(pair) => pair,
Err(err) => {
eprintln!(
"{}",
json!({
"diagnostic": "list_skipped_entry",
"path": path.display().to_string(),
"reason": "malformed_meta",
"error": err.message(),
})
);
continue;
}
};
if let Some(ttl) = meta.ttl_s
&& meta
.written_at_epoch_s
.checked_add(ttl)
.is_none_or(|exp| epoch_s() >= exp)
{
continue;
}
keys.push(json!({
"key": name_str,
"client_id": meta.client_id,
"written_at_epoch_s": meta.written_at_epoch_s,
"ttl_s": meta.ttl_s,
}));
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
}
Err(err) => {
return Err(storage_failure_error(format!(
"open {}: {err}",
dir.display()
)));
}
}
println!("{}", json!({ "thread": thread, "keys": keys }));
Ok(())
}
fn run_drop_thread<I: Iterator<Item = String>>(args: I) -> Result<(), CliError> {
let mut iter = args.collect::<Vec<_>>().into_iter();
let mut thread: Option<String> = None;
while let Some(arg) = iter.next() {
match arg.as_str() {
"--thread" => thread = Some(require_value(&arg, iter.next())?),
other => {
return Err(CliError::Usage(format!(
"continuation drop-thread: unexpected argument `{other}`"
)));
}
}
}
let thread = thread.ok_or_else(|| {
CliError::Usage("continuation drop-thread: missing required --thread".into())
})?;
validate_identifier(&thread, "thread")?;
let dir = continuation_root()?.join(&thread);
let mut dropped: u32 = 0;
match fs::read_dir(&dir) {
Ok(rd) => {
for entry in rd {
let entry = entry.map_err(|err| {
storage_failure_error(format!("iterate {}: {err}", dir.display()))
})?;
let path = entry.path();
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
if name_str.starts_with('.') && !is_owned_tempfile(name_str) {
continue;
}
match fs::remove_file(&path) {
Ok(()) => {
if !name_str.starts_with('.') {
dropped = dropped.saturating_add(1);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(storage_failure_error(format!(
"remove {}: {err}",
path.display()
)));
}
}
}
let _ = fs::remove_dir(&dir);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
}
Err(err) => {
return Err(storage_failure_error(format!(
"open {}: {err}",
dir.display()
)));
}
}
println!("{}", json!({ "status": "ok", "dropped_count": dropped }));
Ok(())
}
fn epoch_s() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_framed_maps_missing_ttl_s_key_to_none() {
let meta = r#"{"schema_version":"lifeloop.continuation.v0.1","client_id":"x","written_at_epoch_s":1}"#;
let meta_bytes = meta.as_bytes();
let mut framed = Vec::with_capacity(4 + meta_bytes.len());
framed.extend_from_slice(&(meta_bytes.len() as u32).to_be_bytes());
framed.extend_from_slice(meta_bytes);
framed.extend_from_slice(b"blob");
let (meta, _) = parse_framed(&framed).expect("missing ttl_s → None");
assert!(meta.ttl_s.is_none(), "missing ttl_s key → None (no TTL)");
}
#[test]
fn parse_framed_accepts_ttl_s_null() {
let good_meta = r#"{"schema_version":"lifeloop.continuation.v0.1","client_id":"x","written_at_epoch_s":1,"ttl_s":null}"#;
let meta_bytes = good_meta.as_bytes();
let mut framed = Vec::with_capacity(4 + meta_bytes.len());
framed.extend_from_slice(&(meta_bytes.len() as u32).to_be_bytes());
framed.extend_from_slice(meta_bytes);
framed.extend_from_slice(b"blob");
let (meta, blob) = parse_framed(&framed).expect("ttl_s null is valid");
assert!(meta.ttl_s.is_none(), "ttl_s null → None (no TTL)");
assert_eq!(blob, b"blob");
}
#[test]
fn parse_framed_accepts_ttl_s_u64() {
let good_meta = r#"{"schema_version":"lifeloop.continuation.v0.1","client_id":"x","written_at_epoch_s":1,"ttl_s":300}"#;
let meta_bytes = good_meta.as_bytes();
let mut framed = Vec::with_capacity(4 + meta_bytes.len());
framed.extend_from_slice(&(meta_bytes.len() as u32).to_be_bytes());
framed.extend_from_slice(meta_bytes);
framed.extend_from_slice(b"blob");
let (meta, _) = parse_framed(&framed).expect("ttl_s u64 is valid");
assert_eq!(meta.ttl_s, Some(300));
}
#[test]
fn validate_identifier_accepts_normal_names() {
assert!(validate_identifier("thread-abc", "thread").is_ok());
assert!(validate_identifier("01H8K7Q2RXJG7HBPV5MDT9NS3R", "thread").is_ok());
assert!(validate_identifier("renewal-state", "key").is_ok());
}
#[test]
fn validate_identifier_rejects_path_separators() {
let err = validate_identifier("foo/bar", "thread").unwrap_err();
assert!(err.message().contains("invalid_identifier"));
assert!(err.message().contains("ASCII alphanumerics"));
let err = validate_identifier("foo\\bar", "thread").unwrap_err();
assert!(err.message().contains("ASCII alphanumerics"));
}
#[test]
fn validate_identifier_leading_dot_takes_precedence_over_separators() {
let err = validate_identifier("../escape", "thread").unwrap_err();
assert!(err.message().contains("invalid_identifier"));
assert!(err.message().contains("hidden-file"));
}
#[test]
fn validate_identifier_rejects_leading_dot() {
let err = validate_identifier(".hidden", "key").unwrap_err();
assert!(err.message().contains("invalid_identifier"));
assert!(err.message().contains("hidden-file"));
}
#[test]
fn validate_identifier_rejects_empty() {
let err = validate_identifier("", "thread").unwrap_err();
assert!(err.message().contains("invalid_identifier"));
}
#[test]
fn validate_identifier_rejects_control_chars() {
let err = validate_identifier("with\nnewline", "key").unwrap_err();
assert!(err.message().contains("invalid_identifier"));
}
#[test]
fn validate_identifier_rejects_oversized() {
let big = "a".repeat(MAX_IDENTIFIER_LEN + 1);
let err = validate_identifier(&big, "key").unwrap_err();
assert!(err.message().contains("invalid_identifier"));
}
#[test]
fn framing_roundtrip_preserves_meta_and_blob() {
let meta = Meta {
schema_version: SCHEMA_VERSION.to_owned(),
client_id: "test-client".to_owned(),
written_at_epoch_s: 1716385200,
ttl_s: Some(600),
};
let blob = b"hello world\x00\x01\x02 binary content";
let framed = compose_framed(&meta, blob).unwrap();
let (m2, b2) = parse_framed(&framed).unwrap();
assert_eq!(meta, m2);
assert_eq!(blob.as_slice(), b2.as_slice());
}
#[test]
fn framing_rejects_truncated_header() {
let err = parse_framed(&[0u8, 0, 0]).unwrap_err();
assert!(err.message().contains("truncated"));
}
#[test]
fn framing_rejects_truncated_meta() {
let mut bytes = vec![0u8, 0, 0, 100];
bytes.extend_from_slice(&[0u8; 10]);
let err = parse_framed(&bytes).unwrap_err();
assert!(err.message().contains("truncated"));
}
#[test]
fn invalid_identifier_envelope_shape() {
let err = validate_identifier(".hidden", "key").unwrap_err();
let parsed: serde_json::Value = serde_json::from_str(err.message()).unwrap();
assert_eq!(parsed["error"], "invalid_identifier");
assert_eq!(parsed["field"], "key");
assert!(parsed["detail"].is_string());
}
#[test]
fn guarded_drop_on_expired_foreign_entry_is_idempotent_ok() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("entry");
let meta = Meta {
schema_version: SCHEMA_VERSION.to_owned(),
client_id: "client-a".to_owned(),
written_at_epoch_s: epoch_s().saturating_sub(1000),
ttl_s: Some(1),
};
let framed = compose_framed(&meta, b"blob").unwrap();
fs::write(&path, &framed).unwrap();
let result = run_drop_guarded(&path, "client-b".to_owned());
assert!(
result.is_ok(),
"expired foreign entry must drop idempotently, got: {result:?}"
);
}
#[test]
fn guarded_drop_on_live_foreign_entry_still_mismatches() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("entry");
let meta = Meta {
schema_version: SCHEMA_VERSION.to_owned(),
client_id: "client-a".to_owned(),
written_at_epoch_s: epoch_s(),
ttl_s: Some(3600),
};
let framed = compose_framed(&meta, b"blob").unwrap();
fs::write(&path, &framed).unwrap();
let err = run_drop_guarded(&path, "client-b".to_owned()).unwrap_err();
assert!(err.message().contains("client_id_mismatch"));
}
}