use crate::adapters::Snapshot;
use crate::paths::Paths;
use crate::secret::Secret;
use anyhow::{Context, Result};
use fs2::FileExt;
use std::fs;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::PathBuf;
pub struct Store {
dir: PathBuf,
}
pub struct ProfileInfo {
pub name: String,
pub tools: Vec<String>,
}
pub struct LockGuard(#[allow(dead_code)] fs::File);
impl Store {
pub fn open(paths: &Paths) -> Result<Store> {
let dir = paths.store_dir();
fs::create_dir_all(&dir).with_context(|| format!("create store {}", dir.display()))?;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).ok();
for sub in ["accounts", "backups"] {
let d = dir.join(sub);
fs::create_dir_all(&d).ok();
fs::set_permissions(&d, fs::Permissions::from_mode(0o700)).ok();
}
Ok(Store { dir })
}
pub fn lock(&self) -> Result<LockGuard> {
let path = self.dir.join(".lock");
let f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(&path)
.context("open store lock")?;
f.try_lock_exclusive()
.context("another swapdex is mid-switch (store is locked)")?;
Ok(LockGuard(f))
}
fn account_tool_dir(&self, name: &str, tool: &str) -> PathBuf {
self.dir.join("accounts").join(name).join(tool)
}
pub fn save(&self, name: &str, snap: &Snapshot) -> Result<()> {
let d = self.account_tool_dir(name, snap.tool);
fs::create_dir_all(&d).ok();
fs::set_permissions(
self.dir.join("accounts").join(name),
fs::Permissions::from_mode(0o700),
)
.ok();
fs::set_permissions(&d, fs::Permissions::from_mode(0o700)).ok();
for (part, secret) in &snap.blobs {
crate::atomic::write_secret(&d.join(part), secret.expose())?;
}
Ok(())
}
pub fn load(&self, name: &str, tool: &str) -> Result<Option<Snapshot>> {
let d = self.account_tool_dir(name, tool);
if !d.exists() {
return Ok(None);
}
let tool_static: &'static str = match tool {
"claude-code" => "claude-code",
"codex" => "codex",
"gemini" => "gemini",
"antigravity" => "antigravity",
_ => return Ok(None),
};
let mut blobs = Vec::new();
for e in fs::read_dir(&d)?.flatten() {
let part = e.file_name().to_string_lossy().into_owned();
if e.path().is_file() && !part.starts_with('.') {
let bytes = crate::atomic::read_regular(&e.path())?;
blobs.push((part, Secret::new(bytes)));
}
}
Ok(Some(Snapshot {
tool: tool_static,
blobs,
}))
}
pub fn list(&self) -> Vec<ProfileInfo> {
let mut out = Vec::new();
let accounts = self.dir.join("accounts");
if let Ok(rd) = fs::read_dir(&accounts) {
for e in rd.flatten() {
if !e.path().is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().into_owned();
let mut tools = Vec::new();
if let Ok(td) = fs::read_dir(e.path()) {
for t in td.flatten() {
if t.path().is_dir() {
tools.push(t.file_name().to_string_lossy().into_owned());
}
}
}
tools.sort();
out.push(ProfileInfo { name, tools });
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn remove(&self, name: &str) -> Result<bool> {
let d = self.dir.join("accounts").join(name);
if !d.exists() {
return Ok(false);
}
overwrite_tree(&d);
fs::remove_dir_all(&d).with_context(|| format!("remove profile {name}"))?;
Ok(true)
}
pub fn rename(&self, old: &str, new: &str) -> Result<bool> {
let from = self.dir.join("accounts").join(old);
let to = self.dir.join("accounts").join(new);
if !from.exists() {
return Ok(false);
}
if to.exists() {
anyhow::bail!("a profile named '{new}' already exists");
}
fs::rename(&from, &to).with_context(|| format!("rename profile {old} -> {new}"))?;
Ok(true)
}
pub fn backup(&self, snap: &Snapshot) -> Result<()> {
let base = self.dir.join("backups").join(snap.tool);
fs::create_dir_all(&base).ok();
fs::set_permissions(&base, fs::Permissions::from_mode(0o700)).ok();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let d = base.join(stamp.to_string());
fs::create_dir_all(&d).ok();
fs::set_permissions(&d, fs::Permissions::from_mode(0o700)).ok();
for (part, secret) in &snap.blobs {
crate::atomic::write_secret(&d.join(part), secret.expose())?;
}
let mut stamps: Vec<PathBuf> = fs::read_dir(&base)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
stamps.sort_by_key(|p| {
p.file_name()
.and_then(|n| n.to_str())
.and_then(|s| s.parse::<u128>().ok())
.unwrap_or(0)
});
while stamps.len() > 2 {
let old = stamps.remove(0);
overwrite_tree(&old);
let _ = fs::remove_dir_all(&old);
}
Ok(())
}
pub fn load_backup(&self, tool: &str) -> Result<Option<(u128, Snapshot)>> {
let tool_static: &'static str = match tool {
"claude-code" => "claude-code",
"codex" => "codex",
"gemini" => "gemini",
"antigravity" => "antigravity",
_ => return Ok(None),
};
let base = self.dir.join("backups").join(tool);
let mut stamps: Vec<(u128, PathBuf)> = fs::read_dir(&base)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.filter_map(|p| {
let s = p.file_name()?.to_str()?.parse::<u128>().ok()?;
Some((s, p))
})
.collect();
stamps.sort_by_key(|(s, _)| *s);
while let Some((stamp, d)) = stamps.pop() {
let mut blobs = Vec::new();
for e in fs::read_dir(&d)?.flatten() {
let part = e.file_name().to_string_lossy().into_owned();
if e.path().is_file() && !part.starts_with('.') {
let bytes = crate::atomic::read_regular(&e.path())?;
blobs.push((part, Secret::new(bytes)));
}
}
let complete = match tool_static {
"claude-code" => {
blobs.iter().any(|(n, _)| n == "credentials")
&& blobs.iter().any(|(n, _)| n == "oauth_account")
}
_ => !blobs.is_empty(),
};
if !complete {
continue;
}
return Ok(Some((
stamp,
Snapshot {
tool: tool_static,
blobs,
},
)));
}
Ok(None)
}
pub fn append_timeline(&self, tool: &str, account: &str, action: &str) -> Result<()> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.append_timeline_at(tool, account, action, ts)
}
pub fn append_timeline_at(
&self,
tool: &str,
account: &str,
action: &str,
ts: u64,
) -> Result<()> {
let path = self.dir.join("timeline.jsonl");
let line =
serde_json::json!({"ts": ts, "tool": tool, "account": account, "action": action});
let mut buf = if path.exists() {
crate::atomic::read_regular(&path)?
} else {
Vec::new()
};
buf.extend_from_slice(serde_json::to_string(&line)?.as_bytes());
buf.push(b'\n');
const TIMELINE_KEEP: usize = 1000;
let lines = buf.iter().filter(|&&b| b == b'\n').count();
if lines > TIMELINE_KEEP * 2 {
let text = String::from_utf8_lossy(&buf).into_owned();
let tail: Vec<&str> = text
.lines()
.rev()
.take(TIMELINE_KEEP)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
buf = (tail.join("\n") + "\n").into_bytes();
}
crate::atomic::write_secret(&path, &buf)
}
}
pub fn valid_profile_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& !name.starts_with('.')
&& !name.contains(['/', '\\'])
&& !name.chars().any(|c| c.is_control())
}
fn overwrite_tree(dir: &std::path::Path) {
if let Ok(rd) = fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
overwrite_tree(&p);
} else if let Ok(len) = fs::metadata(&p).map(|m| m.len()) {
let _ = fs::write(&p, vec![0u8; len as usize]);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::secret::Secret;
fn snap() -> Snapshot {
Snapshot {
tool: "codex",
blobs: vec![("auth".into(), Secret::new(b"{\"k\":\"SENTINEL\"}".to_vec()))],
}
}
fn walk_files(dir: &std::path::Path) -> Vec<PathBuf> {
let mut out = vec![];
if let Ok(rd) = fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
out.extend(walk_files(&p));
} else {
out.push(p);
}
}
}
out
}
#[test]
fn store_dir_is_0700_and_roundtrips_a_snapshot() {
let d = tempfile::tempdir().unwrap();
let p = Paths::rooted(d.path());
let s = Store::open(&p).unwrap();
assert_eq!(
fs::metadata(p.store_dir()).unwrap().permissions().mode() & 0o777,
0o700
);
s.save("work", &snap()).unwrap();
let back = s.load("work", "codex").unwrap().unwrap();
assert_eq!(back.part("auth").unwrap().expose(), b"{\"k\":\"SENTINEL\"}");
for f in walk_files(&p.store_dir().join("accounts/work")) {
assert_eq!(
fs::metadata(&f).unwrap().permissions().mode() & 0o777,
0o600,
"{f:?}"
);
}
}
#[test]
fn timeline_holds_no_secret() {
let d = tempfile::tempdir().unwrap();
let p = Paths::rooted(d.path());
let s = Store::open(&p).unwrap();
s.append_timeline("codex", "work", "use").unwrap();
let tl = fs::read_to_string(p.store_dir().join("timeline.jsonl")).unwrap();
assert!(!tl.contains("SENTINEL"));
assert!(tl.contains("work") && tl.contains("codex"));
}
#[test]
fn lock_is_exclusive() {
let d = tempfile::tempdir().unwrap();
let p = Paths::rooted(d.path());
let s = Store::open(&p).unwrap();
let _g = s.lock().unwrap();
assert!(s.lock().is_err(), "second lock must fail while held");
}
}