use crate::commands::log;
use crate::expand_path;
use crate::Cli;
use std::fs;
use std::io;
use std::os::unix::fs as unix_fs;
use std::path::{Path, PathBuf};
pub fn detect_circular_symlink_scenario(
link_path: &Path,
actual: &Path,
) -> Option<(PathBuf, PathBuf)> {
let link_absolute = link_path.to_path_buf();
let actual_parent = actual.parent()?;
let link_parent = link_absolute.parent()?;
if actual_parent.is_symlink() {
if let Ok(parent_target) = fs::read_link(actual_parent) {
let parent_target = if parent_target.is_absolute() {
parent_target
} else {
actual_parent
.parent()
.unwrap_or(actual_parent)
.join(&parent_target)
};
if let Ok(parent_target_resolved) = parent_target.canonicalize() {
if parent_target_resolved == link_parent {
return Some((actual_parent.to_path_buf(), link_parent.to_path_buf()));
}
}
}
}
None
}
pub fn would_create_symlink_loop(link_path: &Path, actual: &Path) -> bool {
if link_path.is_symlink() {
if let Ok(existing_target) = fs::read_link(link_path) {
if let Ok(existing_resolved) = existing_target.canonicalize() {
if let Ok(actual_resolved) = actual.canonicalize() {
if existing_resolved == actual_resolved {
return false;
}
}
}
}
}
let link_absolute = link_path.to_path_buf();
let actual_resolved = match actual.canonicalize() {
Ok(p) => p,
Err(_) => return false,
};
let mut symlink_parent = None;
let mut current = match link_absolute.parent() {
Some(p) => p.to_path_buf(),
None => return false,
};
while current.parent() != Some(¤t) {
if current.is_symlink() {
if let Ok(target) = fs::read_link(¤t) {
let target = if target.is_absolute() {
target
} else {
current.parent().unwrap_or(¤t).join(&target)
};
if let Ok(target_resolved) = target.canonicalize() {
symlink_parent = Some((current.clone(), target_resolved));
break;
}
}
}
current = match current.parent() {
Some(p) => p.to_path_buf(),
None => break,
};
}
let (symlink_source, symlink_target) = match symlink_parent {
Some(pair) => pair,
None => return false,
};
if let Ok(rel_from_target) = actual_resolved.strip_prefix(&symlink_target) {
if let Ok(rel_from_source) = link_absolute.strip_prefix(&symlink_source) {
if rel_from_source == rel_from_target {
return true;
}
}
}
false
}
pub fn paths_would_conflict(link_path: &Path, actual: &Path) -> bool {
let link_absolute = link_path.to_path_buf();
let actual_resolved = match actual.canonicalize() {
Ok(p) => p,
Err(_) => return false,
};
let link_resolved = link_absolute
.canonicalize()
.unwrap_or_else(|_| link_absolute.clone());
if link_absolute == actual_resolved || link_resolved == actual_resolved {
return true;
}
if link_absolute.strip_prefix(&actual_resolved).is_ok()
|| link_resolved.strip_prefix(&actual_resolved).is_ok()
{
return true;
}
false
}
pub fn create_symlink(actual_path: &str, link: &str, cli: &Cli) -> Result<(), String> {
let actual = expand_path(actual_path)
.canonicalize()
.map_err(|e| format!("Source path does not exist: {}: {}", actual_path, e))?;
let link_path = expand_path(link);
if let Some(link_parent) = link_path.parent() {
if link_parent.is_symlink() && !actual.is_dir() {
if let Ok(parent_target) = fs::read_link(link_parent) {
let parent_target = if parent_target.is_absolute() {
parent_target
} else {
link_parent
.parent()
.unwrap_or(link_parent)
.join(&parent_target)
};
if let Ok(parent_target_resolved) = parent_target.canonicalize() {
if actual.starts_with(&parent_target_resolved) {
if cli.force {
log(
cli,
"warning",
&format!(
"RISKY: Parent directory {} is a symlink to {}",
link_parent.display(),
parent_target_resolved.display()
),
);
log(
cli,
"info",
&format!(
"Action: Removing parent symlink {} and creating real directory",
link_parent.display()
),
);
log(
cli,
"info",
"Note: The symlink target's contents will NOT be deleted, only the symlink itself is removed"
);
fs::remove_file(link_parent).map_err(|e| e.to_string())?;
fs::create_dir_all(link_parent).map_err(|e| e.to_string())?;
} else if cli.interactive {
log(
cli,
"warning",
&format!(
"Parent directory {} is a symlink to {}",
link_parent.display(),
parent_target_resolved.display()
),
);
print!(
"Remove {} and create real directory? [y/n] ",
link_parent.display()
);
io::Write::flush(&mut io::stdout()).ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
if input.trim().to_lowercase() != "y" {
return Err(
"Would overwrite actual file (parent is symlink)".to_string()
);
}
fs::remove_file(link_parent).map_err(|e| e.to_string())?;
fs::create_dir_all(link_parent).map_err(|e| e.to_string())?;
} else {
log(
cli,
"warning",
&format!(
"Parent directory {} is a symlink to {}",
link_parent.display(),
parent_target_resolved.display()
),
);
return Err(
"Would overwrite actual file (parent is symlink)".to_string()
);
}
}
}
}
}
}
if link_path.exists() || link_path.is_symlink() {
if link_path.is_symlink() {
if let Ok(existing_target) = fs::read_link(&link_path) {
let existing_resolved = expand_path(&existing_target.to_string_lossy());
if let Ok(existing_canon) = existing_resolved.canonicalize() {
if existing_canon == actual {
log(cli, "debug", "Symlink already exists, skipping");
return Ok(());
}
}
}
}
if cli.interactive {
print!("Link {} exists, remove it? [y/n] ", link_path.display());
io::Write::flush(&mut io::stdout()).ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
if input.trim().to_lowercase() != "y" {
return Ok(());
}
} else if !cli.force {
return Err(format!(
"Path exists, use --force or --interactive to overwrite: {}",
link_path.display()
));
}
if cli.dry_run {
log(
cli,
"debug",
&format!("Would remove {}", link_path.display()),
);
} else {
log(cli, "debug", &format!("Removing {}", link_path.display()));
if link_path.is_dir() && !link_path.is_symlink() {
fs::remove_dir_all(&link_path).map_err(|e| e.to_string())?;
} else {
fs::remove_file(&link_path).map_err(|e| e.to_string())?;
}
}
}
if paths_would_conflict(&link_path, &actual) {
log(
cli,
"warning",
&format!(
"Path conflict: {} and {} would conflict!",
link_path.display(),
actual.display()
),
);
return Err("Path conflict detected".to_string());
}
if would_create_symlink_loop(&link_path, &actual) {
log(
cli,
"warning",
&format!(
"Creating symlink {} -> {} would create a loop!",
link_path.display(),
actual.display()
),
);
if actual.is_dir() && cli.interactive {
print!(
"Create real directory at {} instead? [y/n] ",
link_path.display()
);
io::Write::flush(&mut io::stdout()).ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
if input.trim().to_lowercase() == "y" {
if cli.dry_run {
log(
cli,
"debug",
&format!("Would create directory {}", link_path.display()),
);
} else {
fs::create_dir_all(&link_path).map_err(|e| e.to_string())?;
}
return Ok(());
}
}
return Err("Symlink loop detected, skipped".to_string());
}
if let Some((circular_symlink, link_parent)) =
detect_circular_symlink_scenario(&link_path, &actual)
{
log(cli, "warning", "Circular symlink scenario detected!");
log(
cli,
"warning",
&format!(
"Creating {} -> {} when {} -> {}",
link_path.display(),
actual.display(),
circular_symlink.display(),
link_parent.display()
),
);
if cli.interactive {
print!(
"Remove {} and create real directory? [y/n] ",
circular_symlink.display()
);
io::Write::flush(&mut io::stdout()).ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
if input.trim().to_lowercase() != "y" {
return Err("Circular symlink scenario detected, skipped".to_string());
}
if cli.dry_run {
log(
cli,
"info",
&format!("Would remove symlink {}", circular_symlink.display()),
);
} else {
fs::remove_file(&circular_symlink).map_err(|e| e.to_string())?;
fs::create_dir_all(&circular_symlink).map_err(|e| e.to_string())?;
}
} else {
log(
cli,
"warning",
"Skipping this link to prevent circular reference (use -i to fix interactively)",
);
return Err("Circular symlink scenario detected, skipped".to_string());
}
}
if cli.dry_run {
log(
cli,
"info",
&format!(
"Would create symlink: {} -> {}",
link_path.display(),
actual.display()
),
);
} else {
if let Some(parent) = link_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
}
}
unix_fs::symlink(&actual, &link_path)
.map_err(|e| format!("Failed to create symlink: {}", e))?;
log(
cli,
"debug",
&format!(
"Created symlink: {} -> {}",
link_path.display(),
actual.display()
),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs as unix_fs;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn tmpdir(name: &str) -> PathBuf {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("xd_{}_{}_{}", name, std::process::id(), id));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn cleanup(dir: &Path) {
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_paths_would_conflict_same_path() {
let dir = tmpdir("conflict_same");
let file = dir.join("file.txt");
fs::write(&file, "test").unwrap();
assert!(paths_would_conflict(&file, &file));
cleanup(&dir);
}
#[test]
fn test_paths_would_conflict_parent_child() {
let dir = tmpdir("conflict_parent_child");
let parent = dir.join("parent");
let child_dir = parent.join("child");
fs::create_dir_all(&child_dir).unwrap();
let child_file = child_dir.join("file.txt");
fs::write(&child_file, "test").unwrap();
assert!(paths_would_conflict(&child_file, &parent));
cleanup(&dir);
}
#[test]
fn test_paths_would_conflict_no_conflict() {
let dir = tmpdir("conflict_no");
let dir_a = dir.join("dir_a");
let dir_b = dir.join("dir_b");
fs::create_dir_all(&dir_a).unwrap();
fs::create_dir_all(&dir_b).unwrap();
let file_a = dir_a.join("file.txt");
let file_b = dir_b.join("file.txt");
fs::write(&file_a, "test").unwrap();
fs::write(&file_b, "test").unwrap();
assert!(!paths_would_conflict(&file_a, &file_b));
cleanup(&dir);
}
#[test]
fn test_no_loop_simple() {
let dir = tmpdir("loop_simple");
let source = dir.join("source");
let target = dir.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
let link_path = target.join("link.txt");
let actual = source.join("file.txt");
fs::write(&actual, "test").unwrap();
assert!(!would_create_symlink_loop(&link_path, &actual));
cleanup(&dir);
}
#[test]
fn test_no_loop_through_symlink() {
let dir = tmpdir("loop_symlink");
let dotfiles_config = dir.join("dotfiles/.config");
fs::create_dir_all(&dotfiles_config).unwrap();
fs::write(dotfiles_config.join("file.txt"), "test").unwrap();
let config_link = dir.join(".config");
let _ = fs::remove_file(&config_link);
unix_fs::symlink(&dotfiles_config, &config_link).unwrap();
let link_path = dir.join(".config/file.txt");
let actual = dotfiles_config.join("file.txt");
let result = would_create_symlink_loop(&link_path, &actual);
assert!(
result,
"Conservative loop detector flags symlink-inside-symlink"
);
cleanup(&dir);
}
#[test]
fn test_detect_circular_scenario() {
let dir = tmpdir("circular_yes");
let a = dir.join("A");
fs::create_dir_all(&a).unwrap();
let c = dir.join("C");
let _ = fs::remove_file(&c);
unix_fs::symlink(&a, &c).unwrap();
let link_path = a.join("B");
let actual = c.join("B");
let result = detect_circular_symlink_scenario(&link_path, &actual);
assert!(result.is_some(), "Should detect circular scenario");
let (circular_sym, link_parent) = result.unwrap();
assert_eq!(circular_sym, c);
assert_eq!(link_parent, a);
cleanup(&dir);
}
#[test]
fn test_no_circular_when_not_symlink() {
let dir = tmpdir("circular_no");
let a = dir.join("A");
let c = dir.join("C");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&c).unwrap();
assert!(!c.is_symlink(), "C should not be a symlink");
let link_path = a.join("B");
let actual = c.join("B");
let result = detect_circular_symlink_scenario(&link_path, &actual);
assert!(
result.is_none(),
"Should not detect circular when C is not symlink"
);
cleanup(&dir);
}
#[test]
fn test_detect_circular_direct_parent() {
let dir = tmpdir("circular_direct");
let a = dir.join("A");
fs::create_dir_all(&a).unwrap();
let c = dir.join("C");
let _ = fs::remove_file(&c);
unix_fs::symlink(&a, &c).unwrap();
assert!(c.is_symlink(), "C should be a symlink");
let link_path = a.join("file");
let actual = c.join("file");
let result = detect_circular_symlink_scenario(&link_path, &actual);
assert!(
result.is_some(),
"Should detect circular scenario (direct parent)"
);
cleanup(&dir);
}
}