pitboard 0.1.0

Park and restore your own Claude Code logins on one machine, and see what each one has left.
Documentation
//! Where pitboard parks logins on platforms with no keychain: one 0600 file per parked
//! login, inside pitboard's own 0700 directory.

use super::{Backend, Error, RawStore};
use crate::{atomic, home};
use std::path::PathBuf;

pub(super) struct FileVault;

pub(super) const FILE: FileVault = FileVault;

impl FileVault {
    fn dir(&self) -> PathBuf {
        home::dir().join("vault")
    }

    /// Service names are generated by pitboard and contain only hex, hyphens and ASCII
    /// words, so they are used as file names directly. Anything else is refused rather
    /// than escaped, because escaping invites a traversal bug for no benefit.
    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 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 only_names_pitboard_generates_are_accepted() {
        for good in [
            "pitboard-park-9aeb9c89-316c-4344-84c5-603d71dc5c9a-1789935600123",
            "a.b_c-1",
        ] {
            assert!(FILE.path(good).is_ok(), "{good}");
        }
        for bad in ["", "../escape", "has space", "a/b", "sl\\ash"] {
            assert!(
                FILE.path(bad).is_err(),
                "{bad:?} must be refused, not escaped"
            );
        }
    }
}