use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use crate::{Error, Result};
const TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n# Seyk cache: disposable derived data.\n# https://bford.info/cachedir/\n";
static SYNC_WARNED: AtomicBool = AtomicBool::new(false);
static TAG_ID: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy)]
pub(crate) struct GuardStatus {
pub backup_excluded: Option<bool>,
pub in_synced_folder: bool,
}
pub(crate) fn prepare(dir: &Path, owned: bool) -> GuardStatus {
prepare_with(
dir,
owned,
|source, destination| std::fs::hard_link(source, destination),
backup_excluded,
|warning| eprintln!("{warning}"),
)
}
pub(crate) fn prepare_with(
dir: &Path,
owned: bool,
publish: impl FnOnce(&Path, &Path) -> std::io::Result<()>,
verify: impl FnOnce(&Path) -> Option<bool>,
mut warn: impl FnMut(String),
) -> GuardStatus {
let tag = dir.join("CACHEDIR.TAG");
let tag_result = if owned {
sweep_stale_tags(dir);
write_tag(dir, publish)
} else {
match std::fs::symlink_metadata(&tag) {
Ok(_) => validate_tag(&tag),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(Error::io(&tag, error)),
}
};
if let Err(error) = tag_result {
warn(format!(
"warning: could not tag cache directory {}: {error}",
dir.display()
));
}
if owned {
#[cfg(target_os = "macos")]
if !exclude_from_backup(dir) {
warn(format!(
"warning: could not exclude cache from Time Machine: {}",
dir.display()
));
}
}
let status = GuardStatus {
backup_excluded: verify(dir),
in_synced_folder: in_synced_folder(dir),
};
if status.in_synced_folder && !SYNC_WARNED.swap(true, Ordering::Relaxed) {
warn(format!(
"warning: Seyk cache is inside a known synced folder; sensitive cache data may leave this machine: {}",
dir.display()
));
}
status
}
fn sweep_stale_tags(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if !entry
.file_name()
.to_string_lossy()
.starts_with(".CACHEDIR.TAG-")
{
continue;
}
let Ok(metadata) = std::fs::symlink_metadata(entry.path()) else {
continue;
};
if metadata.is_file()
&& metadata
.modified()
.ok()
.and_then(|time| time.elapsed().ok())
.is_some_and(|age| age > std::time::Duration::from_secs(3600))
{
let _ = std::fs::remove_file(entry.path());
}
}
}
fn write_tag(dir: &Path, publish: impl FnOnce(&Path, &Path) -> std::io::Result<()>) -> Result<()> {
use std::io::Write;
let tag = dir.join("CACHEDIR.TAG");
let temporary = dir.join(format!(
".CACHEDIR.TAG-{}-{}",
std::process::id(),
TAG_ID.fetch_add(1, Ordering::Relaxed)
));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temporary)
.map_err(|e| Error::io(&temporary, e))?;
let result = (|| {
file.write_all(TAG.as_bytes())
.map_err(|e| Error::io(&temporary, e))?;
match publish(&temporary, &tag) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => validate_tag(&tag),
Err(e) => Err(Error::io(&tag, e)),
}
})();
drop(file);
let _ = std::fs::remove_file(&temporary);
result
}
fn validate_tag(tag: &Path) -> Result<()> {
if !std::fs::symlink_metadata(tag)
.map_err(|e| Error::io(tag, e))?
.is_file()
{
return Err(Error::Config(format!(
"cache tag is not a regular file: {}",
tag.display()
)));
}
let contents = std::fs::read(tag).map_err(|e| Error::io(tag, e))?;
if !contents.starts_with(b"Signature: 8a477f597d28d172789f06886806bc55") {
return Err(Error::Config(format!(
"invalid cache tag: {}",
tag.display()
)));
}
Ok(())
}
#[cfg(target_os = "macos")]
fn exclude_from_backup(dir: &Path) -> bool {
use std::ffi::c_void;
use std::os::unix::ffi::OsStrExt;
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
static kCFURLIsExcludedFromBackupKey: *const c_void;
static kCFBooleanTrue: *const c_void;
fn CFURLCreateFromFileSystemRepresentation(
allocator: *const c_void,
bytes: *const u8,
length: isize,
is_directory: u8,
) -> *const c_void;
fn CFURLSetResourcePropertyForKey(
url: *const c_void,
key: *const c_void,
value: *const c_void,
error: *mut *const c_void,
) -> u8;
fn CFRelease(value: *const c_void);
}
let Ok(dir) = std::fs::canonicalize(dir) else {
return false;
};
let bytes = dir.as_os_str().as_bytes();
let Ok(length) = isize::try_from(bytes.len()) else {
return false;
};
unsafe {
let url =
CFURLCreateFromFileSystemRepresentation(std::ptr::null(), bytes.as_ptr(), length, 1);
if url.is_null() {
return false;
}
let success = CFURLSetResourcePropertyForKey(
url,
kCFURLIsExcludedFromBackupKey,
kCFBooleanTrue,
std::ptr::null_mut(),
) != 0;
CFRelease(url);
success
}
}
#[cfg(target_os = "macos")]
pub(crate) fn backup_excluded(dir: &Path) -> Option<bool> {
use std::ffi::c_void;
use std::os::unix::ffi::OsStrExt;
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
static kCFURLIsExcludedFromBackupKey: *const c_void;
fn CFURLCreateFromFileSystemRepresentation(
allocator: *const c_void,
bytes: *const u8,
length: isize,
is_directory: u8,
) -> *const c_void;
fn CFURLCopyResourcePropertyForKey(
url: *const c_void,
key: *const c_void,
value: *mut *const c_void,
error: *mut *const c_void,
) -> u8;
fn CFBooleanGetValue(value: *const c_void) -> u8;
fn CFRelease(value: *const c_void);
}
let dir = std::fs::canonicalize(dir).ok()?;
let bytes = dir.as_os_str().as_bytes();
unsafe {
let url = CFURLCreateFromFileSystemRepresentation(
std::ptr::null(),
bytes.as_ptr(),
bytes.len().try_into().ok()?,
1,
);
if url.is_null() {
return None;
}
let mut value = std::ptr::null();
let copied = CFURLCopyResourcePropertyForKey(
url,
kCFURLIsExcludedFromBackupKey,
&mut value,
std::ptr::null_mut(),
) != 0;
let result = if copied && !value.is_null() {
Some(CFBooleanGetValue(value) != 0)
} else {
None
};
if !value.is_null() {
CFRelease(value);
}
CFRelease(url);
result
}
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn backup_excluded(_dir: &Path) -> Option<bool> {
None
}
pub(crate) fn parent_dir(path: &Path) -> &Path {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."))
}
pub(crate) fn is_default_dir(dir: &Path) -> bool {
dirs::cache_dir().is_some_and(|base| {
resolved(dir)
.zip(resolved(&base.join("seyk")))
.is_some_and(|(a, b)| a == b)
})
}
fn resolved(path: &Path) -> Option<PathBuf> {
let absolute = std::path::absolute(path).ok()?;
let mut ancestor = absolute.as_path();
let mut suffix = Vec::new();
loop {
if let Ok(mut base) = std::fs::canonicalize(ancestor) {
for part in suffix.iter().rev() {
base.push(part);
}
let mut normalized = PathBuf::new();
for part in base.components() {
match part {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {}
_ => normalized.push(part.as_os_str()),
}
}
return Some(normalized);
}
suffix.push(ancestor.file_name()?.to_os_string());
ancestor = ancestor.parent()?;
}
}
pub(crate) fn in_synced_folder(path: &Path) -> bool {
detected(path, dirs::home_dir().as_deref())
}
fn detected(path: &Path, home: Option<&Path>) -> bool {
let Some(path) = resolved(path) else {
return false;
};
if path.ancestors().any(|p| p.join(".stfolder").is_dir()) {
return true;
}
let mut roots = Vec::new();
if let Some(home) = home {
for suffix in [
"Library/Mobile Documents",
"Dropbox",
"OneDrive",
"Google Drive",
"GoogleDrive",
"Sync",
] {
roots.push(home.join(suffix));
}
let cloud = home.join("Library/CloudStorage");
if let Ok(entries) = std::fs::read_dir(cloud) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if ["Dropbox", "OneDrive", "GoogleDrive"]
.iter()
.any(|prefix| name.starts_with(prefix))
{
roots.push(entry.path());
}
}
}
for config in [
home.join(".dropbox/info.json"),
home.join("AppData/Local/Dropbox/info.json"),
home.join("AppData/Roaming/Dropbox/info.json"),
] {
if let Ok(bytes) = std::fs::read(config)
&& let Ok(serde_json::Value::Object(accounts)) = serde_json::from_slice(&bytes)
{
roots.extend(
accounts
.values()
.filter_map(|account| account.get("path")?.as_str().map(PathBuf::from)),
);
}
}
}
for key in ["OneDrive", "OneDriveCommercial", "OneDriveConsumer"] {
if let Some(root) = std::env::var_os(key) {
roots.push(PathBuf::from(root));
}
}
roots
.iter()
.filter_map(|root| resolved(root))
.any(|root| path.starts_with(root))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tags_are_standard_and_idempotent_and_exclusion_is_verified() {
let temp = tempfile::tempdir().unwrap();
#[cfg(target_os = "macos")]
assert_eq!(backup_excluded(temp.path()), Some(false));
prepare(temp.path(), true);
prepare(temp.path(), true);
assert_eq!(
std::fs::read_to_string(temp.path().join("CACHEDIR.TAG")).unwrap(),
TAG
);
#[cfg(target_os = "macos")]
{
assert_eq!(backup_excluded(temp.path()), Some(true));
let output = std::process::Command::new("/usr/bin/tmutil")
.arg("isexcluded")
.arg(temp.path())
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).starts_with("[Excluded]"));
let destination = tempfile::tempdir().unwrap();
let moved = destination.path().join("moved-cache");
std::fs::rename(temp.path(), &moved).unwrap();
assert_eq!(backup_excluded(&moved), Some(true));
}
#[cfg(not(target_os = "macos"))]
assert_eq!(backup_excluded(temp.path()), None);
}
#[test]
fn concurrent_preparation_publishes_only_complete_tags() {
let temp = tempfile::tempdir().unwrap();
prepare(temp.path(), true);
std::fs::remove_file(temp.path().join("CACHEDIR.TAG")).unwrap();
std::thread::scope(|scope| {
for _ in 0..8 {
let dir = temp.path();
scope.spawn(move || prepare(dir, true));
}
});
assert_eq!(
std::fs::read_to_string(temp.path().join("CACHEDIR.TAG")).unwrap(),
TAG
);
assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1);
}
#[test]
fn synced_warning_once_per_process() {
const CHILD: &str = "SEYK_GUARDS_WARNING_CHILD";
if std::env::var_os(CHILD).is_some() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir(temp.path().join(".stfolder")).unwrap();
prepare(temp.path(), true);
prepare(temp.path(), true);
return;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"cache_guards::tests::synced_warning_once_per_process",
"--nocapture",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
assert_eq!(
String::from_utf8_lossy(&output.stderr)
.matches("inside a known synced folder")
.count(),
1
);
}
#[cfg(unix)]
#[test]
fn symlink_tag_is_rejected_without_modifying_target() {
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("target");
std::fs::write(&target, TAG).unwrap();
std::os::unix::fs::symlink(&target, temp.path().join("CACHEDIR.TAG")).unwrap();
prepare(temp.path(), true);
assert_eq!(std::fs::read_to_string(target).unwrap(), TAG);
}
#[test]
fn existing_unrelated_tag_is_never_overwritten() {
let temp = tempfile::tempdir().unwrap();
let tag = temp.path().join("CACHEDIR.TAG");
std::fs::write(&tag, "other data").unwrap();
prepare(temp.path(), true);
assert_eq!(std::fs::read_to_string(tag).unwrap(), "other data");
}
#[test]
fn foreign_custom_directory_warns_without_tagging_or_excluding() {
let temp = tempfile::tempdir().unwrap();
let tag = temp.path().join("CACHEDIR.TAG");
std::fs::write(&tag, "foreign data").unwrap();
let before = backup_excluded(temp.path());
let mut warnings = Vec::new();
let status = prepare_with(
temp.path(),
false,
|_, _| panic!("must not publish"),
backup_excluded,
|w| warnings.push(w),
);
assert_eq!(status.backup_excluded, before);
assert_eq!(std::fs::read_to_string(tag).unwrap(), "foreign data");
assert!(warnings.iter().any(|w| w.contains("invalid cache tag")));
}
#[test]
fn foreign_tags_warn_and_stale_temporary_tags_are_swept() {
let temp = tempfile::tempdir().unwrap();
let tag = temp.path().join("CACHEDIR.TAG");
std::fs::write(&tag, "foreign data").unwrap();
let old = temp.path().join(".CACHEDIR.TAG-old");
let fresh = temp.path().join(".CACHEDIR.TAG-fresh");
std::fs::write(&old, "unfinished").unwrap();
std::fs::write(&fresh, "unfinished").unwrap();
std::fs::OpenOptions::new()
.write(true)
.open(&old)
.unwrap()
.set_times(
std::fs::FileTimes::new().set_modified(
std::time::SystemTime::now() - std::time::Duration::from_secs(7200),
),
)
.unwrap();
let mut warnings = Vec::new();
prepare_with(
temp.path(),
true,
|source, destination| std::fs::hard_link(source, destination),
backup_excluded,
|w| warnings.push(w),
);
assert!(warnings.iter().any(|w| w.contains("invalid cache tag")));
assert_eq!(std::fs::read_to_string(tag).unwrap(), "foreign data");
assert!(!old.exists());
assert!(fresh.exists());
}
#[test]
fn sync_detection_covers_provider_roots_config_and_markers() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path();
for suffix in [
"Library/Mobile Documents/com~apple~CloudDocs",
"Dropbox",
"OneDrive",
"Google Drive",
"Library/CloudStorage/GoogleDrive-account",
"Library/CloudStorage/OneDrive-account",
] {
let root = home.join(suffix);
std::fs::create_dir_all(&root).unwrap();
assert!(
detected(&root.join("cache/cache.sqlite3"), Some(home)),
"{suffix}"
);
}
assert!(!detected(
&home.join("Dropbox-other/cache.sqlite3"),
Some(home)
));
assert!(!detected(
&home.join("Library/Caches/seyk/cache.sqlite3"),
Some(home)
));
let custom = home.join("custom-dropbox");
std::fs::create_dir_all(home.join(".dropbox")).unwrap();
std::fs::write(
home.join(".dropbox/info.json"),
serde_json::json!({"personal": {"path": custom}}).to_string(),
)
.unwrap();
assert!(detected(&custom.join("cache.sqlite3"), Some(home)));
let syncthing = home.join("custom-sync");
std::fs::create_dir_all(syncthing.join(".stfolder")).unwrap();
assert!(detected(&syncthing.join("nested/cache.sqlite3"), None));
#[cfg(unix)]
{
let alias = home.join("alias");
std::os::unix::fs::symlink(home.join("Dropbox"), &alias).unwrap();
assert!(detected(&alias.join("cache.sqlite3"), Some(home)));
assert!(!detected(
&home.join("Dropbox/../local/cache.sqlite3"),
Some(home)
));
}
}
}