use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use crate::ExtractionError;
use crate::Result;
use crate::SecurityConfig;
use crate::types::DestDir;
use crate::types::SafePath;
use crate::types::safe_symlink::resolve_through_symlinks;
#[derive(Debug, Default)]
pub struct HardlinkTracker {
seen_targets: HashMap<PathBuf, PathBuf>,
}
impl HardlinkTracker {
#[must_use]
pub fn new() -> Self {
Self {
seen_targets: HashMap::new(),
}
}
#[allow(clippy::items_after_statements)]
pub fn validate_hardlink(
&mut self,
link_path: &SafePath,
target: &Path,
dest: &DestDir,
config: &SecurityConfig,
) -> Result<()> {
if !config.allowed.hardlinks {
return Err(ExtractionError::SecurityViolation {
reason: "hardlinks not allowed".into(),
});
}
use std::path::Component;
for component in target.components() {
if matches!(component, Component::Prefix(_) | Component::RootDir) {
return Err(ExtractionError::HardlinkEscape {
path: link_path.as_path().to_path_buf(),
});
}
}
if target.is_absolute() {
return Err(ExtractionError::HardlinkEscape {
path: link_path.as_path().to_path_buf(),
});
}
let resolved =
resolve_through_symlinks(dest.as_path(), target, dest.as_path(), link_path.as_path())
.map_err(|_| ExtractionError::HardlinkEscape {
path: link_path.as_path().to_path_buf(),
})?;
self.seen_targets
.entry(resolved)
.or_insert_with(|| link_path.as_path().to_path_buf());
Ok(())
}
#[inline]
#[must_use]
pub fn count(&self) -> usize {
self.seen_targets.len()
}
#[must_use]
pub fn has_target(&self, target: &Path) -> bool {
self.seen_targets.contains_key(target)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::field_reassign_with_default
)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_dest() -> (TempDir, DestDir) {
let temp = TempDir::new().expect("failed to create temp dir");
let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
(temp, dest)
}
#[test]
fn test_hardlink_tracker_new() {
let tracker = HardlinkTracker::new();
assert_eq!(tracker.count(), 0);
}
#[test]
fn test_validate_hardlink_allowed() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
let target = PathBuf::from("target.txt");
assert!(
tracker
.validate_hardlink(&link, &target, &dest, &config)
.is_ok()
);
assert_eq!(tracker.count(), 1);
}
#[test]
fn test_validate_hardlink_disabled() {
let (_temp, dest) = create_test_dest();
let config = SecurityConfig::default();
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
let target = PathBuf::from("target.txt");
assert!(
tracker
.validate_hardlink(&link, &target, &dest, &config)
.is_err()
);
}
#[test]
fn test_validate_hardlink_absolute_target() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
let target = PathBuf::from("/etc/passwd");
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
assert!(matches!(
result,
Err(ExtractionError::HardlinkEscape { .. })
));
}
#[test]
fn test_validate_hardlink_escape() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
let target = PathBuf::from("../../etc/passwd");
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
assert!(matches!(
result,
Err(ExtractionError::HardlinkEscape { .. })
));
}
#[test]
fn test_hardlink_tracker_multiple() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let link1 = SafePath::validate(&PathBuf::from("link1"), &dest, &config).unwrap();
let link2 = SafePath::validate(&PathBuf::from("link2"), &dest, &config).unwrap();
tracker
.validate_hardlink(&link1, &PathBuf::from("target1.txt"), &dest, &config)
.unwrap();
tracker
.validate_hardlink(&link2, &PathBuf::from("target2.txt"), &dest, &config)
.unwrap();
assert_eq!(tracker.count(), 2);
}
#[test]
fn test_hardlink_tracker_has_target() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
let target = PathBuf::from("target.txt");
tracker
.validate_hardlink(&link, &target, &dest, &config)
.unwrap();
let resolved_target = dest.as_path().join(&target);
assert!(tracker.has_target(&resolved_target));
}
#[test]
fn test_hardlink_tracker_relative_safe() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("foo/link"), &dest, &config).unwrap();
let target = PathBuf::from("target.txt");
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
assert!(result.is_ok());
}
#[test]
fn test_duplicate_hardlink_to_same_target() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
let target = PathBuf::from("target.txt");
for i in 0..3 {
let link =
SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config).unwrap();
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
assert!(
result.is_ok(),
"multiple hardlinks to same target should be allowed"
);
}
assert_eq!(
tracker.count(),
1,
"should track unique targets, not individual links"
);
}
#[test]
fn test_hardlink_different_targets() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let mut tracker = HardlinkTracker::new();
for i in 0..3 {
let link =
SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config).unwrap();
let target = PathBuf::from(format!("target{i}.txt"));
tracker
.validate_hardlink(&link, &target, &dest, &config)
.unwrap();
}
assert_eq!(tracker.count(), 3, "should track each unique target");
}
#[test]
#[cfg(unix)]
#[allow(clippy::unwrap_used)]
fn test_hardlink_two_hop_chain_rejected() {
use std::fs;
use std::os::unix;
let (temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let a = temp.path().join("a");
let b = a.join("b");
let c = b.join("c");
fs::create_dir_all(&c).unwrap();
unix::fs::symlink("../..", c.join("up")).unwrap();
unix::fs::symlink("c/up/../..", b.join("escape")).unwrap();
let mut tracker = HardlinkTracker::new();
let link = SafePath::validate(&PathBuf::from("exfil"), &dest, &config).unwrap();
let target = PathBuf::from("a/b/escape/../../etc/passwd");
let result = tracker.validate_hardlink(&link, &target, &dest, &config);
assert!(
matches!(result, Err(ExtractionError::HardlinkEscape { .. })),
"hardlink through two-hop symlink chain must be rejected"
);
}
}