use std::path::{Path, PathBuf};
pub const PENDING_DIR: &str = "keychain-pending";
fn dir(agent_home: &Path) -> PathBuf {
agent_home.join(PENDING_DIR)
}
fn safe_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn mark(agent_home: &Path, name: &str) -> std::io::Result<()> {
if !safe_name(name) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("not a secret name: {name:?}"),
));
}
let d = dir(agent_home);
std::fs::create_dir_all(&d)?;
std::fs::write(d.join(name), chrono::Utc::now().to_rfc3339())
}
pub fn is_pending(agent_home: &Path, name: &str) -> bool {
safe_name(name) && dir(agent_home).join(name).is_file()
}
pub fn clear(agent_home: &Path, name: &str) -> bool {
safe_name(name) && std::fs::remove_file(dir(agent_home).join(name)).is_ok()
}
pub fn clear_all(agent_home: &Path) -> usize {
let names = list(agent_home);
names.iter().filter(|n| clear(agent_home, n)).count()
}
pub fn list(agent_home: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir(agent_home)) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_file())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| safe_name(n))
.collect();
names.sort();
names
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mark_then_skip_until_cleared() {
let home = tempfile::tempdir().unwrap();
assert!(!is_pending(home.path(), "GOOGLE_MAPS_KEY"));
mark(home.path(), "GOOGLE_MAPS_KEY").unwrap();
assert!(is_pending(home.path(), "GOOGLE_MAPS_KEY"));
assert!(is_pending(home.path(), "GOOGLE_MAPS_KEY"));
assert!(!is_pending(home.path(), "OTHER_KEY"));
assert!(clear(home.path(), "GOOGLE_MAPS_KEY"));
assert!(!is_pending(home.path(), "GOOGLE_MAPS_KEY"));
assert!(!clear(home.path(), "GOOGLE_MAPS_KEY"));
}
#[test]
fn list_and_clear_all() {
let home = tempfile::tempdir().unwrap();
assert!(list(home.path()).is_empty());
mark(home.path(), "B_KEY").unwrap();
mark(home.path(), "A_KEY").unwrap();
assert_eq!(list(home.path()), vec!["A_KEY", "B_KEY"]);
assert_eq!(clear_all(home.path()), 2);
assert!(list(home.path()).is_empty());
}
#[test]
fn path_like_names_are_refused() {
let home = tempfile::tempdir().unwrap();
assert!(mark(home.path(), "../escape").is_err());
assert!(mark(home.path(), "").is_err());
assert!(!is_pending(home.path(), "../escape"));
assert!(!home.path().parent().unwrap().join("escape").exists());
}
}