use solo_core::{Error, Result};
use std::path::{Component, Path};
use unicode_normalization::UnicodeNormalization;
const CLOUD_SYNC_NAMES: &[&str] = &[
"dropbox",
"onedrive",
"google drive",
"googledrive",
"my drive",
"icloud drive",
"icloud",
"icloud~com~apple~clouddocs",
"mobile documents",
"box",
"box sync",
"pclouddrive",
"pcloud drive",
"mega",
"megasync",
"resilio sync",
"sync",
];
fn canonicalize_for_match(s: &str) -> String {
s.nfkc().collect::<String>().to_lowercase()
}
pub fn validate_data_dir(path: &Path) -> Result<()> {
if !path.is_absolute() {
return Err(Error::invalid_input(format!(
"data dir must be an absolute path: got {}",
path.display()
)));
}
for component in path.components() {
match component {
Component::Normal(os_name) => {
let name_lc = canonicalize_for_match(&os_name.to_string_lossy());
if CLOUD_SYNC_NAMES.iter().any(|&n| name_lc == n) {
return Err(Error::invalid_input(format!(
"refusing to initialize Solo inside a cloud-sync folder: \
`{}` (component `{}` matches known cloud-sync clients). \
SQLCipher + cloud sync corrupts databases. \
Choose a local-only path (e.g., ~/.solo).",
path.display(),
name_lc
)));
}
}
#[cfg(windows)]
Component::Prefix(prefix) => {
let prefix_raw = prefix.as_os_str().to_string_lossy();
for segment in prefix_raw.split(['\\', '/']) {
if segment.is_empty() {
continue;
}
let segment_norm = canonicalize_for_match(segment);
if CLOUD_SYNC_NAMES.iter().any(|&n| segment_norm == n) {
return Err(Error::invalid_input(format!(
"refusing to initialize Solo inside a cloud-sync folder: \
`{}` (UNC prefix segment `{}` matches known cloud-sync clients). \
SQLCipher + cloud sync corrupts databases. \
Choose a local-only path (e.g., ~/.solo).",
path.display(),
segment_norm
)));
}
}
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn abs(suffix: &str) -> PathBuf {
#[cfg(windows)]
{
let win = suffix.replace('/', "\\");
PathBuf::from(format!("C:\\{win}"))
}
#[cfg(not(windows))]
{
PathBuf::from(format!("/{suffix}"))
}
}
#[test]
fn rejects_dropbox_root() {
let p = abs("Users/alice/Dropbox/solo");
let err = validate_data_dir(&p).unwrap_err();
assert!(err.to_string().contains("cloud-sync"), "got: {err}");
assert!(err.to_string().contains("dropbox"), "got: {err}");
}
#[test]
fn rejects_onedrive_with_org_suffix() {
let p = abs("Users/bob/OneDrive/solo");
assert!(validate_data_dir(&p).is_err());
}
#[test]
fn rejects_icloud_drive() {
let p = abs("Users/c/Library/Mobile Documents/com~apple~CloudDocs/solo");
assert!(validate_data_dir(&p).is_err());
}
#[test]
fn rejects_case_variations() {
let p1 = abs("Users/d/DROPBOX/solo");
let p2 = abs("Users/d/dropbox/solo");
let p3 = abs("Users/d/Dropbox/solo");
assert!(validate_data_dir(&p1).is_err());
assert!(validate_data_dir(&p2).is_err());
assert!(validate_data_dir(&p3).is_err());
}
#[test]
fn accepts_dot_solo() {
let p = abs("home/eve/.solo");
assert!(validate_data_dir(&p).is_ok());
}
#[test]
fn accepts_explicit_local_path() {
let p = abs("var/lib/solo");
assert!(validate_data_dir(&p).is_ok());
}
#[test]
fn rejects_relative_path() {
let p = PathBuf::from(".solo");
let err = validate_data_dir(&p).unwrap_err();
assert!(err.to_string().contains("absolute"), "got: {err}");
}
#[test]
fn no_match_on_substring_within_a_component() {
let p = abs("home/f/dropboxlike/solo");
assert!(validate_data_dir(&p).is_ok());
}
#[test]
fn rejects_dropbox_with_unicode_case_variants() {
let p_cyrillic = abs("Users/x/dr\u{043e}pbox/solo"); assert!(
validate_data_dir(&p_cyrillic).is_ok(),
"NFKC does not catch script-mixed confusables — \
documented behaviour, fix would need UTS #39 confusable detection"
);
}
#[test]
fn rejects_full_width_latin_dropbox_via_nfkc() {
let p = abs("Users/z/\u{FF24}\u{FF52}\u{FF4F}\u{FF50}\u{FF42}\u{FF4F}\u{FF58}/solo");
let err = validate_data_dir(&p).unwrap_err();
assert!(
err.to_string().contains("cloud-sync"),
"NFKC should fold full-width Latin to ASCII; got: {err}"
);
}
#[test]
fn nfkc_decomposes_ligatures() {
let normalised = canonicalize_for_match("o\u{FB03}ce"); assert_eq!(normalised, "office");
}
#[test]
fn rejects_box_dot_com_via_box_component() {
let p = abs("Users/y/Box/solo");
assert!(validate_data_dir(&p).is_err());
}
#[test]
fn empty_path_is_rejected_as_non_absolute() {
let p = PathBuf::new();
let err = validate_data_dir(&p).unwrap_err();
assert!(err.to_string().contains("absolute"), "got: {err}");
}
#[test]
fn windows_unc_path_share_name_is_caught() {
#[cfg(windows)]
{
let p_share = PathBuf::from(r"\\fileserver\Dropbox\team\solo");
let err = validate_data_dir(&p_share).unwrap_err();
assert!(
err.to_string().contains("UNC prefix segment"),
"expected UNC-specific error, got: {err}"
);
let p_onedrive = PathBuf::from(r"\\nas\OneDrive\users\me\solo");
assert!(validate_data_dir(&p_onedrive).is_err());
let p_inner = PathBuf::from(r"\\fileserver\share\Dropbox\solo");
assert!(validate_data_dir(&p_inner).is_err());
let p_ok = PathBuf::from(r"\\fileserver\backup\team\solo");
assert!(validate_data_dir(&p_ok).is_ok());
}
}
}