use crate::error::FaucetError;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
#[async_trait]
pub trait StateStore: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError>;
async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError>;
async fn delete(&self, key: &str) -> Result<(), FaucetError>;
async fn check(
&self,
_ctx: &crate::check::CheckContext,
) -> Result<crate::check::CheckReport, FaucetError> {
Ok(crate::check::CheckReport::not_implemented())
}
fn supports_list(&self) -> bool {
false
}
async fn list(&self, _prefix: &str) -> Result<Vec<String>, FaucetError> {
Err(FaucetError::State(
"this state store cannot enumerate its keys".into(),
))
}
fn supports_atomic_batch(&self) -> bool {
false
}
async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
for (key, value) in entries {
self.put(key, value).await?;
}
Ok(())
}
}
pub const STATE_EXPORT_VERSION: u32 = 1;
pub fn namespace_prefix(pipeline: &str) -> String {
format!("{pipeline}::")
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StateExport {
pub version: u32,
pub pipeline: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exported_at: Option<String>,
#[serde(default)]
pub keys: std::collections::BTreeMap<String, Value>,
}
impl StateExport {
pub fn new(pipeline: impl Into<String>) -> Self {
Self {
version: STATE_EXPORT_VERSION,
pipeline: pipeline.into(),
exported_at: None,
keys: Default::default(),
}
}
pub fn from_value(value: Value) -> Result<Self, FaucetError> {
let version = match value.get("version") {
None => {
return Err(FaucetError::State(
"state export has no `version` field".into(),
));
}
Some(v) => v.as_u64().ok_or_else(|| {
FaucetError::State(format!(
"state export `version` must be an integer, got {v}"
))
})?,
};
if version == 0 || version > u64::from(STATE_EXPORT_VERSION) {
return Err(FaucetError::State(format!(
"state export version {version} is not supported by this build (supported: \
{STATE_EXPORT_VERSION}) — import it with the faucet release that wrote it or newer"
)));
}
let export: Self = serde_json::from_value(value)
.map_err(|e| FaucetError::State(format!("malformed state export: {e}")))?;
export.validate()?;
Ok(export)
}
pub fn validate(&self) -> Result<(), FaucetError> {
validate_state_key(&self.pipeline)
.map_err(|e| FaucetError::State(format!("state export pipeline name: {e}")))?;
let ns = namespace_prefix(&self.pipeline);
for key in self.keys.keys() {
validate_state_key(key)?;
if !key.starts_with(&ns) {
return Err(FaucetError::State(format!(
"state export key '{key}' is outside the namespace '{ns}' of pipeline '{}'",
self.pipeline
)));
}
}
Ok(())
}
}
pub async fn export_namespace(
store: &dyn StateStore,
pipeline: &str,
) -> Result<StateExport, FaucetError> {
let mut export = StateExport::new(pipeline);
for key in store.list(&namespace_prefix(pipeline)).await? {
if let Some(value) = store.get(&key).await? {
export.keys.insert(key, value);
}
}
Ok(export)
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
pub struct ImportReport {
pub written: Vec<String>,
pub deleted: Vec<String>,
pub atomic: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn import_namespace(
store: &dyn StateStore,
export: &StateExport,
replace: bool,
) -> Result<ImportReport, FaucetError> {
export.validate()?;
let entries: Vec<(String, Value)> = export
.keys
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let stale: Vec<String> = if replace {
store
.list(&namespace_prefix(&export.pipeline))
.await?
.into_iter()
.filter(|k| !export.keys.contains_key(k))
.collect()
} else {
Vec::new()
};
let mut report = ImportReport {
atomic: store.supports_atomic_batch(),
..Default::default()
};
if report.atomic {
if let Err(e) = store.put_batch(&entries).await {
report.error = Some(e.to_string());
return Ok(report);
}
report.written = entries.into_iter().map(|(k, _)| k).collect();
} else {
for (key, value) in &entries {
if let Err(e) = store.put(key, value).await {
report.error = Some(format!("writing '{key}': {e}"));
return Ok(report);
}
report.written.push(key.clone());
}
}
for key in stale {
if let Err(e) = store.delete(&key).await {
report.error = Some(format!("deleting stale '{key}': {e}"));
return Ok(report);
}
report.deleted.push(key);
}
Ok(report)
}
pub const DOCTOR_SENTINEL_KEY: &str = "faucet_doctor_probe";
pub fn validate_state_key(key: &str) -> Result<(), FaucetError> {
if key.is_empty() {
return Err(FaucetError::State("state key must not be empty".into()));
}
if key.len() > 256 {
return Err(FaucetError::State(format!(
"state key '{key}' exceeds 256 characters"
)));
}
for (i, c) in key.char_indices() {
let ok = c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ':' | '.' | '/');
if !ok {
return Err(FaucetError::State(format!(
"state key '{key}' contains illegal character {c:?} at byte {i}"
)));
}
}
if key == "." || key == ".." || key.starts_with('.') {
return Err(FaucetError::State(format!(
"state key '{key}' must not begin with a dot"
)));
}
if key.contains('/')
&& key
.split('/')
.any(|seg| seg.is_empty() || seg == "." || seg == ".." || seg.starts_with('.'))
{
return Err(FaucetError::State(format!(
"state key '{key}' has a path-like segment — `/` may only separate non-empty, non-dot segments"
)));
}
Ok(())
}
#[derive(Default)]
pub struct MemoryStateStore {
inner: Mutex<HashMap<String, Value>>,
}
impl MemoryStateStore {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl StateStore for MemoryStateStore {
async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
validate_state_key(key)?;
Ok(self.inner.lock().await.get(key).cloned())
}
async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
validate_state_key(key)?;
self.inner
.lock()
.await
.insert(key.to_owned(), value.clone());
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), FaucetError> {
validate_state_key(key)?;
self.inner.lock().await.remove(key);
Ok(())
}
fn supports_list(&self) -> bool {
true
}
async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
let mut keys: Vec<String> = self
.inner
.lock()
.await
.keys()
.filter(|k| k.starts_with(prefix))
.cloned()
.collect();
keys.sort();
Ok(keys)
}
fn supports_atomic_batch(&self) -> bool {
true
}
async fn put_batch(&self, entries: &[(String, Value)]) -> Result<(), FaucetError> {
for (key, _) in entries {
validate_state_key(key)?;
}
let mut map = self.inner.lock().await;
for (key, value) in entries {
map.insert(key.clone(), value.clone());
}
Ok(())
}
async fn check(
&self,
_ctx: &crate::check::CheckContext,
) -> Result<crate::check::CheckReport, FaucetError> {
Ok(crate::check::CheckReport::single(
crate::check::Probe::pass("sentinel", std::time::Duration::ZERO),
))
}
}
fn safe_filename(key: &str) -> String {
key.replace(':', "%3A").replace('/', "%2F")
}
fn key_from_filename(stem: &str) -> Option<String> {
let key = stem.replace("%3A", ":").replace("%2F", "/");
validate_state_key(&key).ok().map(|()| key)
}
pub struct FileStateStore {
root: PathBuf,
write_lock: Mutex<()>,
#[cfg(feature = "encryption")]
encryption: Option<crate::encryption::CompiledEncryption>,
}
impl FileStateStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
write_lock: Mutex::new(()),
#[cfg(feature = "encryption")]
encryption: None,
}
}
#[cfg(feature = "encryption")]
pub fn with_encryption(mut self, encryption: crate::encryption::CompiledEncryption) -> Self {
self.encryption = Some(encryption);
self
}
fn entry_path(&self, key: &str) -> PathBuf {
self.root.join(format!("{}.json", safe_filename(key)))
}
fn temp_path(&self, key: &str) -> PathBuf {
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
static PROC_TOKEN: OnceLock<String> = OnceLock::new();
static SEQ: AtomicU64 = AtomicU64::new(0);
let token = PROC_TOKEN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string());
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
self.root
.join(format!("{}.{}.{}.json.tmp", safe_filename(key), token, seq))
}
async fn ensure_root(&self) -> Result<(), FaucetError> {
tokio::fs::create_dir_all(&self.root).await.map_err(|e| {
FaucetError::State(format!(
"failed to create state dir {}: {e}",
self.root.display()
))
})
}
pub fn root(&self) -> &Path {
&self.root
}
}
#[async_trait]
impl StateStore for FileStateStore {
async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
validate_state_key(key)?;
let path = self.entry_path(key);
match tokio::fs::read(&path).await {
Ok(bytes) => {
#[cfg(feature = "encryption")]
let bytes: Vec<u8> = if crate::encryption::is_encrypted(&bytes) {
match &self.encryption {
Some(enc) => enc.decrypt(&bytes).map_err(|e| {
FaucetError::State(format!(
"state file {} could not be decrypted: {e}",
path.display()
))
})?,
None => {
return Err(FaucetError::State(format!(
"state file {} is encrypted but no `encryption` block is \
configured on the file state store — add \
`state.config.encryption` with the original key",
path.display()
)));
}
}
} else {
bytes
};
#[cfg(not(feature = "encryption"))]
let bytes = {
if bytes.starts_with(b"FCT1") {
return Err(FaucetError::State(format!(
"state file {} is encrypted but this build of faucet has no \
`encryption` feature",
path.display()
)));
}
bytes
};
let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
FaucetError::State(format!(
"failed to parse state file {}: {e}",
path.display()
))
})?;
Ok(Some(value))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(FaucetError::State(format!(
"failed to read state file {}: {e}",
path.display()
))),
}
}
async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
validate_state_key(key)?;
let _guard = self.write_lock.lock().await;
self.ensure_root().await?;
let bytes = serde_json::to_vec(value).map_err(|e| {
FaucetError::State(format!("failed to serialize state for key '{key}': {e}"))
})?;
#[cfg(feature = "encryption")]
let bytes = match &self.encryption {
Some(enc) => enc.encrypt(&bytes),
None => bytes,
};
let final_path = self.entry_path(key);
let tmp_path = self.temp_path(key);
{
let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
FaucetError::State(format!(
"failed to create temp state file {}: {e}",
tmp_path.display()
))
})?;
file.write_all(&bytes).await.map_err(|e| {
FaucetError::State(format!(
"failed to write temp state file {}: {e}",
tmp_path.display()
))
})?;
file.sync_all().await.map_err(|e| {
FaucetError::State(format!(
"failed to fsync temp state file {}: {e}",
tmp_path.display()
))
})?;
}
tokio::fs::rename(&tmp_path, &final_path)
.await
.map_err(|e| {
FaucetError::State(format!(
"failed to commit state file {}: {e}",
final_path.display()
))
})?;
#[cfg(unix)]
{
let dir = tokio::fs::File::open(&self.root).await.map_err(|e| {
FaucetError::State(format!(
"failed to open state dir {} for fsync: {e}",
self.root.display()
))
})?;
dir.sync_all().await.map_err(|e| {
FaucetError::State(format!(
"failed to fsync state dir {}: {e}",
self.root.display()
))
})?;
}
tracing::debug!(
key,
path = %final_path.display(),
"state file written"
);
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), FaucetError> {
validate_state_key(key)?;
let path = self.entry_path(key);
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(FaucetError::State(format!(
"failed to delete state file {}: {e}",
path.display()
))),
}
}
fn supports_list(&self) -> bool {
true
}
async fn list(&self, prefix: &str) -> Result<Vec<String>, FaucetError> {
let mut dir = match tokio::fs::read_dir(&self.root).await {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(FaucetError::State(format!(
"failed to list state dir {}: {e}",
self.root.display()
)));
}
};
let mut keys = Vec::new();
loop {
let entry = dir.next_entry().await.map_err(|e| {
FaucetError::State(format!(
"failed to list state dir {}: {e}",
self.root.display()
))
})?;
let Some(entry) = entry else { break };
let name = entry.file_name();
let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".json")) else {
continue;
};
if let Some(key) = key_from_filename(stem)
&& key.starts_with(prefix)
{
keys.push(key);
}
}
keys.sort();
Ok(keys)
}
async fn check(
&self,
_ctx: &crate::check::CheckContext,
) -> Result<crate::check::CheckReport, FaucetError> {
use crate::check::{CheckReport, Probe};
let start = std::time::Instant::now();
let probe = match self.sentinel_roundtrip().await {
Ok(()) => Probe::pass("sentinel", start.elapsed()),
Err(e) => Probe::fail_hint(
"sentinel",
start.elapsed(),
e.to_string(),
format!("ensure {} exists and is writable", self.root.display()),
),
};
Ok(CheckReport::single(probe))
}
}
impl FileStateStore {
async fn sentinel_roundtrip(&self) -> Result<(), FaucetError> {
let probe = serde_json::json!({ "faucet_doctor": true });
self.put(DOCTOR_SENTINEL_KEY, &probe).await?;
let got = self.get(DOCTOR_SENTINEL_KEY).await?;
let _ = self.delete(DOCTOR_SENTINEL_KEY).await;
match got {
Some(v) if v == probe => Ok(()),
_ => Err(FaucetError::State(
"sentinel readback did not match what was written".into(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Arc;
use tempfile::TempDir;
#[test]
fn rejects_empty_key() {
let err = validate_state_key("").unwrap_err();
assert!(matches!(err, FaucetError::State(_)));
}
#[test]
fn rejects_path_traversal_segments() {
for k in [
"../etc/passwd",
"a/../b",
"a/./b",
"a//b",
"/a",
"a/",
"a/.x",
"a\\b",
"..",
".",
] {
assert!(validate_state_key(k).is_err(), "expected reject for {k:?}");
}
assert!(validate_state_key("acme/erp::invoices").is_ok());
}
#[test]
fn rejects_leading_dot() {
assert!(validate_state_key(".hidden").is_err());
}
#[test]
fn rejects_over_long_key() {
let k = "a".repeat(257);
assert!(validate_state_key(&k).is_err());
}
#[test]
fn accepts_typical_keys() {
for k in [
"github_issues",
"pipeline:rest:issues",
"with.dot",
"with-dash_and_underscore",
"lower-Case_99",
] {
validate_state_key(k).unwrap_or_else(|e| panic!("expected ok for {k:?}: {e}"));
}
}
#[tokio::test]
async fn memory_get_returns_none_for_missing_key() {
let s = MemoryStateStore::new();
assert!(s.get("nope").await.unwrap().is_none());
}
#[tokio::test]
async fn memory_put_then_get_round_trips() {
let s = MemoryStateStore::new();
s.put("k", &json!({"cursor": "abc", "n": 7})).await.unwrap();
let got = s.get("k").await.unwrap().unwrap();
assert_eq!(got["cursor"], "abc");
assert_eq!(got["n"], 7);
}
#[tokio::test]
async fn memory_put_overwrites_previous_value() {
let s = MemoryStateStore::new();
s.put("k", &json!(1)).await.unwrap();
s.put("k", &json!(2)).await.unwrap();
assert_eq!(s.get("k").await.unwrap().unwrap(), json!(2));
}
#[tokio::test]
async fn memory_delete_makes_get_return_none() {
let s = MemoryStateStore::new();
s.put("k", &json!("v")).await.unwrap();
s.delete("k").await.unwrap();
assert!(s.get("k").await.unwrap().is_none());
}
#[tokio::test]
async fn memory_delete_missing_key_is_ok() {
let s = MemoryStateStore::new();
s.delete("absent").await.unwrap();
}
#[tokio::test]
async fn memory_rejects_invalid_keys() {
let s = MemoryStateStore::new();
assert!(s.get("a b").await.is_err());
assert!(s.put("a b", &json!(1)).await.is_err());
assert!(s.delete("a b").await.is_err());
}
#[tokio::test]
async fn file_get_returns_none_for_missing_key() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
assert!(s.get("nope").await.unwrap().is_none());
}
#[tokio::test]
async fn file_put_creates_root_directory_lazily() {
let dir = TempDir::new().unwrap();
let root = dir.path().join("nested/state");
let s = FileStateStore::new(&root);
s.put("k", &json!("v")).await.unwrap();
assert!(root.is_dir(), "root dir should be created on first put");
}
#[tokio::test]
async fn file_put_then_get_round_trips() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
let value = json!({"cursor": "abc", "n": 42, "nested": {"flag": true}});
s.put("github_issues", &value).await.unwrap();
let got = s.get("github_issues").await.unwrap().unwrap();
assert_eq!(got, value);
}
#[test]
fn temp_path_is_unique_and_not_pid_derived() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
let a = s.temp_path("k");
let b = s.temp_path("k");
assert_ne!(a, b);
let name_a = a.file_name().unwrap().to_str().unwrap();
let pid = std::process::id().to_string();
assert!(
!name_a.split('.').any(|seg| seg == pid),
"temp filename {name_a} must not embed the process id ({pid})"
);
assert!(name_a.ends_with(".json.tmp"));
}
#[test]
fn safe_filename_percent_encodes_colon() {
assert_eq!(
safe_filename("pipeline:rest:issues"),
"pipeline%3Arest%3Aissues"
);
assert_eq!(safe_filename("plain_key-1.v2"), "plain_key-1.v2");
assert_eq!(
safe_filename("acme/erp::invoices"),
"acme%2Ferp%3A%3Ainvoices"
);
assert!(validate_state_key("acme/erp::invoices").is_ok());
assert!(validate_state_key("a b").is_err());
}
#[tokio::test]
async fn file_round_trips_colon_keys_with_safe_filename() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
let value = json!({"cursor": "z"});
s.put("pipeline:rest:issues", &value).await.unwrap();
assert_eq!(s.get("pipeline:rest:issues").await.unwrap().unwrap(), value);
assert!(dir.path().join("pipeline%3Arest%3Aissues.json").exists());
let mut has_colon = false;
for entry in std::fs::read_dir(dir.path()).unwrap() {
if entry.unwrap().file_name().to_string_lossy().contains(':') {
has_colon = true;
}
}
assert!(!has_colon, "no state filename may contain ':'");
}
fn has_tmp_residue(dir: &std::path::Path) -> bool {
std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().ends_with(".json.tmp"))
}
#[tokio::test]
async fn file_put_overwrites_previous_value_atomically() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
s.put("k", &json!({"v": 1})).await.unwrap();
s.put("k", &json!({"v": 2})).await.unwrap();
assert_eq!(s.get("k").await.unwrap().unwrap(), json!({"v": 2}));
assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
}
#[test]
fn file_temp_paths_are_unique_per_write() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
let a = s.temp_path("k");
let b = s.temp_path("k");
assert_ne!(a, b, "each write must get a distinct temp path");
assert_eq!(s.entry_path("k"), s.entry_path("k"));
}
#[tokio::test]
async fn file_put_writes_complete_durable_file_with_no_temp_residue() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
let big: Vec<Value> = (0..1_000)
.map(|i| json!({"i": i, "s": "x".repeat(20)}))
.collect();
let value = json!({"cursor": "abc", "rows": big});
s.put("github_issues", &value).await.unwrap();
let raw = tokio::fs::read(dir.path().join("github_issues.json"))
.await
.expect("state file must exist after put");
assert!(!raw.is_empty(), "state file must not be zero-length");
let parsed: Value = serde_json::from_slice(&raw).expect("state file must be valid JSON");
assert_eq!(parsed, value);
assert!(!has_tmp_residue(dir.path()), "no temp residue after put");
}
#[tokio::test]
async fn file_delete_removes_file() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
s.put("k", &json!("v")).await.unwrap();
s.delete("k").await.unwrap();
assert!(s.get("k").await.unwrap().is_none());
assert!(!dir.path().join("k.json").exists());
}
#[tokio::test]
async fn file_delete_missing_key_is_ok() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
s.delete("absent").await.unwrap();
}
#[tokio::test]
async fn file_get_returns_error_for_corrupt_json() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
tokio::fs::create_dir_all(dir.path()).await.unwrap();
tokio::fs::write(dir.path().join("bad.json"), b"not json")
.await
.unwrap();
let err = s.get("bad").await.unwrap_err();
match err {
FaucetError::State(msg) => assert!(msg.contains("bad.json")),
other => panic!("expected State error, got {other:?}"),
}
}
#[tokio::test]
async fn file_concurrent_puts_do_not_corrupt_or_leak_temp() {
let dir = TempDir::new().unwrap();
let s = Arc::new(FileStateStore::new(dir.path()));
let mut handles = vec![];
for i in 0..50 {
let s = Arc::clone(&s);
handles.push(tokio::spawn(async move {
s.put("k", &json!({"i": i})).await.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
let got = s.get("k").await.unwrap().unwrap();
let i = got["i"].as_i64().unwrap();
assert!((0..50).contains(&i));
assert!(
!has_tmp_residue(dir.path()),
"no temp residue after concurrent puts"
);
}
#[tokio::test]
async fn file_store_works_through_trait_object() {
let dir = TempDir::new().unwrap();
let s: Box<dyn StateStore> = Box::new(FileStateStore::new(dir.path()));
s.put("k", &json!(1)).await.unwrap();
assert_eq!(s.get("k").await.unwrap().unwrap(), json!(1));
}
#[tokio::test]
async fn memory_check_passes() {
let s = MemoryStateStore::new();
let report = s
.check(&crate::check::CheckContext::default())
.await
.unwrap();
assert_eq!(report.failed_count(), 0);
assert!(
report
.probes
.iter()
.all(|p| matches!(p.status, crate::check::ProbeStatus::Pass))
);
}
#[tokio::test]
async fn file_check_passes_for_writable_root() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
let report = s
.check(&crate::check::CheckContext::default())
.await
.unwrap();
assert_eq!(report.failed_count(), 0, "writable root should pass");
let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
assert!(leftovers.is_empty(), "check() must not leave files behind");
}
#[tokio::test]
async fn file_store_root_returns_configured_directory() {
let dir = TempDir::new().unwrap();
let s = FileStateStore::new(dir.path());
assert_eq!(s.root(), dir.path());
}
#[tokio::test]
async fn default_check_reports_not_implemented() {
struct BareStore;
#[async_trait]
impl StateStore for BareStore {
async fn get(&self, _key: &str) -> Result<Option<Value>, FaucetError> {
Ok(None)
}
async fn put(&self, _key: &str, _value: &Value) -> Result<(), FaucetError> {
Ok(())
}
async fn delete(&self, _key: &str) -> Result<(), FaucetError> {
Ok(())
}
}
let s = BareStore;
let report = s
.check(&crate::check::CheckContext::default())
.await
.unwrap();
assert_eq!(report.failed_count(), 0);
assert!(
report
.probes
.iter()
.any(|p| matches!(p.status, crate::check::ProbeStatus::Skip { .. })),
"default check must surface a skipped (not-implemented) probe"
);
}
#[tokio::test]
async fn file_check_fails_when_root_unusable() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("not_a_dir");
std::fs::write(&file, b"x").unwrap();
let s = FileStateStore::new(file.join("state"));
let report = s
.check(&crate::check::CheckContext::default())
.await
.unwrap();
assert_eq!(report.failed_count(), 1, "unusable root should fail");
}
#[cfg(feature = "encryption")]
mod encryption_at_rest {
use super::*;
use crate::encryption::{CompiledEncryption, EncryptionSpec, is_encrypted};
use serde_json::json;
fn enc(key: &str) -> CompiledEncryption {
CompiledEncryption::compile(&EncryptionSpec {
key: key.into(),
previous_keys: vec![],
algorithm: Default::default(),
})
.unwrap()
}
#[tokio::test]
async fn encrypted_round_trip_and_ciphertext_on_disk() {
let dir = tempfile::tempdir().unwrap();
let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
store.put("bk", &json!({"lsn": 42})).await.unwrap();
assert_eq!(store.get("bk").await.unwrap(), Some(json!({"lsn": 42})));
let raw = std::fs::read(dir.path().join("bk.json")).unwrap();
assert!(is_encrypted(&raw));
assert!(serde_json::from_slice::<Value>(&raw).is_err());
store.delete("bk").await.unwrap();
assert_eq!(store.get("bk").await.unwrap(), None);
}
#[tokio::test]
async fn plaintext_file_stays_readable_and_is_sealed_on_next_write() {
let dir = tempfile::tempdir().unwrap();
let plain = FileStateStore::new(dir.path());
plain.put("bk", &json!("legacy")).await.unwrap();
let before = std::fs::read(dir.path().join("bk.json")).unwrap();
assert!(!is_encrypted(&before));
let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("legacy")));
sealed.put("bk", &json!("updated")).await.unwrap();
let after = std::fs::read(dir.path().join("bk.json")).unwrap();
assert!(is_encrypted(&after), "next write must seal the file");
assert_eq!(sealed.get("bk").await.unwrap(), Some(json!("updated")));
}
#[tokio::test]
async fn wrong_key_is_a_typed_error_not_a_missing_bookmark() {
let dir = tempfile::tempdir().unwrap();
let a = FileStateStore::new(dir.path()).with_encryption(enc("right"));
a.put("bk", &json!(1)).await.unwrap();
let b = FileStateStore::new(dir.path()).with_encryption(enc("wrong"));
let err = b.get("bk").await.unwrap_err();
assert!(matches!(err, FaucetError::State(_)));
assert!(err.to_string().contains("could not be decrypted"), "{err}");
}
#[tokio::test]
async fn encrypted_file_with_unconfigured_store_is_a_typed_error() {
let dir = tempfile::tempdir().unwrap();
let sealed = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
sealed.put("bk", &json!(1)).await.unwrap();
let plain = FileStateStore::new(dir.path());
let err = plain.get("bk").await.unwrap_err();
assert!(err.to_string().contains("no `encryption` block"), "{err}");
}
#[tokio::test]
async fn rotation_reads_old_key_files() {
let dir = tempfile::tempdir().unwrap();
let old = FileStateStore::new(dir.path()).with_encryption(enc("old"));
old.put("bk", &json!("v1")).await.unwrap();
let rotated = FileStateStore::new(dir.path()).with_encryption(
CompiledEncryption::compile(&EncryptionSpec {
key: "new".into(),
previous_keys: vec!["old".into()],
algorithm: Default::default(),
})
.unwrap(),
);
assert_eq!(rotated.get("bk").await.unwrap(), Some(json!("v1")));
rotated.put("bk", &json!("v2")).await.unwrap();
let new_only = FileStateStore::new(dir.path()).with_encryption(enc("new"));
assert_eq!(new_only.get("bk").await.unwrap(), Some(json!("v2")));
}
#[tokio::test]
async fn no_temp_files_left_behind() {
let dir = tempfile::tempdir().unwrap();
let store = FileStateStore::new(dir.path()).with_encryption(enc("k1"));
store.put("bk", &json!(1)).await.unwrap();
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.path().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(
leftovers.is_empty(),
"atomic write must leave no temp files"
);
}
}
}
#[cfg(test)]
mod list_export_tests {
use super::*;
use serde_json::json;
struct Bare(MemoryStateStore);
#[async_trait]
impl StateStore for Bare {
async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
self.0.get(key).await
}
async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
if key.ends_with("::boom") {
return Err(FaucetError::State("disk full".into()));
}
self.0.put(key, value).await
}
async fn delete(&self, key: &str) -> Result<(), FaucetError> {
if key.ends_with("::stuck") {
return Err(FaucetError::State("locked".into()));
}
self.0.delete(key).await
}
}
#[tokio::test]
async fn defaults_cannot_list_and_batch_sequentially() {
let s = Bare(MemoryStateStore::new());
assert!(!s.supports_list());
assert!(!s.supports_atomic_batch());
assert!(s.list("p::").await.is_err());
s.put_batch(&[("p::a".into(), json!(1)), ("p::b".into(), json!(2))])
.await
.unwrap();
assert_eq!(s.get("p::b").await.unwrap(), Some(json!(2)));
let err = s
.put_batch(&[("p::c".into(), json!(1)), ("p::boom".into(), json!(2))])
.await
.unwrap_err();
assert!(err.to_string().contains("disk full"));
assert_eq!(
s.get("p::c").await.unwrap(),
Some(json!(1)),
"prefix landed"
);
}
#[tokio::test]
async fn memory_lists_by_prefix_sorted_and_batches_atomically() {
let s = MemoryStateStore::new();
for k in ["p::b", "p::a", "q::a", "p::a::__sla__"] {
s.put(k, &json!(k)).await.unwrap();
}
assert!(s.supports_list());
assert_eq!(
s.list("p::").await.unwrap(),
vec!["p::a", "p::a::__sla__", "p::b"]
);
assert!(s.supports_atomic_batch());
let err = s
.put_batch(&[("p::x".into(), json!(1)), ("../bad".into(), json!(2))])
.await
.unwrap_err();
assert!(matches!(err, FaucetError::State(_)));
assert!(s.get("p::x").await.unwrap().is_none(), "nothing written");
s.put_batch(&[("p::x".into(), json!(1))]).await.unwrap();
assert_eq!(s.get("p::x").await.unwrap(), Some(json!(1)));
}
#[tokio::test]
async fn file_lists_decoded_keys_ignoring_temp_and_foreign_files() {
let dir = tempfile::tempdir().unwrap();
let s = FileStateStore::new(dir.path());
assert!(s.list("p::").await.unwrap().is_empty(), "missing root");
for k in ["p::a", "p::a::__sla__", "acme/p::x", "q::a"] {
s.put(k, &json!(1)).await.unwrap();
}
std::fs::write(dir.path().join("p%3A%3Az.json.tmp"), b"{}").unwrap();
std::fs::write(dir.path().join("notes.txt"), b"x").unwrap();
std::fs::write(dir.path().join(".hidden.json"), b"{}").unwrap();
assert!(s.supports_list());
assert_eq!(s.list("p::").await.unwrap(), vec!["p::a", "p::a::__sla__"]);
assert_eq!(s.list("acme/").await.unwrap(), vec!["acme/p::x"]);
assert!(!s.supports_atomic_batch());
}
#[cfg(unix)]
#[tokio::test]
async fn file_list_reports_unreadable_root() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
let s = FileStateStore::new(&file);
let err = s.list("p::").await.unwrap_err();
assert!(
err.to_string().contains("failed to list state dir"),
"{err}"
);
}
#[test]
fn export_document_parses_and_validates() {
let ok = json!({
"version": 1,
"pipeline": "orders",
"exported_at": "2026-09-26T00:00:00Z",
"keys": {"orders::a": {"id": 3}, "orders::a::__sla__": {}}
});
let e = StateExport::from_value(ok).unwrap();
assert_eq!(e.keys.len(), 2);
assert_eq!(namespace_prefix("orders"), "orders::");
let cases = [
(json!({"pipeline": "o", "keys": {}}), "no `version`"),
(
json!({"version": "1", "pipeline": "o"}),
"must be an integer",
),
(json!({"version": 2, "pipeline": "o"}), "not supported"),
(json!({"version": 0, "pipeline": "o"}), "not supported"),
(json!({"version": 1}), "malformed"),
(json!({"version": 1, "pipeline": "../o"}), "pipeline name"),
(
json!({"version": 1, "pipeline": "o", "keys": {"other::a": 1}}),
"outside the namespace",
),
(
json!({"version": 1, "pipeline": "o", "keys": {"o::a b": 1}}),
"illegal character",
),
];
for (doc, needle) in cases {
let err = StateExport::from_value(doc.clone())
.unwrap_err()
.to_string();
assert!(err.contains(needle), "{doc} → {err}");
}
}
#[tokio::test]
async fn export_then_import_round_trips_and_replace_drops_stale_keys() {
let from = MemoryStateStore::new();
from.put("o::a", &json!({"c": 1})).await.unwrap();
from.put("o::b", &json!(2)).await.unwrap();
from.put("other::a", &json!(9)).await.unwrap();
let export = export_namespace(&from, "o").await.unwrap();
assert_eq!(export.keys.len(), 2);
let to = MemoryStateStore::new();
to.put("o::stale", &json!(0)).await.unwrap();
let r = import_namespace(&to, &export, false).await.unwrap();
assert!(r.atomic && r.error.is_none());
assert_eq!(r.written, vec!["o::a", "o::b"]);
assert!(
to.get("o::stale").await.unwrap().is_some(),
"kept without replace"
);
let r = import_namespace(&to, &export, true).await.unwrap();
assert_eq!(r.deleted, vec!["o::stale"]);
assert!(to.get("o::stale").await.unwrap().is_none());
assert_eq!(to.get("o::a").await.unwrap(), Some(json!({"c": 1})));
}
#[tokio::test]
async fn import_reports_partial_progress_on_a_non_atomic_store() {
let mut export = StateExport::new("o");
export.keys.insert("o::a".into(), json!(1));
export.keys.insert("o::boom".into(), json!(2));
let s = Bare(MemoryStateStore::new());
let r = import_namespace(&s, &export, false).await.unwrap();
assert!(!r.atomic);
assert_eq!(r.written, vec!["o::a"]);
assert!(r.error.unwrap().contains("o::boom"));
let mut bad = StateExport::new("o");
bad.keys.insert("x::a".into(), json!(1));
assert!(import_namespace(&s, &bad, false).await.is_err());
}
#[tokio::test]
async fn import_reports_batch_and_delete_failures() {
struct Failing;
#[async_trait]
impl StateStore for Failing {
async fn get(&self, _: &str) -> Result<Option<Value>, FaucetError> {
Ok(None)
}
async fn put(&self, _: &str, _: &Value) -> Result<(), FaucetError> {
Ok(())
}
async fn delete(&self, _: &str) -> Result<(), FaucetError> {
Ok(())
}
fn supports_atomic_batch(&self) -> bool {
true
}
async fn put_batch(&self, _: &[(String, Value)]) -> Result<(), FaucetError> {
Err(FaucetError::State("tx aborted".into()))
}
}
assert_eq!(Failing.get("o::a").await.unwrap(), None);
Failing.put("o::a", &json!(1)).await.unwrap();
Failing.delete("o::a").await.unwrap();
let mut export = StateExport::new("o");
export.keys.insert("o::a".into(), json!(1));
let r = import_namespace(&Failing, &export, false).await.unwrap();
assert!(r.written.is_empty());
assert!(r.error.unwrap().contains("tx aborted"));
assert!(import_namespace(&Failing, &export, true).await.is_err());
let s = Bare(MemoryStateStore::new());
struct Listing(Bare);
#[async_trait]
impl StateStore for Listing {
async fn get(&self, k: &str) -> Result<Option<Value>, FaucetError> {
self.0.get(k).await
}
async fn put(&self, k: &str, v: &Value) -> Result<(), FaucetError> {
self.0.put(k, v).await
}
async fn delete(&self, k: &str) -> Result<(), FaucetError> {
self.0.delete(k).await
}
async fn list(&self, p: &str) -> Result<Vec<String>, FaucetError> {
self.0.0.list(p).await
}
}
s.put("o::stuck", &json!(1)).await.unwrap();
let listing = Listing(s);
assert_eq!(listing.get("o::stuck").await.unwrap(), Some(json!(1)));
let r = import_namespace(&listing, &export, true).await.unwrap();
assert_eq!(r.written, vec!["o::a"]);
assert!(r.error.unwrap().contains("deleting stale 'o::stuck'"));
}
}