pitboard-core 0.3.0

The engine behind pitboard: parking and restoring Claude Code logins. Serves pitboard's own front ends.
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::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),
        }
    }

    /// 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)?)
    }

    /// A directory enumerates itself. A vault that is not there yet holds nothing, which is
    /// an answer; anything else that stops the read is not, and says so.
    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::*;

    /// A real Codex account id is the ChatGPT account and the person inside it, and a park
    /// of one has to be a name this vault stores. Joined with a colon once, every Codex
    /// park on Linux was refused, after a browser sign-in had already been finished.
    #[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);
    }

    /// SECURITY.md tells people that a parked login here is 0600 inside a 0700 directory,
    /// and that this is the whole of what keeps it from everyone else with an account on
    /// the machine. Nothing checked it. `atomic::Perms::Secret` and `home::create_private`
    /// are two other modules' promises, and a change to either would quietly widen every
    /// parked login on Linux.
    #[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"
            );
        }
    }
}