use super::{Backend, Error, RawStore};
use crate::context::Context;
use crate::{atomic, home};
use std::path::PathBuf;
pub(super) struct FileVault {
dir: PathBuf,
}
impl FileVault {
pub(super) fn new(ctx: &Context) -> FileVault {
FileVault {
dir: super::vault_dir(ctx),
}
}
fn path(&self, service: &str) -> Result<PathBuf, Error> {
if service.is_empty()
|| !service
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
{
return Err(Error::Write(format!(
"{service} is not a name this vault will store"
)));
}
Ok(self.dir.join(format!("{service}.json")))
}
}
impl RawStore for FileVault {
fn kind(&self) -> Backend {
Backend::File
}
fn contains(&self, service: &str) -> Result<bool, Error> {
super::exists(&self.path(service)?)
}
fn list(&self) -> Result<Option<Vec<String>>, Error> {
let entries = match std::fs::read_dir(&self.dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Some(Vec::new())),
Err(e) => {
return Err(Error::Unreadable(format!(
"cannot list {}: {e}",
self.dir.display()
)));
}
};
let mut names: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
let service = name.strip_suffix(".json")?.to_string();
crate::park::is_park_name(&service).then_some(service)
})
.collect();
names.sort();
Ok(Some(names))
}
fn read(&self, service: &str) -> Result<Option<String>, Error> {
let path = self.path(service)?;
match std::fs::read_to_string(&path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::Unreadable(format!(
"cannot read {}: {e}",
path.display()
))),
}
}
fn write(&self, service: &str, contents: &str) -> Result<(), Error> {
let path = self.path(service)?;
home::create_private(&self.dir).map_err(|e| Error::Write(e.to_string()))?;
atomic::write(&path, contents.as_bytes(), atomic::Perms::Secret)
.map_err(|e| Error::Write(format!("cannot write {}: {e}", path.display())))?;
match self.read(service)? {
Some(back) if back == contents => Ok(()),
_ => Err(Error::NotDurable(format!(
"{} does not hold what was written",
path.display()
))),
}
}
fn delete(&self, service: &str) -> Result<(), Error> {
let path = self.path(service)?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::Write(e.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_real_codex_park_name_is_one_the_vault_stores() {
let root = std::env::temp_dir().join(format!(
"pitboard-vault-codex-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let ctx = Context::new(root.clone()).with_pitboard_home(root.clone());
let vault = FileVault::new(&ctx);
let name = crate::park::service_name(
"8c3f0f86-0a7c-4d52-9b0e-1f2a3b4c5d6e_user-AbC123dEf456",
1_790_000_000_000,
);
assert!(vault.path(&name).is_ok(), "{name}");
assert!(crate::park::is_park_name(&name));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_parked_login_is_readable_only_by_its_owner() {
use std::os::unix::fs::PermissionsExt;
let root = std::env::temp_dir().join(format!(
"pitboard-vault-modes-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = Scratch(root.clone());
let ctx = Context::new(root.clone()).with_pitboard_home(root.join(".pitboard"));
let vault = FileVault::new(&ctx);
let name = "pitboard-park-1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0-1789935600123";
vault
.write(name, r#"{"claudeAiOauth":{}}"#)
.expect("a park");
let mode = |p: &std::path::Path| {
std::fs::metadata(p)
.expect("it exists")
.permissions()
.mode()
& 0o777
};
assert_eq!(mode(&vault.dir), 0o700, "the vault directory");
assert_eq!(mode(&vault.path(name).unwrap()), 0o600, "the parked login");
}
#[test]
fn only_names_pitboard_generates_are_accepted() {
let vault = FileVault::new(&Context::from_env());
for good in [
"pitboard-park-1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0-1789935600123",
"a.b_c-1",
] {
assert!(vault.path(good).is_ok(), "{good}");
}
for bad in ["", "../escape", "has space", "a/b", "sl\\ash"] {
assert!(
vault.path(bad).is_err(),
"{bad:?} must be refused, not escaped"
);
}
}
}