feat(daemon): storage::pending JSON read/write/clear with path-traversal guards

This commit is contained in:
michael
2026-04-27 06:27:30 -07:00
parent ea70386c2f
commit dd766e3077
2 changed files with 103 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
use authforge_common::types::PendingFlag;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum PendingError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("invalid username: {0:?}")]
InvalidUser(String),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
#[allow(dead_code)] // wired through AppState in Task 2.15.
pub(crate) struct PendingStore {
dir: PathBuf,
}
#[allow(dead_code)] // wired through AppState in Task 2.15.
impl PendingStore {
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
fn user_path(&self, user: &str) -> Result<PathBuf, PendingError> {
if user.is_empty()
|| user.contains('/')
|| user.contains('\0')
|| user == "."
|| user == ".."
{
return Err(PendingError::InvalidUser(user.to_string()));
}
Ok(self.dir.join(user))
}
pub fn set(&self, user: &str, flag: &PendingFlag) -> Result<(), PendingError> {
std::fs::create_dir_all(&self.dir)?;
let path = self.user_path(user)?;
let body = serde_json::to_vec_pretty(flag)?;
std::fs::write(path, body)?;
Ok(())
}
pub fn clear(&self, user: &str) -> Result<bool, PendingError> {
let path = self.user_path(user)?;
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(PendingError::Io(e)),
}
}
pub fn get(&self, user: &str) -> Result<Option<PendingFlag>, PendingError> {
let path = self.user_path(user)?;
match std::fs::read(&path) {
Ok(b) => Ok(Some(serde_json::from_slice(&b)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(PendingError::Io(e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use authforge_common::types::Method;
use tempfile::tempdir;
fn flag() -> PendingFlag {
PendingFlag {
required_methods: vec![Method::Fido2],
created_unix: 1,
deadline_unix: 0,
re_enroll: false,
}
}
#[test]
fn set_get_clear_roundtrip() {
let d = tempdir().unwrap();
let s = PendingStore::new(d.path().to_path_buf());
assert!(s.get("alice").unwrap().is_none());
s.set("alice", &flag()).unwrap();
assert_eq!(s.get("alice").unwrap().unwrap(), flag());
assert!(s.clear("alice").unwrap());
assert!(s.get("alice").unwrap().is_none());
assert!(!s.clear("alice").unwrap());
}
#[test]
fn rejects_path_traversal() {
let d = tempdir().unwrap();
let s = PendingStore::new(d.path().to_path_buf());
for evil in ["../etc", "a/b", "", ".", "..", "x\0y"] {
assert!(s.set(evil, &flag()).is_err());
assert!(s.get(evil).is_err());
assert!(s.clear(evil).is_err());
}
}
}