use crate::context::Context;
use crate::error::{Error, Result};
use crate::host::Os;
use crate::service::Permit;
use std::io;
use std::path::{Path, PathBuf};
const SYNCED: [&str; 5] = [
"Dropbox",
"Google Drive",
"OneDrive",
"com~apple~CloudDocs",
"Sync",
];
fn a_sync_clients_folder(name: &str) -> bool {
let name = name.to_lowercase();
[
"onedrive",
"dropbox",
"google drive",
"com~apple~clouddocs",
"sync",
]
.contains(&name.as_str())
|| name.starts_with("onedrive - ")
|| name.starts_with("onedrive-")
|| (name.starts_with("dropbox (") && name.ends_with(')'))
|| name.starts_with("googledrive-")
}
const SYNCED_INSIDE: [[&str; 2]; 2] =
[["library", "cloudstorage"], ["library", "mobile documents"]];
fn synced_by(path: &Path) -> Option<String> {
let text = path.to_string_lossy();
if let Some(marker) = SYNCED.iter().find(|marker| text.contains(**marker)) {
return Some((*marker).to_string());
}
let names: Vec<String> = path
.components()
.map(|part| part.as_os_str().to_string_lossy().into_owned())
.collect();
names.iter().enumerate().find_map(|(at, name)| {
if a_sync_clients_folder(name) {
return Some(name.clone());
}
let parent = at.checked_sub(1).map(|before| names[before].as_str())?;
SYNCED_INSIDE
.iter()
.any(|[outer, inner]| parent.to_lowercase() == *outer && name.to_lowercase() == *inner)
.then(|| format!("{parent}/{name}"))
})
}
pub(crate) fn dir(ctx: &Context) -> PathBuf {
ctx.pitboard_home.clone()
}
pub(crate) fn remove_retired(ctx: &Context, permit: Permit) {
let readings = dir(ctx).join("readings");
let Ok(entries) = std::fs::read_dir(&readings) else {
return;
};
for path in entries.flatten().map(|entry| entry.path()) {
let written_by_pitboard = path.extension().is_some_and(|e| e == "ndjson")
&& path
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| {
!stem.is_empty()
&& stem
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
});
if written_by_pitboard {
let _ = crate::host::fs::remove_file(permit, &path);
}
}
let _ = crate::host::fs::remove_dir(permit, &readings);
}
#[derive(Debug)]
pub(crate) struct Kept {
pub(crate) left: Vec<PathBuf>,
pub(crate) why: io::Error,
}
impl From<Kept> for io::Error {
fn from(kept: Kept) -> io::Error {
let left: Vec<String> = kept
.left
.iter()
.map(|path| path.display().to_string())
.collect();
io::Error::new(
kept.why.kind(),
format!("{} could not be removed: {}", left.join(", "), kept.why),
)
}
}
pub(crate) fn remove_all(permit: Permit, folder: &Path) -> std::result::Result<(), Kept> {
let why = match crate::host::fs::remove_dir_all(permit, folder) {
Ok(()) => return Ok(()),
Err(_) if !folder.exists() => return Ok(()),
Err(why) => why,
};
let mut left = Vec::new();
remove_each(permit, folder, &mut left);
if left.is_empty() {
return Ok(());
}
left.sort();
Err(Kept { left, why })
}
fn remove_each(permit: Permit, folder: &Path, left: &mut Vec<PathBuf>) {
let before = left.len();
for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
remove_each(permit, &path, left);
} else if crate::host::fs::remove_file(permit, &path).is_err() {
left.push(path);
}
}
if crate::host::fs::remove_dir(permit, folder).is_err() && left.len() == before {
left.push(folder.to_path_buf());
}
}
pub(crate) fn ensure(ctx: &Context, permit: Permit) -> io::Result<PathBuf> {
let path = dir(ctx);
crate::host::fs::create_private_dir(permit, &path)?;
Ok(path)
}
pub(crate) fn check_absolute(ctx: &Context) -> Result<()> {
let homes: [(&'static str, Option<&Path>); 5] = [
(ctx.os.home_variable(), Some(ctx.home())),
("PITBOARD_HOME", Some(&ctx.pitboard_home)),
(
"CLAUDE_CONFIG_DIR",
ctx.claude_config_dir.as_deref().map(Path::new),
),
(
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
ctx.secure_storage_dir
.as_deref()
.filter(|dir| !dir.is_empty())
.map(Path::new),
),
("CODEX_HOME", ctx.codex_home().map(Path::new)),
];
match homes
.into_iter()
.find_map(|(variable, path)| path.filter(|p| !p.is_absolute()).map(|p| (variable, p)))
{
Some((variable, path)) => Err(Error::HomeNotAbsolute {
variable,
path: path.to_path_buf(),
}),
None => Ok(()),
}
}
pub(crate) fn check_location(ctx: &Context, path: &Path) -> Result<()> {
check_location_with(path, &Syncing::of(ctx))
}
pub(crate) fn check_location_with(path: &Path, syncing: &Syncing) -> Result<()> {
let resolved = path
.canonicalize()
.map_or_else(|_| path.to_path_buf(), crate::host::without_verbatim_prefix);
match synced_by(&resolved).or_else(|| syncing.over(&resolved)) {
Some(marker) => Err(Error::StateOnSyncedDrive {
path: resolved,
marker,
}),
None => Ok(()),
}
}
pub(crate) struct Syncing<'a> {
pub(crate) folders: Vec<PathBuf>,
pub(crate) roaming: Option<PathBuf>,
pub(crate) in_a_sync_root: &'a dyn Fn(&Path) -> bool,
}
impl Default for Syncing<'_> {
fn default() -> Self {
Syncing {
folders: Vec::new(),
roaming: None,
in_a_sync_root: &|_| false,
}
}
}
impl Syncing<'static> {
fn of(ctx: &Context) -> Syncing<'static> {
match ctx.os {
Os::MacOs | Os::Linux => Syncing::default(),
Os::Windows => Syncing {
folders: ctx.one_drive.clone(),
roaming: crate::host::user::profile_roams()
.then(|| ctx.profile.clone().unwrap_or_else(|| ctx.home.clone())),
in_a_sync_root: &crate::host::fs::in_a_sync_root,
},
}
}
}
impl Syncing<'_> {
fn over(&self, path: &Path) -> Option<String> {
let within = |folder: &Path| {
path.ancestors()
.any(|above| crate::host::same_path_in_any_case(above, folder))
};
if let Some(folder) = self.folders.iter().find(|folder| within(folder)) {
return Some(folder.display().to_string());
}
if (self.in_a_sync_root)(path) {
return Some("a cloud sync folder".into());
}
let profile = self.roaming.as_ref()?;
let local = ["Local", "LocalLow"].map(|name| profile.join("AppData").join(name));
(within(profile) && !local.iter().any(|folder| within(folder)))
.then(|| "a roaming profile".into())
}
}
#[cfg(test)]
#[allow(
clippy::disallowed_methods,
reason = "a test sets up its scratch files as a person or a tool would"
)]
mod tests {
use super::*;
use crate::context::Environment;
fn refused_over(os: Os, pairs: &[(&str, &str)]) -> Option<(&'static str, PathBuf)> {
let env = Environment::on(os, pairs.iter().copied());
match check_absolute(&Context::read(&env, "cli")) {
Ok(()) => None,
Err(Error::HomeNotAbsolute { variable, path }) => Some((variable, path)),
Err(other) => panic!("{other}"),
}
}
#[test]
fn what_an_older_pitboard_kept_and_this_one_does_not_is_removed() {
let root = std::env::temp_dir().join(format!(
"pitboard-home-retired-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
let ctx = Context::new(root.clone()).with_pitboard_home(root.join(".pitboard"));
let readings = dir(&ctx).join("readings");
std::fs::create_dir_all(&readings).expect("a readings folder");
std::fs::write(readings.join("acc.ndjson"), "{}\n").expect("a reading");
std::fs::write(dir(&ctx).join("usage.json"), "{}").expect("usage");
remove_retired(&ctx, Permit::for_a_test());
assert!(!readings.exists());
assert!(
dir(&ctx).join("usage.json").exists(),
"only what is retired"
);
remove_retired(&ctx, Permit::for_a_test());
std::fs::create_dir_all(&readings).expect("a readings folder");
std::fs::write(readings.join("acc.ndjson"), "{}\n").expect("a reading");
std::fs::write(readings.join("notes.txt"), "mine").expect("somebody's own");
std::fs::write(readings.join("my data.ndjson"), "mine").expect("not an account id");
remove_retired(&ctx, Permit::for_a_test());
assert!(!readings.join("acc.ndjson").exists());
assert!(readings.join("notes.txt").exists());
assert!(readings.join("my data.ndjson").exists());
let _ = std::fs::remove_dir_all(&root);
}
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"pitboard-home-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
root
}
#[test]
fn what_could_not_be_removed_is_named_alone_and_the_rest_goes() {
use crate::host::fs::testing;
let home = scratch("kept");
let stuck = home.join("stuck");
std::fs::create_dir_all(&stuck).expect("a folder");
std::fs::write(stuck.join("kept"), "x").expect("written");
for n in 0..20 {
std::fs::write(home.join(format!("file-{n}")), "x").expect("written");
}
testing::deny_changes(&stuck).expect("read-only");
let kept = remove_all(Permit::for_a_test(), &home);
testing::allow_changes(&stuck).expect("changeable again");
let kept = kept.expect_err("a file stayed");
assert_eq!(kept.left, vec![stuck.join("kept")]);
let mut there: Vec<PathBuf> = std::fs::read_dir(&home)
.expect("still there")
.map(|entry| entry.expect("listed").path())
.collect();
there.sort();
assert_eq!(there, vec![stuck.clone()]);
remove_all(Permit::for_a_test(), &home).expect("removed once it can be");
assert!(!home.exists());
}
#[cfg(windows)]
#[test]
fn a_file_held_without_sharing_its_deletion_keeps_only_itself() {
use crate::helper::{Helper, Job};
let home = scratch("held");
std::fs::create_dir_all(&home).expect("a folder");
for name in ["audit.log", "state.json", "usage.json"] {
std::fs::write(home.join(name), "x").expect("written");
}
let held = Helper::start(Job::HoldsWithoutDelete, &home.join("audit.log"));
let kept = remove_all(Permit::for_a_test(), &home).expect_err("held");
assert_eq!(kept.left, vec![home.join("audit.log")]);
assert!(!home.join("state.json").exists());
assert!(!home.join("usage.json").exists());
held.stop();
remove_all(Permit::for_a_test(), &home).expect("removed once let go of");
assert!(!home.exists());
}
#[test]
fn a_home_that_is_not_a_full_path_is_refused_by_the_variable_that_named_it() {
let full = std::env::temp_dir().join("pitboard-full-home");
let at = |name: &str| full.join(name).to_string_lossy().into_owned();
let (pitboard, claude, slot, codex) =
(at("pitboard"), at("claude"), at("slot"), at("codex"));
let x = full.to_string_lossy().into_owned();
for os in [Os::MacOs, Os::Linux, Os::Windows] {
let variable = os.home_variable();
let home = (variable, x.as_str());
for pairs in [
&[home][..],
&[home, ("PITBOARD_HOME", pitboard.as_str())],
&[home, ("CLAUDE_CONFIG_DIR", claude.as_str())],
&[home, ("CLAUDE_SECURESTORAGE_CONFIG_DIR", "")],
&[home, ("CLAUDE_SECURESTORAGE_CONFIG_DIR", slot.as_str())],
&[home, ("CODEX_HOME", codex.as_str())],
&[home, ("CODEX_HOME", "")],
] {
assert_eq!(refused_over(os, pairs), None, "{os:?} {pairs:?}");
}
for (pairs, refused, path) in [
(&[(variable, "")][..], variable, ""),
(&[(variable, "relative")], variable, "relative"),
(
&[(variable, "x"), ("PITBOARD_HOME", pitboard.as_str())],
variable,
"x",
),
(&[home, ("PITBOARD_HOME", "")], "PITBOARD_HOME", ""),
(
&[home, ("PITBOARD_HOME", "pitboard")],
"PITBOARD_HOME",
"pitboard",
),
(
&[home, ("CLAUDE_CONFIG_DIR", "claude")],
"CLAUDE_CONFIG_DIR",
"claude",
),
(&[home, ("CLAUDE_CONFIG_DIR", "")], "CLAUDE_CONFIG_DIR", ""),
(
&[home, ("CLAUDE_SECURESTORAGE_CONFIG_DIR", "./slot")],
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
"./slot",
),
(&[home, ("CODEX_HOME", "codex")], "CODEX_HOME", "codex"),
] {
assert_eq!(
refused_over(os, pairs),
Some((refused, PathBuf::from(path))),
"{os:?} {pairs:?}"
);
}
}
}
#[test]
fn a_home_that_is_not_a_full_path_is_said_with_the_way_out() {
let said = |pairs: &[(&str, &str)]| {
let env: Environment = pairs.iter().copied().collect();
let refused = check_absolute(&Context::for_command_line(&env)).expect_err("refused");
assert_eq!(refused.code(), "home_not_absolute");
refused.to_string()
};
let variable = crate::host::OS.home_variable();
assert_eq!(
said(&[(variable, "")]),
format!(
"{variable} is empty, so the folder it names would depend on where each \
program runs. Set it to a full path, or unset it; Pitboard reads and changes \
nothing until then."
)
);
let full = std::env::temp_dir().to_string_lossy().into_owned();
assert!(
said(&[(variable, full.as_str()), ("CODEX_HOME", "codex")])
.starts_with("CODEX_HOME is `codex`, which is not a full path, so ")
);
}
fn refused_before(path: &str) -> bool {
SYNCED.iter().any(|marker| path.contains(marker))
}
fn synced_over(path: &str) -> Option<String> {
synced_on(path, &Syncing::default())
}
fn synced_on(path: &str, syncing: &Syncing) -> Option<String> {
match check_location_with(Path::new(path), syncing) {
Ok(()) => None,
Err(Error::StateOnSyncedDrive { marker, .. }) => Some(marker),
Err(other) => panic!("{other}"),
}
}
#[test]
fn a_windows_folder_that_syncs_or_roams_is_refused() {
let roots = |path: &Path| path.starts_with("/u/Box");
let syncing = Syncing {
folders: vec![PathBuf::from("/u/Work Files")],
roaming: Some(PathBuf::from("/u/profile")),
in_a_sync_root: &roots,
};
for (path, marker) in [
("/u/Work Files/pitboard", "/u/Work Files"),
("/u/work files/x/pitboard", "/u/Work Files"),
("/u/Box/pitboard", "a cloud sync folder"),
("/u/profile/pitboard", "a roaming profile"),
("/u/profile/AppData/Roaming/Pitboard", "a roaming profile"),
("/u/PROFILE/AppData/LocalLowly", "a roaming profile"),
] {
assert_eq!(synced_on(path, &syncing).as_deref(), Some(marker), "{path}");
}
for path in [
"/u/profile/AppData/Local/Pitboard",
"/u/profile/appdata/local",
"/u/profile/AppData/LocalLow/Pitboard",
"/u/elsewhere/pitboard",
"/u/Boxes/pitboard",
] {
assert_eq!(synced_on(path, &syncing), None, "{path}");
}
assert_eq!(
synced_on("/u/profile/pitboard", &Syncing::default()),
None,
"a local profile"
);
}
#[test]
fn a_sync_clients_folder_is_refused_by_its_name_in_any_case() {
for (path, marker) in [
("/home/x/OneDrive/pitboard", "OneDrive"),
("/home/x/onedrive/pitboard", "onedrive"),
("/home/x/OneDrive - Contoso/pitboard", "OneDrive"),
("/home/x/onedrive - contoso/pitboard", "onedrive - contoso"),
("/home/x/OneDrive-Personal/pitboard", "OneDrive"),
("/home/x/ONEDRIVE-PERSONAL/pitboard", "ONEDRIVE-PERSONAL"),
("/home/x/Dropbox/pitboard", "Dropbox"),
("/home/x/dropbox/pitboard", "dropbox"),
("/home/x/Dropbox (Personal)/pitboard", "Dropbox"),
("/home/x/Dropbox (Business)/pitboard", "Dropbox"),
("/home/x/dropbox (team)/pitboard", "dropbox (team)"),
(
"/home/x/GoogleDrive-a@b.example/pitboard",
"GoogleDrive-a@b.example",
),
(
"/home/x/googledrive-A@B.example/pitboard",
"googledrive-A@B.example",
),
("/home/x/Google Drive/pitboard", "Google Drive"),
("/home/x/google drive/pitboard", "google drive"),
(
"/Users/x/Library/CloudStorage/Box-Box/pitboard",
"Library/CloudStorage",
),
("/Users/x/library/cloudstorage/x", "library/cloudstorage"),
(
"/Users/x/Library/Mobile Documents/iCloud~md~obsidian/pitboard",
"Library/Mobile Documents",
),
(
"/Users/x/Library/Mobile Documents/com~apple~CloudDocs/pitboard",
"com~apple~CloudDocs",
),
(
"/Users/x/COM~APPLE~CLOUDDOCS/pitboard",
"COM~APPLE~CLOUDDOCS",
),
("/home/x/Sync/pitboard", "Sync"),
("/home/x/sync/pitboard", "sync"),
] {
assert_eq!(synced_over(path).as_deref(), Some(marker), "{path}");
}
}
#[test]
fn a_home_named_as_a_sync_client_names_its_folder_is_refused_too() {
for (path, marker) in [
("/home/sync/.pitboard", "sync"),
("/home/dropbox/.pitboard", "dropbox"),
("/home/OneDrive/.pitboard", "OneDrive"),
] {
assert_eq!(synced_over(path).as_deref(), Some(marker), "{path}");
}
assert_eq!(synced_over("/home/syncer/.pitboard"), None);
}
#[test]
fn every_folder_refused_before_is_refused_still() {
let before = [
"/home/x/Resilio Sync/pitboard",
"/home/x/Box Sync/pitboard",
"/home/x/Syncthing/pitboard",
"/home/x/SyncAdmin/pitboard",
"/home/x/OneDriveTools/pitboard",
"/home/x/MyDropboxBackup/pitboard",
"/home/x/Google Drive Stream/pitboard",
"/Users/Synchro/.pitboard",
"/home/x/Dropbox (Personal)/pitboard",
"/home/x/OneDrive - Contoso/pitboard",
];
for path in before {
assert!(refused_before(path), "{path}");
assert!(synced_over(path).is_some(), "{path}");
}
let neither = [
"/Users/x/.pitboard",
"/home/x/.pitboard",
"/home/x/projects/synchronous/.pitboard",
"/home/x/syncthing/pitboard",
"/home/x/dropboxes/pitboard",
"/home/x/onedrivetools/pitboard",
"/Users/x/Library/Application Support/Pitboard",
"/Users/x/Library/Caches/CloudStorage/pitboard",
"/Users/x/Documents/Library/Mobile/pitboard",
];
for path in neither {
assert!(!refused_before(path), "{path}");
assert_eq!(synced_over(path), None, "{path}");
}
}
}