use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::paths::normalize_for_comparison;
use super::fs_caps::FilesystemCapabilities;
use super::workload::{DirEntry, Entry, Workload};
#[derive(Debug, Default)]
pub(crate) struct ValidationOutcome {
pub rejected_entries: Vec<(Entry, Error)>,
pub rejected_directories: Vec<(DirEntry, Error)>,
}
pub(crate) fn validate(
workload: &mut Workload,
dest_caps: &FilesystemCapabilities,
allow_filesystem_integrity_risk: bool,
) -> Result<ValidationOutcome> {
if dest_caps.write_integrity_risk && !allow_filesystem_integrity_risk {
return Err(Error::FilesystemIntegrityRisk {
filesystem: dest_caps.name.clone(),
});
}
let mut outcome = ValidationOutcome::default();
if !dest_caps.case_sensitive {
reject_case_collisions(workload, &mut outcome);
}
retain_entries(
&mut workload.small,
dest_caps,
&mut outcome.rejected_entries,
);
retain_entries(
&mut workload.large,
dest_caps,
&mut outcome.rejected_entries,
);
retain_directories(
&mut workload.directories,
dest_caps,
&mut outcome.rejected_directories,
);
Ok(outcome)
}
fn collision_key(relative_path: &Path) -> String {
normalize_for_comparison(relative_path)
.to_string_lossy()
.to_lowercase()
}
fn reject_case_collisions(workload: &mut Workload, outcome: &mut ValidationOutcome) {
let mut groups: HashMap<String, Vec<PathBuf>> = HashMap::new();
for entry in workload.small.iter().chain(&workload.large) {
groups
.entry(collision_key(&entry.relative_path))
.or_default()
.push(entry.relative_path.clone());
}
for dir in &workload.directories {
groups
.entry(collision_key(&dir.relative_path))
.or_default()
.push(dir.relative_path.clone());
}
let colliding_keys: HashSet<String> = groups
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.map(|(key, _)| key)
.collect();
if colliding_keys.is_empty() {
return;
}
let mut siblings: HashMap<String, Vec<PathBuf>> = HashMap::new();
for entry in workload.small.iter().chain(&workload.large) {
let key = collision_key(&entry.relative_path);
if colliding_keys.contains(&key) {
siblings
.entry(key)
.or_default()
.push(entry.relative_path.clone());
}
}
for dir in &workload.directories {
let key = collision_key(&dir.relative_path);
if colliding_keys.contains(&key) {
siblings
.entry(key)
.or_default()
.push(dir.relative_path.clone());
}
}
let other_for = |path: &Path, key: &str| -> PathBuf {
siblings[key]
.iter()
.find(|candidate| candidate.as_path() != path)
.cloned()
.unwrap_or_else(|| path.to_path_buf())
};
for group in [&mut workload.small, &mut workload.large] {
let mut i = 0;
while i < group.len() {
let key = collision_key(&group[i].relative_path);
if colliding_keys.contains(&key) {
let entry = group.remove(i);
let other = other_for(&entry.relative_path, &key);
let error = Error::CaseCollision {
path: entry.relative_path.clone(),
other,
};
outcome.rejected_entries.push((entry, error));
} else {
i += 1;
}
}
}
let mut i = 0;
while i < workload.directories.len() {
let key = collision_key(&workload.directories[i].relative_path);
if colliding_keys.contains(&key) {
let dir = workload.directories.remove(i);
let other = other_for(&dir.relative_path, &key);
let error = Error::CaseCollision {
path: dir.relative_path.clone(),
other,
};
outcome.rejected_directories.push((dir, error));
} else {
i += 1;
}
}
}
fn retain_entries(
entries: &mut Vec<Entry>,
dest_caps: &FilesystemCapabilities,
rejected: &mut Vec<(Entry, Error)>,
) {
let mut i = 0;
while i < entries.len() {
match entry_violation(&entries[i], dest_caps) {
Some(err) => rejected.push((entries.remove(i), err)),
None => i += 1,
}
}
}
fn entry_violation(entry: &Entry, dest_caps: &FilesystemCapabilities) -> Option<Error> {
if let Some(max) = dest_caps.max_file_size {
if entry.size > max {
return Some(Error::FileTooLargeForDest {
path: entry.relative_path.clone(),
size: entry.size,
max,
});
}
}
if dest_caps.windows_naming_rules {
if let Some(err) = check_windows_naming(&entry.relative_path) {
return Some(err);
}
}
None
}
fn retain_directories(
directories: &mut Vec<DirEntry>,
dest_caps: &FilesystemCapabilities,
rejected: &mut Vec<(DirEntry, Error)>,
) {
if !dest_caps.windows_naming_rules {
return;
}
let mut i = 0;
while i < directories.len() {
match check_windows_naming(&directories[i].relative_path) {
Some(err) => rejected.push((directories.remove(i), err)),
None => i += 1,
}
}
}
const RESERVED_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
const RESERVED_BASE_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
fn check_windows_naming(relative_path: &Path) -> Option<Error> {
for component in relative_path.components() {
let std::path::Component::Normal(os_str) = component else {
continue;
};
let name = os_str.to_string_lossy();
let has_reserved_char = name
.chars()
.any(|c| RESERVED_CHARS.contains(&c) || (c as u32) < 0x20);
let has_trailing_dot_or_space = name.ends_with('.') || name.ends_with(' ');
let base = name.split('.').next().unwrap_or(&name);
let is_reserved_name = RESERVED_BASE_NAMES
.iter()
.any(|reserved| reserved.eq_ignore_ascii_case(base));
if has_reserved_char || has_trailing_dot_or_space || is_reserved_name {
return Some(Error::ReservedName {
path: relative_path.to_path_buf(),
});
}
}
None
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
fn caps(overrides: impl FnOnce(&mut FilesystemCapabilities)) -> FilesystemCapabilities {
let mut caps = FilesystemCapabilities {
name: "test".to_string(),
case_sensitive: true,
max_file_size: None,
windows_naming_rules: false,
timestamp_granularity: Duration::ZERO,
write_integrity_risk: false,
};
overrides(&mut caps);
caps
}
fn entry(relative_path: &str, size: u64) -> Entry {
Entry {
path: PathBuf::from(relative_path),
relative_path: PathBuf::from(relative_path),
size,
modified: None,
}
}
fn dir(relative_path: &str) -> DirEntry {
DirEntry {
path: PathBuf::from(relative_path),
relative_path: PathBuf::from(relative_path),
mode: None,
}
}
#[test]
fn a_valid_workload_is_untouched() {
let mut workload = Workload {
small: vec![entry("a.txt", 10)],
large: vec![entry("nested/big.bin", 1000)],
directories: vec![dir(""), dir("nested")],
};
let dest_caps = caps(|_| {});
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(outcome.rejected_entries.is_empty());
assert!(outcome.rejected_directories.is_empty());
assert_eq!(workload.small.len(), 1);
assert_eq!(workload.large.len(), 1);
assert_eq!(workload.directories.len(), 2);
}
#[test]
fn write_integrity_risk_returns_err_before_touching_the_workload() {
let mut workload = Workload {
small: vec![entry("a.txt", 10)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| {
c.name = "exfat".to_string();
c.write_integrity_risk = true;
});
let result = validate(&mut workload, &dest_caps, false);
assert!(
matches!(result, Err(Error::FilesystemIntegrityRisk { filesystem }) if filesystem == "exfat")
);
assert_eq!(
workload.small.len(),
1,
"workload must be untouched when this short-circuits"
);
}
#[test]
fn allow_filesystem_integrity_risk_bypasses_the_check() {
let mut workload = Workload {
small: vec![entry("a.txt", 10)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| {
c.name = "exfat".to_string();
c.write_integrity_risk = true;
});
let outcome = validate(&mut workload, &dest_caps, true).unwrap();
assert!(outcome.rejected_entries.is_empty());
assert_eq!(
workload.small.len(),
1,
"the entry should proceed normally once the risk is acknowledged"
);
}
#[test]
fn case_sensitive_destination_never_flags_collisions() {
let mut workload = Workload {
small: vec![entry("Report.txt", 1), entry("report.txt", 1)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.case_sensitive = true);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(outcome.rejected_entries.is_empty());
assert_eq!(workload.small.len(), 2);
}
#[test]
fn case_insensitive_destination_rejects_both_sides_of_a_collision() {
let mut workload = Workload {
small: vec![entry("Report.txt", 1), entry("report.txt", 1)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.case_sensitive = false);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(
workload.small.is_empty(),
"both colliding entries should be removed, not just one"
);
assert_eq!(outcome.rejected_entries.len(), 2);
for (_, err) in &outcome.rejected_entries {
assert!(matches!(err, Error::CaseCollision { .. }));
}
}
#[test]
fn a_file_and_a_directory_differing_only_by_case_collide() {
let mut workload = Workload {
small: vec![entry("Assets/readme.txt", 1)],
large: vec![],
directories: vec![dir("Assets"), dir("assets")],
};
let dest_caps = caps(|c| c.case_sensitive = false);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert_eq!(outcome.rejected_directories.len(), 2);
assert!(workload.directories.is_empty());
assert_eq!(workload.small.len(), 1);
}
#[test]
fn normalization_differences_are_treated_as_the_same_name() {
use unicode_normalization::UnicodeNormalization;
let nfc: String = "café.txt".nfc().collect();
let nfd: String = "café.txt".nfd().collect();
assert_ne!(
nfc.as_bytes(),
nfd.as_bytes(),
"test setup: these must start out byte-different"
);
let mut workload = Workload {
small: vec![entry(&nfc, 1), entry(&nfd, 1)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.case_sensitive = false);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(workload.small.is_empty());
assert_eq!(outcome.rejected_entries.len(), 2);
}
#[test]
fn non_colliding_entries_survive_alongside_a_collision() {
let mut workload = Workload {
small: vec![
entry("Report.txt", 1),
entry("report.txt", 1),
entry("unrelated.txt", 1),
],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.case_sensitive = false);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert_eq!(outcome.rejected_entries.len(), 2);
assert_eq!(workload.small.len(), 1);
assert_eq!(
workload.small[0].relative_path,
PathBuf::from("unrelated.txt")
);
}
#[test]
fn file_over_the_size_limit_is_rejected() {
let mut workload = Workload {
small: vec![],
large: vec![entry("big.bin", 101)],
directories: vec![],
};
let dest_caps = caps(|c| c.max_file_size = Some(100));
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(workload.large.is_empty());
assert_eq!(outcome.rejected_entries.len(), 1);
assert!(matches!(
outcome.rejected_entries[0].1,
Error::FileTooLargeForDest {
size: 101,
max: 100,
..
}
));
}
#[test]
fn file_exactly_at_the_size_limit_is_kept() {
let mut workload = Workload {
small: vec![],
large: vec![entry("big.bin", 100)],
directories: vec![],
};
let dest_caps = caps(|c| c.max_file_size = Some(100));
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(outcome.rejected_entries.is_empty());
assert_eq!(workload.large.len(), 1);
}
#[test]
fn no_size_limit_never_rejects_on_size() {
let mut workload = Workload {
small: vec![],
large: vec![entry("huge.bin", u64::MAX)],
directories: vec![],
};
let dest_caps = caps(|c| c.max_file_size = None);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(outcome.rejected_entries.is_empty());
}
#[test]
fn reserved_character_in_a_filename_is_rejected_when_windows_naming_rules_apply() {
let mut workload = Workload {
small: vec![entry("a<b.txt", 1)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.windows_naming_rules = true);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(workload.small.is_empty());
assert!(matches!(
outcome.rejected_entries[0].1,
Error::ReservedName { .. }
));
}
#[test]
fn reserved_characters_are_allowed_when_windows_naming_rules_do_not_apply() {
let mut workload = Workload {
small: vec![entry("a<b.txt", 1)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.windows_naming_rules = false);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(outcome.rejected_entries.is_empty());
assert_eq!(workload.small.len(), 1);
}
#[cfg(not(windows))]
#[test]
fn a_colon_in_a_unix_filename_is_rejected_for_a_windows_destination() {
let mut workload = Workload {
small: vec![entry("a:b.txt", 1)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.windows_naming_rules = true);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(workload.small.is_empty());
assert!(matches!(
outcome.rejected_entries[0].1,
Error::ReservedName { .. }
));
}
#[cfg(unix)]
#[test]
fn a_backslash_in_a_unix_filename_is_rejected_for_a_windows_destination() {
let mut workload = Workload {
small: vec![entry("a\\b.txt", 1)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.windows_naming_rules = true);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(workload.small.is_empty());
assert!(matches!(
outcome.rejected_entries[0].1,
Error::ReservedName { .. }
));
}
#[test]
fn reserved_device_name_is_rejected_case_insensitively_regardless_of_extension() {
let dest_caps = caps(|c| c.windows_naming_rules = true);
for name in [
"CON", "con.txt", "NUL", "nul.log", "COM1", "com1.dat", "AUX",
] {
let mut workload = Workload {
small: vec![entry(name, 1)],
large: vec![],
directories: vec![],
};
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(
workload.small.is_empty(),
"{name} should have been rejected"
);
assert!(matches!(
outcome.rejected_entries[0].1,
Error::ReservedName { .. }
));
}
}
#[test]
fn a_name_that_merely_contains_a_reserved_word_is_not_rejected() {
let mut workload = Workload {
small: vec![entry("console.txt", 1)],
large: vec![],
directories: vec![],
};
let dest_caps = caps(|c| c.windows_naming_rules = true);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(outcome.rejected_entries.is_empty());
}
#[test]
fn trailing_dot_or_space_is_rejected() {
let dest_caps = caps(|c| c.windows_naming_rules = true);
for name in ["trailing.", "trailing "] {
let mut workload = Workload {
small: vec![entry(name, 1)],
large: vec![],
directories: vec![],
};
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(
workload.small.is_empty(),
"{name:?} should have been rejected"
);
assert!(matches!(
outcome.rejected_entries[0].1,
Error::ReservedName { .. }
));
}
}
#[test]
fn reserved_directory_name_is_rejected() {
let mut workload = Workload {
small: vec![],
large: vec![],
directories: vec![dir("CON")],
};
let dest_caps = caps(|c| c.windows_naming_rules = true);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(workload.directories.is_empty());
assert!(matches!(
outcome.rejected_directories[0].1,
Error::ReservedName { .. }
));
}
#[test]
fn the_scanned_root_with_empty_relative_path_is_never_rejected() {
let mut workload = Workload {
small: vec![],
large: vec![],
directories: vec![dir("")],
};
let dest_caps = caps(|c| c.windows_naming_rules = true);
let outcome = validate(&mut workload, &dest_caps, false).unwrap();
assert!(outcome.rejected_directories.is_empty());
assert_eq!(workload.directories.len(), 1);
}
}