use crate::store::Store;
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(PartialEq, Eq, Debug)]
pub enum Kind {
Directory,
Rclone,
Git,
}
pub fn classify(remote: &str) -> Kind {
let remote = remote.trim();
if remote.starts_with("git@")
|| remote.starts_with("ssh://")
|| remote.ends_with(".git")
|| remote.starts_with("https://")
{
return Kind::Git;
}
if remote.starts_with('/') || remote.starts_with('~') || remote.starts_with('.') {
return Kind::Directory;
}
if remote.contains(':') {
return Kind::Rclone;
}
Kind::Directory
}
pub fn remote_path(store: &Store) -> PathBuf {
store.dir.join("remote")
}
pub fn config_path(store: &Store) -> PathBuf {
store.dir.join("config.toml")
}
#[derive(serde::Deserialize, serde::Serialize, Debug, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct Config {
sync: Engine,
}
#[derive(serde::Deserialize, serde::Serialize, Debug, PartialEq, Eq, Clone)]
#[serde(tag = "engine", rename_all = "lowercase", deny_unknown_fields)]
pub enum Engine {
Icloud {
#[serde(skip_serializing_if = "Option::is_none")]
directory: Option<String>,
},
Directory {
path: String,
},
Git {
remote: String,
},
Rclone {
remote: String,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct Destination {
pub kind: Kind,
pub target: String,
}
impl Destination {
pub fn guess(target: &str) -> Self {
Destination {
kind: classify(target),
target: target.trim().to_string(),
}
}
}
fn icloud_root() -> Result<PathBuf> {
let home = std::env::var_os("HOME").context("HOME is not set")?;
Ok(PathBuf::from(home).join("Library/Mobile Documents/com~apple~CloudDocs"))
}
fn clean(what: &str, value: &str) -> Result<()> {
if value.is_empty() || value != value.trim() || value.chars().any(char::is_control) {
bail!("{what} {value:?} is empty, has a space at either end, or holds a control character");
}
Ok(())
}
impl Engine {
pub fn resolve(&self) -> Result<Destination> {
let (kind, target) = match self {
Engine::Icloud { directory } => {
let name = directory.as_deref().unwrap_or(".passbox");
clean("directory", name)?;
if name.contains('/') || name == "." || name == ".." {
bail!("directory {name:?} must be one folder name inside iCloud Drive");
}
let target = icloud_root()?.join(name).to_string_lossy().to_string();
(Kind::Directory, target)
}
Engine::Directory { path } => {
clean("path", path)?;
if !Path::new(path).is_absolute() {
bail!("path {path:?} must be absolute");
}
(Kind::Directory, path.clone())
}
Engine::Git { remote } => {
clean("remote", remote)?;
if classify(remote) != Kind::Git {
bail!("remote {remote:?} is not a git URL");
}
(Kind::Git, remote.clone())
}
Engine::Rclone { remote } => {
clean("remote", remote)?;
if classify(remote) != Kind::Rclone {
bail!("remote {remote:?} is not an rclone remote such as b2:passbox");
}
(Kind::Rclone, remote.clone())
}
};
Ok(Destination { kind, target })
}
}
pub fn configured(store: &Store) -> Result<Option<Destination>> {
let path = config_path(store);
if let Ok(text) = fs::read_to_string(&path) {
let invalid = || format!("{} is not valid", path.display());
let config: Config = toml::from_str(&text).with_context(invalid)?;
return config.sync.resolve().map(Some).with_context(invalid);
}
let old = legacy(store)?;
if old.is_some() {
eprintln!(
"passbox: {} is deprecated and stops being read in v2, run `passbox sync --enable` to \
move it to {}",
remote_path(store).display(),
config_path(store).display()
);
}
Ok(old)
}
fn legacy(store: &Store) -> Result<Option<Destination>> {
let Ok(text) = fs::read_to_string(remote_path(store)) else {
return Ok(None);
};
let text = text.trim();
if text.contains(['\n', '\r']) {
bail!(
"{} holds more than one line, write the destination on one line",
remote_path(store).display()
);
}
if text.is_empty() {
return Ok(None);
}
Ok(Some(Destination::guess(text)))
}
pub fn migrate(store: &Store) -> Result<Option<Destination>> {
if config_path(store).exists() {
return Ok(None);
}
let Some(old) = legacy(store)? else {
return Ok(None);
};
let engine = match old.kind {
Kind::Git => Engine::Git { remote: old.target },
Kind::Rclone => Engine::Rclone { remote: old.target },
Kind::Directory => match Path::new(&old.target).strip_prefix(icloud_root()?) {
Ok(name) if name == Path::new(".passbox") => Engine::Icloud { directory: None },
Ok(name) if name.components().count() == 1 => Engine::Icloud {
directory: Some(name.to_string_lossy().to_string()),
},
_ => Engine::Directory { path: old.target },
},
};
choose(store, engine).map(Some)
}
pub fn choose(store: &Store, engine: Engine) -> Result<Destination> {
let destination = engine.resolve()?;
let text = toml::to_string(&Config { sync: engine })?;
crate::store::write_private(&config_path(store), text.as_bytes())?;
let _ = fs::remove_file(remote_path(store));
Ok(destination)
}
pub fn default_destination(store: &Store) -> Result<Destination> {
if let Some(set) = std::env::var_os("PASSBOX_REMOTE") {
return Ok(Destination::guess(&set.to_string_lossy()));
}
if let Some(chosen) = configured(store)? {
return Ok(chosen);
}
Engine::Icloud { directory: None }.resolve()
}
fn skip(relative: &Path) -> bool {
let text = relative.to_string_lossy();
text == "broker.sock"
|| text == "remote"
|| text == "config.toml"
|| text == "names"
|| text == "plugins"
|| text == ".git"
|| text.starts_with(".git/")
|| text == ".gitignore"
|| text.starts_with("store/.versions")
|| text.ends_with(".partial")
|| (text.starts_with("grants-") && text.ends_with(".age"))
}
fn walk(root: &Path, base: &Path, out: &mut BTreeMap<PathBuf, SystemTime>) -> Result<()> {
if !base.exists() {
return Ok(());
}
for entry in fs::read_dir(base)? {
let path = entry?.path();
let relative = path.strip_prefix(root)?.to_path_buf();
if skip(&relative) {
continue;
}
if path.is_dir() {
walk(root, &path, out)?;
} else {
out.insert(relative, fs::metadata(&path)?.modified()?);
}
}
Ok(())
}
fn copy(from: &Path, to: &Path) -> Result<()> {
let parent = to.parent().context("a copy needs a parent directory")?;
fs::create_dir_all(parent)?;
let mut partial = to.as_os_str().to_owned();
partial.push(".partial");
let partial = PathBuf::from(partial);
fs::copy(from, &partial)?;
let when = fs::metadata(from)?.modified()?;
fs::File::options()
.write(true)
.open(&partial)?
.set_modified(when)?;
fs::rename(&partial, to)?;
Ok(())
}
#[derive(Default, Debug, PartialEq, Eq)]
pub struct Report {
pub pushed: usize,
pub pulled: usize,
}
fn check_destination(store: &Store, remote: &Path) -> Result<()> {
let shown = remote.display();
let again = "run `passbox sync --enable` to choose again";
if !remote.is_absolute() {
bail!("{shown} is not an absolute path, {again}");
}
if !remote.is_dir() {
bail!("{shown} does not exist. Create it, or {again}");
}
let (there, here) = (remote.canonicalize()?, store.dir.canonicalize()?);
if there.starts_with(&here) || here.starts_with(&there) {
bail!(
"{shown} overlaps the store at {}, {again}",
store.dir.display()
);
}
let mirror = remote.join("store").is_dir() || remote.join("wraps").is_dir();
let empty = fs::read_dir(remote)?
.filter_map(|e| e.ok())
.all(|e| e.file_name() == ".DS_Store");
if !mirror && !empty {
bail!("{shown} holds other files and no passbox copy, {again} with an empty folder");
}
Ok(())
}
pub fn sync(store: &Store, remote: &Path) -> Result<Report> {
if remote.to_string_lossy().contains(':') {
bail!(
"{} looks like an rclone remote, not a directory",
remote.display()
);
}
check_destination(store, remote)?;
let mut here = BTreeMap::new();
let mut there = BTreeMap::new();
walk(&store.dir, &store.dir, &mut here)?;
walk(remote, remote, &mut there)?;
let mut report = Report::default();
let names: Vec<PathBuf> = here.keys().chain(there.keys()).cloned().collect();
for name in names {
let ours = here.get(&name);
let theirs = there.get(&name);
let from_here = store.dir.join(&name);
let from_there = remote.join(&name);
match (ours, theirs) {
(Some(_), None) => {
copy(&from_here, &from_there)?;
report.pushed += 1;
}
(None, Some(_)) => {
keep_losing_version(store, &name)?;
copy(&from_there, &from_here)?;
report.pulled += 1;
}
(Some(ours), Some(theirs)) if ours > theirs => {
copy(&from_here, &from_there)?;
report.pushed += 1;
}
(Some(ours), Some(theirs)) if theirs > ours => {
keep_losing_version(store, &name)?;
copy(&from_there, &from_here)?;
report.pulled += 1;
}
_ => {}
}
}
Ok(report)
}
fn keep_losing_version(store: &Store, relative: &Path) -> Result<()> {
let Some(id) = secret_id(relative) else {
return Ok(());
};
store.keep_version(&id)
}
fn secret_id(relative: &Path) -> Option<String> {
if relative.parent() != Some(Path::new("store")) {
return None;
}
if relative.extension()?.to_str()? != "age" {
return None;
}
Some(relative.file_stem()?.to_str()?.to_string())
}
pub fn sync_via_rclone(store: &Store, remote: &str) -> Result<()> {
let status = std::process::Command::new("rclone")
.arg("bisync")
.arg(&store.dir)
.arg(remote)
.args(["--filter", "- broker.sock"])
.args(["--filter", "- .versions/**"])
.arg("--resync-mode=newer")
.status()
.context("rclone is not on PATH, install it or point PASSBOX_REMOTE at a directory")?;
if !status.success() {
bail!("rclone bisync failed, try once with --resync");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{Secret, new_id, now};
use secrecy::SecretString;
fn store(dir: &Path) -> (Store, age::x25519::Identity) {
let store = Store {
dir: dir.to_path_buf(),
};
let pass = SecretString::from("hunter2".to_string());
let key = store.init().unwrap();
store
.create_recovery_wrap_with_factor(&key, pass, Some(10))
.unwrap();
(store, key)
}
fn secret(name: &str, value: &str) -> Secret {
Secret {
name: name.to_string(),
value: value.to_string(),
created: now(),
updated: now(),
}
}
#[test]
fn destinations_are_classified_by_shape() {
assert_eq!(classify("/Volumes/stick/passbox"), Kind::Directory);
assert_eq!(classify("~/Dropbox/passbox"), Kind::Directory);
assert_eq!(classify("b2:passbox"), Kind::Rclone);
assert_eq!(classify("drive:passbox"), Kind::Rclone);
assert_eq!(classify("git@github.com:you/store.git"), Kind::Git);
assert_eq!(classify("https://github.com/you/store.git"), Kind::Git);
assert_eq!(classify("ssh://git@host/store"), Kind::Git);
}
#[test]
fn a_git_url_is_not_read_as_an_rclone_remote() {
assert_ne!(classify("git@github.com:you/store.git"), Kind::Rclone);
}
#[test]
fn a_chosen_remote_outlives_the_shell() {
let tmp = tempfile::tempdir().unwrap();
let (one, _key) = store(tmp.path());
assert!(configured(&one).unwrap().is_none());
crate::store::write_private(&remote_path(&one), b"/Volumes/stick/passbox\n").unwrap();
let chosen = configured(&one).unwrap().unwrap();
assert_eq!(chosen.target, "/Volumes/stick/passbox");
assert_eq!(chosen.kind, Kind::Directory);
}
#[test]
fn choosing_writes_a_config_that_wins_over_the_old_file() {
let tmp = tempfile::tempdir().unwrap();
let (one, _key) = store(tmp.path());
crate::store::write_private(&remote_path(&one), b"/old/place").unwrap();
let engine = Engine::Directory {
path: "/Volumes/stick/passbox".into(),
};
choose(&one, engine).unwrap();
assert!(!remote_path(&one).exists(), "the old file is replaced");
let text = fs::read_to_string(config_path(&one)).unwrap();
assert!(text.contains("engine = \"directory\""), "{text}");
assert_eq!(
configured(&one).unwrap().unwrap().target,
"/Volumes/stick/passbox"
);
}
#[test]
fn migrating_keeps_the_old_destination_and_names_its_engine() {
let tmp = tempfile::tempdir().unwrap();
let (one, _key) = store(tmp.path());
let icloud = icloud_root().unwrap();
let cases = [
(
icloud.join(".passbox").display().to_string(),
"engine = \"icloud\"\n",
),
(
icloud.join("vault").display().to_string(),
"directory = \"vault\"",
),
("/Volumes/stick/passbox".into(), "engine = \"directory\""),
("git@github.com:me/store.git".into(), "engine = \"git\""),
("b2:passbox".into(), "engine = \"rclone\""),
];
for (old, expected) in cases {
let _ = fs::remove_file(config_path(&one));
crate::store::write_private(&remote_path(&one), old.as_bytes()).unwrap();
assert_eq!(migrate(&one).unwrap().unwrap().target, old);
let text = fs::read_to_string(config_path(&one)).unwrap();
assert!(text.contains(expected), "{old}: {text}");
assert!(!remote_path(&one).exists(), "{old}");
assert_eq!(configured(&one).unwrap().unwrap().target, old);
}
crate::store::write_private(&remote_path(&one), b"/elsewhere").unwrap();
assert_eq!(migrate(&one).unwrap(), None);
assert!(remote_path(&one).exists());
}
#[test]
fn the_engine_names_the_kind_and_icloud_names_one_folder() {
let parse = |text: &str| toml::from_str::<Config>(text).map(|c| c.sync.resolve());
let icloud = parse("[sync]\nengine = \"icloud\"\n").unwrap().unwrap();
assert!(
icloud.target.ends_with("com~apple~CloudDocs/.passbox"),
"{icloud:?}"
);
let named = parse("[sync]\nengine = \"icloud\"\ndirectory = \"vault\"\n");
assert!(named.unwrap().unwrap().target.ends_with("CloudDocs/vault"));
let odd = parse("[sync]\nengine = \"directory\"\npath = \"/Volumes/a:b\"\n");
assert_eq!(odd.unwrap().unwrap().kind, Kind::Directory);
let git = parse("[sync]\nengine = \"git\"\nremote = \"git@github.com:you/s.git\"\n");
assert_eq!(git.unwrap().unwrap().kind, Kind::Git);
}
#[test]
fn a_config_that_makes_no_sense_is_refused() {
let refused = |text: &str| {
toml::from_str::<Config>(text)
.map_err(anyhow::Error::from)
.and_then(|c| c.sync.resolve())
.is_err()
};
assert!(refused(
"[sync]\nengine = \"icloud\"\ndirectory = \".ccdbkdtdu\npassbox\"\n"
));
assert!(refused(
"[sync]\nengine = \"icloud\"\ndirectory = \"a/b\"\n"
));
assert!(refused("[sync]\nengine = \"icloud\"\ndirectory = \"..\"\n"));
assert!(refused(
"[sync]\nengine = \"icloud\"\ndirectory = \" .passbox\"\n"
));
assert!(refused(
"[sync]\nengine = \"directory\"\npath = \"relative\"\n"
));
assert!(
refused("[sync]\nengine = \"directory\"\n"),
"a path is required"
);
assert!(
refused("[sync]\nengine = \"dropbox\"\n"),
"an unknown engine"
);
assert!(
refused("[sync]\nengine = \"icloud\"\npath = \"/x\"\n"),
"a key the engine has not"
);
assert!(refused(
"[sync]\nengine = \"git\"\nremote = \"/not/a/url\"\n"
));
assert!(refused(
"[sync]\nengine = \"rclone\"\nremote = \"no-colon\"\n"
));
assert!(
refused("[sync]\nengine = \"icloud\"\n.ccdbkdtdu\n"),
"a stray line"
);
assert!(
refused("[sync]\nengine = \"icloud\"\n[other]\n"),
"an unknown section"
);
}
#[test]
fn only_an_empty_folder_or_a_passbox_copy_is_a_destination() {
let tmp = tempfile::tempdir().unwrap();
let (one, _key) = store(&tmp.path().join("store"));
let refused = |remote: &Path| check_destination(&one, remote).is_err();
assert!(refused(Path::new("relative/passbox")));
assert!(refused(&tmp.path().join("missing")));
assert!(refused(&one.dir));
assert!(refused(&one.dir.join("store")));
assert!(refused(tmp.path()), "a folder holding the store");
let dropbox = tmp.path().join("dropbox");
fs::create_dir_all(&dropbox).unwrap();
fs::write(dropbox.join(".DS_Store"), "").unwrap();
assert!(!refused(&dropbox), "an empty folder is a new mirror");
fs::write(dropbox.join("taxes.pdf"), "").unwrap();
assert!(refused(&dropbox), "a folder with other files");
fs::create_dir(dropbox.join("store")).unwrap();
assert!(!refused(&dropbox), "a passbox copy");
}
#[test]
fn a_remote_split_by_a_yubikey_touch_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let (one, _key) = store(tmp.path());
crate::store::write_private(&remote_path(&one), b"/icloud/.ccdbkdtdu\npassbox\n").unwrap();
assert!(configured(&one).is_err());
assert!(default_destination(&one).is_err());
fs::write(
config_path(&one),
"[sync]\nengine = \"icloud\"\ndirectory = \".ccdbkdtdu\npassbox\"\n",
)
.unwrap();
assert!(configured(&one).is_err());
}
#[test]
fn each_side_gets_what_the_other_added() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
let (one, key) = store(a.path());
one.put(
&new_id(),
&secret("from/one", "1"),
&one.recipient().unwrap(),
)
.unwrap();
sync(&one, remote.path()).unwrap();
let two = Store {
dir: b.path().to_path_buf(),
};
sync(&two, remote.path()).unwrap();
two.put(
&new_id(),
&secret("from/two", "2"),
&two.recipient().unwrap(),
)
.unwrap();
sync(&two, remote.path()).unwrap();
sync(&one, remote.path()).unwrap();
assert_eq!(one.names(&key).unwrap(), vec!["from/one", "from/two"]);
assert_eq!(two.names(&key).unwrap(), vec!["from/one", "from/two"]);
}
#[test]
fn a_delete_on_one_side_reaches_the_other_and_stays_deleted() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
let (one, key) = store(a.path());
let id = new_id();
one.put(
&id,
&secret("banking/login", "swordfish"),
&one.recipient().unwrap(),
)
.unwrap();
sync(&one, remote.path()).unwrap();
let two = Store {
dir: b.path().to_path_buf(),
};
sync(&two, remote.path()).unwrap();
assert_eq!(two.names(&key).unwrap(), vec!["banking/login"]);
one.delete(&id).unwrap();
sync(&one, remote.path()).unwrap();
sync(&two, remote.path()).unwrap();
assert!(two.names(&key).unwrap().is_empty());
sync(&two, remote.path()).unwrap();
sync(&one, remote.path()).unwrap();
assert!(one.names(&key).unwrap().is_empty());
assert!(two.names(&key).unwrap().is_empty());
}
#[test]
fn a_repo_is_not_copied_to_the_mirror() {
assert!(skip(Path::new(".git")));
assert!(skip(Path::new(".git/config")));
assert!(skip(Path::new(".git/objects/ab/cdef")));
assert!(skip(Path::new(".gitignore")));
assert!(skip(Path::new("store/0a1b.age.partial")));
assert!(!skip(Path::new("store/abc123.age")));
}
#[test]
fn the_losing_side_of_a_clash_is_kept() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
let (one, key) = store(a.path());
let id = new_id();
one.put(&id, &secret("db/pass", "first"), &one.recipient().unwrap())
.unwrap();
sync(&one, remote.path()).unwrap();
let two = Store {
dir: b.path().to_path_buf(),
};
sync(&two, remote.path()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
two.put(&id, &secret("db/pass", "second"), &two.recipient().unwrap())
.unwrap();
sync(&two, remote.path()).unwrap();
sync(&one, remote.path()).unwrap();
assert_eq!(one.load(&id, &key).unwrap().value, "second");
assert!(!one.versions(&id).unwrap().is_empty());
}
#[test]
fn syncing_twice_changes_nothing_the_second_time() {
let a = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
let (one, _key) = store(a.path());
one.put(&new_id(), &secret("a/b", "v"), &one.recipient().unwrap())
.unwrap();
sync(&one, remote.path()).unwrap();
let second = sync(&one, remote.path()).unwrap();
assert_eq!(
second,
Report {
pushed: 0,
pulled: 0
}
);
}
#[test]
fn grants_never_travel() {
let a = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
let (one, _key) = store(a.path());
let grant = crate::project::Grant {
dir: "/x".into(),
hash: "h".into(),
secrets: vec!["a/b".into()],
until: now() + 600,
};
crate::project::save(&one, &[grant]).unwrap();
sync(&one, remote.path()).unwrap();
let leaked: Vec<_> = fs::read_dir(remote.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.starts_with("grants-"))
.collect();
assert!(leaked.is_empty(), "{leaked:?}");
}
#[test]
fn the_socket_and_the_versions_stay_at_home() {
let a = tempfile::tempdir().unwrap();
let remote = tempfile::tempdir().unwrap();
let (one, _key) = store(a.path());
let id = new_id();
one.put(&id, &secret("a/b", "v1"), &one.recipient().unwrap())
.unwrap();
one.put(&id, &secret("a/b", "v2"), &one.recipient().unwrap())
.unwrap();
fs::write(one.socket_path(), b"").unwrap();
sync(&one, remote.path()).unwrap();
assert!(!remote.path().join("broker.sock").exists());
assert!(!remote.path().join("store/.versions").exists());
}
#[test]
fn an_rclone_remote_is_not_treated_as_a_directory() {
let a = tempfile::tempdir().unwrap();
let (one, _key) = store(a.path());
assert!(sync(&one, Path::new("b2:passbox")).is_err());
}
}