use anyhow::{Context, Result};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_safe: bool,
pub error_message: Option<String>,
}
impl ValidationResult {
#[must_use]
pub fn safe() -> Self {
Self {
is_safe: true,
error_message: None,
}
}
#[must_use]
pub fn unsafe_with(message: String) -> Self {
Self {
is_safe: false,
error_message: Some(message),
}
}
}
#[must_use]
pub fn contains_git_repo(path: &Path) -> bool {
if path.is_dir() && path.join(".git").exists() {
return true;
}
let mut current = if path.is_dir() {
Some(path)
} else {
path.parent()
};
while let Some(dir) = current {
if dir.join(".git").exists() {
return true;
}
current = dir.parent();
}
false
}
pub fn contains_nested_git_repo(path: &Path) -> Result<bool> {
if !path.is_dir() {
return Ok(false);
}
if path.join(".git").exists() {
return Ok(true);
}
const MAX_DEPTH: usize = 10;
fn check_recursive(dir: &Path, depth: usize) -> Result<bool> {
if depth > MAX_DEPTH {
return Ok(false); }
if dir.join(".git").exists() {
return Ok(true);
}
let entries =
std::fs::read_dir(dir).with_context(|| format!("Failed to read directory: {dir:?}"))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if path.file_name().and_then(|n| n.to_str()) == Some(".git") {
continue;
}
if check_recursive(&path, depth + 1)? {
return Ok(true);
}
}
}
Ok(false)
}
check_recursive(path, 0)
}
#[must_use]
pub fn is_file_inside_synced_directory(file_path: &str, synced_files: &HashSet<String>) -> bool {
let normalized = file_path.strip_prefix("./").unwrap_or(file_path);
let path = PathBuf::from(normalized);
let mut current = path.parent();
while let Some(parent) = current {
let parent_str = parent.to_string_lossy().to_string();
if synced_files.contains(&parent_str) {
return true;
}
if !parent_str.starts_with('.') {
let with_dot = format!(".{parent_str}");
if synced_files.contains(&with_dot) {
return true;
}
}
if parent_str.starts_with('.') && parent_str.len() > 1 {
let without_dot = parent_str[1..].to_string();
if synced_files.contains(&without_dot) {
return true;
}
}
current = parent.parent();
}
false
}
#[must_use]
pub fn directory_contains_synced_files(dir_path: &str, synced_files: &HashSet<String>) -> bool {
let normalized = dir_path.strip_prefix("./").unwrap_or(dir_path);
let dir_path_buf = PathBuf::from(normalized);
for synced_file in synced_files {
let synced_path = PathBuf::from(synced_file);
if synced_path.starts_with(&dir_path_buf) && synced_path != dir_path_buf {
return true;
}
let dir_with_dot = if normalized.starts_with('.') {
normalized.to_string()
} else {
format!(".{normalized}")
};
let dir_without_dot = if normalized.starts_with('.') && normalized.len() > 1 {
&normalized[1..]
} else {
normalized
};
let synced_str = synced_file.as_str();
if synced_str.starts_with(&dir_with_dot) && synced_str != dir_with_dot {
return true;
}
if synced_str.starts_with(dir_without_dot) && synced_str != dir_without_dot {
return true;
}
}
false
}
#[derive(Debug, Clone)]
pub enum SymlinkIssue {
Broken {
symlink_path: PathBuf,
target_path: PathBuf,
},
Circular {
symlink_path: PathBuf,
target_path: PathBuf,
},
External {
symlink_path: PathBuf,
target_path: PathBuf,
},
LargeDirectory {
symlink_path: PathBuf,
target_path: PathBuf,
size_bytes: u64,
},
}
impl std::fmt::Display for SymlinkIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SymlinkIssue::Broken {
symlink_path,
target_path,
} => {
write!(
f,
"Broken symlink: '{}' -> '{}' (target does not exist)",
symlink_path.display(),
target_path.display()
)
}
SymlinkIssue::Circular {
symlink_path,
target_path,
} => {
write!(
f,
"Circular symlink: '{}' -> '{}' (would cause infinite recursion)",
symlink_path.display(),
target_path.display()
)
}
SymlinkIssue::External {
symlink_path,
target_path,
} => {
write!(
f,
"External symlink: '{}' -> '{}' (points outside the directory)",
symlink_path.display(),
target_path.display()
)
}
SymlinkIssue::LargeDirectory {
symlink_path,
target_path,
size_bytes,
} => {
let size_mb = *size_bytes / (1024 * 1024);
write!(
f,
"Large directory symlink: '{}' -> '{}' ({} MB)",
symlink_path.display(),
target_path.display(),
size_mb
)
}
}
}
}
#[derive(Debug, Clone)]
pub struct SymlinkValidationResult {
pub is_safe: bool,
pub issues: Vec<SymlinkIssue>,
}
impl SymlinkValidationResult {
#[must_use]
pub fn safe() -> Self {
Self {
is_safe: true,
issues: Vec::new(),
}
}
#[must_use]
pub fn unsafe_with(issues: Vec<SymlinkIssue>) -> Self {
Self {
is_safe: false,
issues,
}
}
}
const MAX_DIRECTORY_SIZE_BYTES: u64 = 100 * 1024 * 1024;
const MAX_VALIDATION_DEPTH: usize = 50;
pub fn validate_directory_symlinks(source_dir: &Path) -> Result<SymlinkValidationResult> {
if !source_dir.is_dir() {
return Ok(SymlinkValidationResult::safe());
}
let source_dir = source_dir
.canonicalize()
.with_context(|| format!("Failed to canonicalize source directory: {source_dir:?}"))?;
info!(
"Validating symlinks in directory: {:?}",
source_dir.display()
);
let mut issues = Vec::new();
let mut visited_paths: HashSet<PathBuf> = HashSet::new();
validate_symlinks_recursive(&source_dir, &source_dir, &mut issues, &mut visited_paths, 0)?;
if issues.is_empty() {
debug!("No symlink issues found in {:?}", source_dir);
Ok(SymlinkValidationResult::safe())
} else {
warn!(
"Found {} symlink issue(s) in {:?}",
issues.len(),
source_dir
);
for issue in &issues {
warn!(" - {}", issue);
}
Ok(SymlinkValidationResult::unsafe_with(issues))
}
}
fn validate_symlinks_recursive(
current_dir: &Path,
root_dir: &Path,
issues: &mut Vec<SymlinkIssue>,
visited_paths: &mut HashSet<PathBuf>,
depth: usize,
) -> Result<()> {
if depth > MAX_VALIDATION_DEPTH {
debug!(
"Reached max validation depth ({}) at {:?}",
MAX_VALIDATION_DEPTH, current_dir
);
return Ok(());
}
let canonical_current = current_dir
.canonicalize()
.unwrap_or_else(|_| current_dir.to_path_buf());
if !visited_paths.insert(canonical_current.clone()) {
debug!("Already visited {:?}, skipping", current_dir);
return Ok(());
}
let entries = match fs::read_dir(current_dir) {
Ok(entries) => entries,
Err(e) => {
warn!("Failed to read directory {:?}: {}", current_dir, e);
return Ok(());
}
};
for entry in entries {
let entry = entry?;
let path = entry.path();
let metadata = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(e) => {
warn!("Failed to get metadata for {:?}: {}", path, e);
continue;
}
};
if metadata.file_type().is_symlink() {
validate_single_symlink(&path, root_dir, issues)?;
} else if metadata.is_dir() {
validate_symlinks_recursive(&path, root_dir, issues, visited_paths, depth + 1)?;
}
}
Ok(())
}
fn validate_single_symlink(
symlink_path: &Path,
root_dir: &Path,
issues: &mut Vec<SymlinkIssue>,
) -> Result<()> {
debug!("Validating symlink: {:?}", symlink_path);
let target = match fs::read_link(symlink_path) {
Ok(t) => t,
Err(e) => {
warn!("Failed to read symlink {:?}: {}", symlink_path, e);
return Ok(());
}
};
let resolved_target = if target.is_relative() {
symlink_path
.parent()
.map_or_else(|| target.clone(), |p| p.join(&target))
} else {
target.clone()
};
if !resolved_target.exists() {
issues.push(SymlinkIssue::Broken {
symlink_path: symlink_path.to_path_buf(),
target_path: target,
});
return Ok(());
}
let canonical_target = match resolved_target.canonicalize() {
Ok(p) => p,
Err(e) => {
warn!(
"Failed to canonicalize symlink target {:?}: {}",
resolved_target, e
);
return Ok(());
}
};
let symlink_parent = symlink_path.parent().and_then(|p| p.canonicalize().ok());
if let Some(parent) = &symlink_parent {
if canonical_target == *parent || parent.starts_with(&canonical_target) {
issues.push(SymlinkIssue::Circular {
symlink_path: symlink_path.to_path_buf(),
target_path: canonical_target,
});
return Ok(());
}
}
if canonical_target == root_dir || root_dir.starts_with(&canonical_target) {
issues.push(SymlinkIssue::Circular {
symlink_path: symlink_path.to_path_buf(),
target_path: canonical_target,
});
return Ok(());
}
if !canonical_target.starts_with(root_dir) {
if canonical_target.is_dir() {
match calculate_directory_size(&canonical_target) {
Ok(size) if size > MAX_DIRECTORY_SIZE_BYTES => {
issues.push(SymlinkIssue::LargeDirectory {
symlink_path: symlink_path.to_path_buf(),
target_path: canonical_target,
size_bytes: size,
});
}
Ok(_) => {
issues.push(SymlinkIssue::External {
symlink_path: symlink_path.to_path_buf(),
target_path: canonical_target,
});
}
Err(e) => {
warn!("Failed to calculate size of {:?}: {}", canonical_target, e);
issues.push(SymlinkIssue::External {
symlink_path: symlink_path.to_path_buf(),
target_path: canonical_target,
});
}
}
}
}
Ok(())
}
fn calculate_directory_size(dir: &Path) -> Result<u64> {
let mut total_size: u64 = 0;
fn calculate_recursive(dir: &Path, total: &mut u64, max: u64) -> Result<bool> {
if *total > max {
return Ok(false); }
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let metadata = entry.metadata()?;
if metadata.is_file() {
*total += metadata.len();
if *total > max {
return Ok(false);
}
} else if metadata.is_dir() && !calculate_recursive(&entry.path(), total, max)? {
return Ok(false);
}
}
Ok(true)
}
let _ = calculate_recursive(dir, &mut total_size, MAX_DIRECTORY_SIZE_BYTES * 2);
Ok(total_size)
}
pub fn validate_before_sync(
relative_path: &str,
full_path: &Path,
synced_files: &HashSet<String>,
repo_path: &Path,
) -> ValidationResult {
debug!(
"Validating path before sync: {} ({:?})",
relative_path, full_path
);
let normalized = relative_path.strip_prefix("./").unwrap_or(relative_path);
if synced_files.contains(normalized) {
return ValidationResult::unsafe_with(format!(
"File or directory is already synced: {normalized}"
));
}
if is_file_inside_synced_directory(normalized, synced_files) {
return ValidationResult::unsafe_with(format!(
"Cannot sync '{normalized}': it is already inside a synced directory.\n\n\
If you want to sync this file, remove the parent directory from sync first."
));
}
if full_path.is_dir() && directory_contains_synced_files(normalized, synced_files) {
return ValidationResult::unsafe_with(format!(
"Cannot sync directory '{normalized}': it contains files that are already synced.\n\n\
If you want to sync this directory, remove the individual files from sync first."
));
}
if contains_git_repo(full_path) {
return ValidationResult::unsafe_with(format!(
"Cannot sync a git repository. Path contains a .git directory: {}",
full_path.display()
));
}
if full_path.is_dir() {
match contains_nested_git_repo(full_path) {
Ok(true) => {
return ValidationResult::unsafe_with(format!(
"Cannot sync directory '{normalized}': it contains a nested git repository.\n\n\
You cannot have a git repository inside a git repository."
));
}
Ok(false) => {}
Err(e) => {
warn!("Failed to check for nested git repos: {}", e);
}
}
match validate_directory_symlinks(full_path) {
Ok(result) if !result.is_safe => {
let issue_descriptions: Vec<String> =
result.issues.iter().map(|i| format!(" • {i}")).collect();
return ValidationResult::unsafe_with(format!(
"Cannot sync directory '{}': it contains problematic symlinks.\n\n\
Issues found:\n{}\n\n\
Please resolve these symlink issues before syncing.",
normalized,
issue_descriptions.join("\n")
));
}
Ok(_) => {}
Err(e) => {
warn!("Failed to validate symlinks: {}", e);
}
}
}
let (is_safe, reason) = crate::utils::is_safe_to_add(full_path, repo_path);
if !is_safe {
return ValidationResult::unsafe_with(
reason.unwrap_or_else(|| "Path is not safe to add".to_string()),
);
}
ValidationResult::safe()
}
pub fn validate_symlink_creation(
original_source: &Path,
_symlink_source: &Path,
target: &Path,
) -> Result<ValidationResult> {
if !original_source.exists() {
return Ok(ValidationResult::unsafe_with(format!(
"Source file does not exist: {original_source:?}"
)));
}
if let Some(parent) = target.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent directory: {parent:?}"))?;
if parent.read_dir()?.next().is_none() {
let _ = std::fs::remove_dir(parent);
}
} else if !parent.is_dir() {
return Ok(ValidationResult::unsafe_with(format!(
"Target parent exists but is not a directory: {parent:?}"
)));
}
}
if let Some(parent) = target.parent() {
if parent.exists() {
let test_file = parent.join(".dotstate_write_test");
if std::fs::File::create(&test_file).is_ok() {
let _ = std::fs::remove_file(&test_file);
} else {
return Ok(ValidationResult::unsafe_with(format!(
"Cannot write to target location: {parent:?}"
)));
}
}
}
Ok(ValidationResult::safe())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::TempDir;
#[test]
fn test_is_file_inside_synced_directory() {
let mut synced = HashSet::new();
synced.insert(".nvim".to_string());
assert!(is_file_inside_synced_directory(".nvim/init.lua", &synced));
assert!(is_file_inside_synced_directory("nvim/init.lua", &synced));
assert!(!is_file_inside_synced_directory(".zshrc", &synced));
}
#[test]
fn test_directory_contains_synced_files() {
let mut synced = HashSet::new();
synced.insert(".nvim/init.lua".to_string());
assert!(directory_contains_synced_files(".nvim", &synced));
assert!(!directory_contains_synced_files(".config", &synced));
}
#[test]
fn test_contains_git_repo() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
assert!(!contains_git_repo(&test_dir));
std::fs::create_dir_all(test_dir.join(".git")).unwrap();
assert!(contains_git_repo(&test_dir));
let nested_file = test_dir.join("file.txt");
File::create(&nested_file).unwrap();
assert!(contains_git_repo(&nested_file));
}
#[test]
fn test_validate_before_sync_file_inside_synced_dir() {
let temp_dir = TempDir::new().unwrap();
let mut synced = HashSet::new();
synced.insert(".nvim".to_string());
let file_path = temp_dir.path().join(".nvim").join("init.lua");
std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
File::create(&file_path).unwrap();
let result = validate_before_sync(".nvim/init.lua", &file_path, &synced, temp_dir.path());
assert!(!result.is_safe);
assert!(result.error_message.is_some());
assert!(result
.error_message
.unwrap()
.contains("already inside a synced directory"));
}
#[test]
fn test_validate_before_sync_dir_contains_synced_files() {
let temp_dir = TempDir::new().unwrap();
let mut synced = HashSet::new();
synced.insert(".nvim/init.lua".to_string());
let dir_path = temp_dir.path().join(".nvim");
std::fs::create_dir_all(&dir_path).unwrap();
let result = validate_before_sync(".nvim", &dir_path, &synced, temp_dir.path());
assert!(!result.is_safe);
assert!(result.error_message.is_some());
assert!(result
.error_message
.unwrap()
.contains("contains files that are already synced"));
}
#[test]
fn test_path_normalization_edge_cases() {
let mut synced = HashSet::new();
synced.insert(".nvim".to_string());
assert!(is_file_inside_synced_directory("./nvim/init.lua", &synced));
assert!(is_file_inside_synced_directory("nvim/init.lua", &synced));
assert!(is_file_inside_synced_directory(".nvim/init.lua", &synced));
assert!(is_file_inside_synced_directory(
".nvim/config/init.lua",
&synced
));
assert!(is_file_inside_synced_directory(
"nvim/config/init.lua",
&synced
));
let mut synced_no_dot = HashSet::new();
synced_no_dot.insert("nvim".to_string());
assert!(is_file_inside_synced_directory(
".nvim/init.lua",
&synced_no_dot
));
assert!(is_file_inside_synced_directory(
"nvim/init.lua",
&synced_no_dot
));
}
#[test]
fn test_deeply_nested_conflicts() {
let mut synced = HashSet::new();
synced.insert(".config".to_string());
assert!(is_file_inside_synced_directory(
".config/nvim/init.lua",
&synced
));
assert!(is_file_inside_synced_directory(
".config/nvim/lua/plugins/init.lua",
&synced
));
assert!(is_file_inside_synced_directory(
"config/nvim/init.lua",
&synced
));
}
#[test]
fn test_multiple_synced_directories() {
let mut synced = HashSet::new();
synced.insert(".config".to_string());
synced.insert(".local".to_string());
synced.insert(".zshrc".to_string());
assert!(is_file_inside_synced_directory(".config/file", &synced));
assert!(is_file_inside_synced_directory(".local/file", &synced));
assert!(!is_file_inside_synced_directory(".zshrc_backup", &synced));
}
#[test]
fn test_directory_contains_multiple_synced_files() {
let mut synced = HashSet::new();
synced.insert(".nvim/init.lua".to_string());
synced.insert(".nvim/lua/config.lua".to_string());
synced.insert(".nvim/after/plugin/colors.lua".to_string());
assert!(directory_contains_synced_files(".nvim", &synced));
assert!(directory_contains_synced_files("nvim", &synced)); assert!(!directory_contains_synced_files(".config", &synced));
}
#[test]
fn test_git_repo_in_parent_directory() {
let temp_dir = TempDir::new().unwrap();
let parent_dir = temp_dir.path().join("parent");
std::fs::create_dir_all(&parent_dir).unwrap();
std::fs::create_dir_all(parent_dir.join(".git")).unwrap();
let file_in_repo = parent_dir.join("child").join("file.txt");
std::fs::create_dir_all(file_in_repo.parent().unwrap()).unwrap();
File::create(&file_in_repo).unwrap();
assert!(contains_git_repo(&file_in_repo));
assert!(contains_git_repo(&parent_dir.join("child")));
}
#[test]
fn test_nested_git_repos() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let nested_dir = test_dir.join("nested");
std::fs::create_dir_all(&nested_dir).unwrap();
std::fs::create_dir_all(nested_dir.join(".git")).unwrap();
let result = contains_nested_git_repo(&test_dir).unwrap();
assert!(result);
}
#[test]
fn test_nested_git_repos_deep() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("level1");
std::fs::create_dir_all(&test_dir).unwrap();
let level2 = test_dir.join("level2");
std::fs::create_dir_all(&level2).unwrap();
let level3 = level2.join("level3");
std::fs::create_dir_all(&level3).unwrap();
std::fs::create_dir_all(level3.join(".git")).unwrap();
let result = contains_nested_git_repo(&test_dir).unwrap();
assert!(result);
}
#[test]
fn test_git_repo_max_depth_limit() {
let temp_dir = TempDir::new().unwrap();
let mut current = temp_dir.path().to_path_buf();
for i in 0..15 {
current = current.join(format!("level{i}"));
std::fs::create_dir_all(¤t).unwrap();
}
let result = contains_nested_git_repo(temp_dir.path()).unwrap();
assert!(!result); }
#[test]
fn test_validate_symlink_creation_source_missing() {
let temp_dir = TempDir::new().unwrap();
let original_source = temp_dir.path().join("nonexistent");
let symlink_source = temp_dir.path().join("repo").join("source.txt");
let target = temp_dir.path().join("target");
let result = validate_symlink_creation(&original_source, &symlink_source, &target).unwrap();
assert!(!result.is_safe);
assert!(result.error_message.unwrap().contains("does not exist"));
}
#[test]
fn test_validate_symlink_creation_parent_not_dir() {
let temp_dir = TempDir::new().unwrap();
let original_source = temp_dir.path().join("source.txt");
File::create(&original_source).unwrap();
let symlink_source = temp_dir.path().join("repo").join("source.txt");
let parent_file = temp_dir.path().join("parent");
File::create(&parent_file).unwrap();
let target = parent_file.join("target");
let result = validate_symlink_creation(&original_source, &symlink_source, &target).unwrap();
assert!(!result.is_safe);
assert!(result.error_message.unwrap().contains("not a directory"));
}
#[test]
fn test_validate_symlink_creation_success() {
let temp_dir = TempDir::new().unwrap();
let original_source = temp_dir.path().join("source.txt");
File::create(&original_source).unwrap();
let symlink_source = temp_dir.path().join("repo").join("source.txt");
let target = temp_dir.path().join("subdir").join("target");
let result = validate_symlink_creation(&original_source, &symlink_source, &target).unwrap();
assert!(result.is_safe);
}
#[test]
fn test_complex_nested_scenario() {
let temp_dir = TempDir::new().unwrap();
let mut synced = HashSet::new();
synced.insert(".config/nvim".to_string());
let file_path = temp_dir
.path()
.join(".config")
.join("nvim")
.join("init.lua");
std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
File::create(&file_path).unwrap();
let result = validate_before_sync(
".config/nvim/init.lua",
&file_path,
&synced,
temp_dir.path(),
);
assert!(!result.is_safe);
}
#[test]
fn test_reverse_scenario_file_then_directory() {
let temp_dir = TempDir::new().unwrap();
let mut synced = HashSet::new();
synced.insert(".nvim/init.lua".to_string());
let dir_path = temp_dir.path().join(".nvim");
std::fs::create_dir_all(&dir_path).unwrap();
let result = validate_before_sync(".nvim", &dir_path, &synced, temp_dir.path());
assert!(!result.is_safe);
assert!(result
.error_message
.unwrap()
.contains("contains files that are already synced"));
}
#[test]
fn test_sibling_files_same_directory() {
let mut synced = HashSet::new();
synced.insert(".nvim/init.lua".to_string());
assert!(!is_file_inside_synced_directory(
".nvim/config.lua",
&synced
));
assert!(!is_file_inside_synced_directory(
".nvim/colors.vim",
&synced
));
}
#[test]
fn test_empty_paths() {
let synced = HashSet::new();
assert!(!is_file_inside_synced_directory("", &synced));
assert!(!is_file_inside_synced_directory(".", &synced));
assert!(!is_file_inside_synced_directory("..", &synced));
}
#[test]
fn test_path_with_dot_components() {
let mut synced = HashSet::new();
synced.insert(".config".to_string());
assert!(is_file_inside_synced_directory(
".config/../.config/file",
&synced
));
}
#[test]
fn test_unicode_and_special_characters() {
let mut synced = HashSet::new();
synced.insert(".config".to_string());
assert!(is_file_inside_synced_directory(".config/测试.txt", &synced));
assert!(is_file_inside_synced_directory(".config/файл.txt", &synced));
}
#[test]
fn test_very_long_paths() {
let mut synced = HashSet::new();
synced.insert(".config".to_string());
let long_path = format!(".config/{}", "a/".repeat(50));
assert!(is_file_inside_synced_directory(&long_path, &synced));
}
#[test]
fn test_case_sensitivity() {
let mut synced = HashSet::new();
synced.insert(".Config".to_string());
assert!(is_file_inside_synced_directory(".Config/file", &synced));
assert!(!is_file_inside_synced_directory(".config/file", &synced));
}
#[test]
fn test_already_synced_exact_match() {
let temp_dir = TempDir::new().unwrap();
let mut synced = HashSet::new();
synced.insert(".zshrc".to_string());
let file_path = temp_dir.path().join(".zshrc");
File::create(&file_path).unwrap();
let result = validate_before_sync(".zshrc", &file_path, &synced, temp_dir.path());
assert!(!result.is_safe);
assert!(result.error_message.unwrap().contains("already synced"));
}
#[test]
fn test_git_repo_as_file_not_directory() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
File::create(test_dir.join(".git")).unwrap();
assert!(contains_git_repo(&test_dir));
}
#[test]
fn test_multiple_git_repos_in_tree() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("project");
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::create_dir_all(test_dir.join("sub1").join(".git")).unwrap();
std::fs::create_dir_all(test_dir.join("sub2").join(".git")).unwrap();
let result = contains_nested_git_repo(&test_dir).unwrap();
assert!(result);
}
#[test]
fn test_symlink_validation_with_existing_symlink() {
let temp_dir = TempDir::new().unwrap();
let source = temp_dir.path().join("source.txt");
File::create(&source).unwrap();
let target = temp_dir.path().join("target");
#[cfg(unix)]
std::os::unix::fs::symlink(&source, &target).unwrap();
let symlink_source = temp_dir.path().join("repo").join("source.txt");
let result = validate_symlink_creation(&source, &symlink_source, &target).unwrap();
assert!(result.is_safe);
}
#[test]
fn test_directory_with_only_dotfiles() {
let mut synced = HashSet::new();
synced.insert(".config/nvim/.nvimrc".to_string());
assert!(directory_contains_synced_files(".config/nvim", &synced));
}
#[test]
fn test_validate_repo_path_conflicts() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_path).unwrap();
let result = validate_before_sync(
"repo",
&repo_path,
&HashSet::new(),
&repo_path, );
assert!(!result.is_safe);
}
#[test]
fn test_validate_home_directory() {
let temp_dir = TempDir::new().unwrap();
let home = temp_dir.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let _result = validate_before_sync("home", &home, &HashSet::new(), temp_dir.path());
}
#[test]
fn test_concurrent_operations_simulation() {
let temp_dir = TempDir::new().unwrap();
let source = temp_dir.path().join("source.txt");
File::create(&source).unwrap();
let target = temp_dir.path().join("target");
let symlink_source = temp_dir.path().join("repo").join("source.txt");
let validation = validate_symlink_creation(&source, &symlink_source, &target).unwrap();
assert!(validation.is_safe);
std::fs::remove_file(&source).unwrap();
let revalidation = validate_symlink_creation(&source, &symlink_source, &target).unwrap();
assert!(!revalidation.is_safe);
}
#[test]
fn test_nested_symlink_scenarios() {
let mut synced = HashSet::new();
synced.insert(".config".to_string());
assert!(is_file_inside_synced_directory(".config/symlink", &synced));
}
#[test]
fn test_validate_directory_symlinks_no_symlinks() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
File::create(test_dir.join("file1.txt")).unwrap();
File::create(test_dir.join("file2.txt")).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(result.is_safe);
assert!(result.issues.is_empty());
}
#[test]
fn test_validate_directory_symlinks_valid_internal_symlink() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let target = test_dir.join("target.txt");
File::create(&target).unwrap();
let symlink = test_dir.join("link.txt");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(result.is_safe);
assert!(result.issues.is_empty());
}
#[test]
fn test_validate_directory_symlinks_broken_symlink() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let symlink = test_dir.join("broken_link");
#[cfg(unix)]
std::os::unix::fs::symlink("/nonexistent/path/that/does/not/exist", &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(!result.is_safe);
assert_eq!(result.issues.len(), 1);
assert!(matches!(result.issues[0], SymlinkIssue::Broken { .. }));
}
#[test]
fn test_validate_directory_symlinks_circular_to_parent() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let symlink = test_dir.join("parent_link");
#[cfg(unix)]
std::os::unix::fs::symlink("..", &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(!result.is_safe);
assert!(!result.issues.is_empty());
assert!(result
.issues
.iter()
.any(|i| matches!(i, SymlinkIssue::Circular { .. })));
}
#[test]
fn test_validate_directory_symlinks_circular_to_self() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let symlink = test_dir.join("self_link");
#[cfg(unix)]
std::os::unix::fs::symlink(&test_dir, &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(!result.is_safe);
assert!(!result.issues.is_empty());
assert!(result
.issues
.iter()
.any(|i| matches!(i, SymlinkIssue::Circular { .. })));
}
#[test]
fn test_validate_directory_symlinks_external_directory() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let external_dir = temp_dir.path().join("external");
std::fs::create_dir_all(&external_dir).unwrap();
File::create(external_dir.join("file.txt")).unwrap();
let symlink = test_dir.join("external_link");
#[cfg(unix)]
std::os::unix::fs::symlink(&external_dir, &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(!result.is_safe);
assert!(!result.issues.is_empty());
assert!(result
.issues
.iter()
.any(|i| matches!(i, SymlinkIssue::External { .. })));
}
#[test]
fn test_validate_directory_symlinks_external_file_ok() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let external_file = temp_dir.path().join("external.txt");
File::create(&external_file).unwrap();
let symlink = test_dir.join("external_file_link");
#[cfg(unix)]
std::os::unix::fs::symlink(&external_file, &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(result.is_safe);
}
#[test]
fn test_validate_directory_symlinks_nested_directory() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
let nested_dir = test_dir.join("nested");
std::fs::create_dir_all(&nested_dir).unwrap();
let target = nested_dir.join("target.txt");
File::create(&target).unwrap();
let symlink = nested_dir.join("link.txt");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(result.is_safe);
}
#[test]
fn test_validate_directory_symlinks_broken_in_nested() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
let nested_dir = test_dir.join("nested").join("deep");
std::fs::create_dir_all(&nested_dir).unwrap();
let symlink = nested_dir.join("broken");
#[cfg(unix)]
std::os::unix::fs::symlink("/this/path/does/not/exist", &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(!result.is_safe);
assert!(result
.issues
.iter()
.any(|i| matches!(i, SymlinkIssue::Broken { .. })));
}
#[test]
fn test_validate_directory_symlinks_multiple_issues() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let broken = test_dir.join("broken");
#[cfg(unix)]
std::os::unix::fs::symlink("/nonexistent", &broken).unwrap();
let external_dir = temp_dir.path().join("external");
std::fs::create_dir_all(&external_dir).unwrap();
let external = test_dir.join("external");
#[cfg(unix)]
std::os::unix::fs::symlink(&external_dir, &external).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(!result.is_safe);
assert!(result.issues.len() >= 2);
}
#[test]
fn test_validate_directory_symlinks_relative_symlink() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join("test");
std::fs::create_dir_all(&test_dir).unwrap();
let target = test_dir.join("target.txt");
File::create(&target).unwrap();
let symlink = test_dir.join("relative_link");
#[cfg(unix)]
std::os::unix::fs::symlink("target.txt", &symlink).unwrap();
let result = validate_directory_symlinks(&test_dir).unwrap();
assert!(result.is_safe);
}
#[test]
fn test_validate_before_sync_with_broken_symlink() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join(".config");
std::fs::create_dir_all(&test_dir).unwrap();
let symlink = test_dir.join("broken_link");
#[cfg(unix)]
std::os::unix::fs::symlink("/this/does/not/exist", &symlink).unwrap();
let result = validate_before_sync(".config", &test_dir, &HashSet::new(), temp_dir.path());
assert!(!result.is_safe);
assert!(result
.error_message
.unwrap()
.contains("problematic symlinks"));
}
#[test]
fn test_validate_before_sync_with_circular_symlink() {
let temp_dir = TempDir::new().unwrap();
let test_dir = temp_dir.path().join(".config");
std::fs::create_dir_all(&test_dir).unwrap();
let symlink = test_dir.join("circular");
#[cfg(unix)]
std::os::unix::fs::symlink("..", &symlink).unwrap();
let result = validate_before_sync(".config", &test_dir, &HashSet::new(), temp_dir.path());
assert!(!result.is_safe);
assert!(result
.error_message
.unwrap()
.contains("problematic symlinks"));
}
#[test]
fn test_symlink_issue_display() {
let broken = SymlinkIssue::Broken {
symlink_path: PathBuf::from("/test/link"),
target_path: PathBuf::from("/nonexistent"),
};
assert!(broken.to_string().contains("Broken symlink"));
assert!(broken.to_string().contains("does not exist"));
let circular = SymlinkIssue::Circular {
symlink_path: PathBuf::from("/test/link"),
target_path: PathBuf::from("/test"),
};
assert!(circular.to_string().contains("Circular symlink"));
assert!(circular.to_string().contains("infinite recursion"));
let external = SymlinkIssue::External {
symlink_path: PathBuf::from("/test/link"),
target_path: PathBuf::from("/external"),
};
assert!(external.to_string().contains("External symlink"));
assert!(external.to_string().contains("outside the directory"));
let large = SymlinkIssue::LargeDirectory {
symlink_path: PathBuf::from("/test/link"),
target_path: PathBuf::from("/large"),
size_bytes: 200 * 1024 * 1024, };
assert!(large.to_string().contains("Large directory"));
assert!(large.to_string().contains("200 MB"));
}
}