Files
authforge/daemon/src/storage/pending.rs
michael eb208579e3 refactor(daemon): centralize username path-segment sanitizer
Extract the inline path-traversal check from PendingStore into a shared
storage::safe_user::join_user_segment helper. RecoveryStore (next) reuses it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:22:23 -07:00

97 lines
2.9 KiB
Rust

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> {
super::safe_user::join_user_segment(&self.dir, user).map_err(|e| match e {
super::safe_user::SegmentError::Invalid(s) => PendingError::InvalidUser(s),
})
}
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());
}
}
}