use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use crate::ArchiveError;
use crate::PolicySetting;
use crate::Result;
use crate::SecurityConfig;
use crate::config::Validated;
use crate::security::fs_view::FsView;
use crate::security::fs_view::HostFs;
use crate::types::DestDir;
use crate::types::SafePath;
use crate::types::safe_path::check_windows_component;
use crate::types::safe_symlink::resolve_through_symlinks_in;
pub fn hardlink_target_missing(target: &Path) -> ArchiveError {
ArchiveError::InvalidArchive(format!(
"hardlink target does not exist: {}",
target.display()
))
}
pub fn require_hardlinks_allowed(config: &SecurityConfig<Validated>) -> Result<()> {
if config.allowed.hardlinks {
Ok(())
} else {
Err(ArchiveError::relaxable_violation(
PolicySetting::Hardlinks,
"hardlinks not allowed".into(),
))
}
}
#[derive(Debug, Default)]
pub struct HardlinkTracker {
seen_targets: HashMap<PathBuf, PathBuf>,
}
impl HardlinkTracker {
#[must_use]
pub fn new() -> Self {
Self {
seen_targets: HashMap::new(),
}
}
pub fn validate_hardlink(
&mut self,
link_path: &SafePath,
target: &Path,
dest: &DestDir,
config: &SecurityConfig<Validated>,
) -> Result<()> {
self.validate_hardlink_in(link_path, target, dest, config, &HostFs)
}
#[expect(
clippy::items_after_statements,
reason = "the import is placed next to its only use"
)]
pub(crate) fn validate_hardlink_in<V: FsView>(
&mut self,
link_path: &SafePath,
target: &Path,
dest: &DestDir,
config: &SecurityConfig<Validated>,
view: &V,
) -> Result<()> {
require_hardlinks_allowed(config)?;
if target.is_empty() {
return Err(ArchiveError::violation("hardlink target is empty".into()));
}
if crate::types::safe_path::has_null_bytes(target) {
return Err(ArchiveError::violation(
"hardlink target contains null bytes".into(),
));
}
use std::path::Component;
for component in target.components() {
if matches!(component, Component::Prefix(_) | Component::RootDir) {
return Err(ArchiveError::HardlinkEscape {
path: link_path.as_path().to_path_buf(),
});
}
}
if cfg!(windows) {
for component in target.components() {
if let Component::Normal(comp) = component {
check_windows_component(comp, config)?;
}
}
}
if target.is_absolute() {
return Err(ArchiveError::HardlinkEscape {
path: link_path.as_path().to_path_buf(),
});
}
let resolved = resolve_through_symlinks_in(
dest.as_path(),
target,
dest.as_path(),
link_path.as_path(),
view,
)
.map_err(|_| ArchiveError::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()
}
}
#[cfg(test)]
impl HardlinkTracker {
pub(crate) 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 std::assert_matches;
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 config = config.validate().expect("valid config");
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().validate().expect("valid config");
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 config = config.validate().expect("valid config");
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(ArchiveError::HardlinkEscape { .. }));
}
#[test]
fn test_validate_hardlink_escape() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let config = config.validate().expect("valid config");
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(ArchiveError::HardlinkEscape { .. }));
}
#[test]
fn test_hardlink_tracker_multiple() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let config = config.validate().expect("valid config");
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 config = config.validate().expect("valid config");
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 config = config.validate().expect("valid config");
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 config = config.validate().expect("valid config");
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 config = config.validate().expect("valid config");
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 config = config.validate().expect("valid config");
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(ArchiveError::HardlinkEscape { .. }),
"hardlink through two-hop symlink chain must be rejected"
);
}
#[test]
#[cfg(windows)]
fn test_hardlink_rejects_windows_hazards_in_target() {
let (_temp, dest) = create_test_dest();
let mut config = SecurityConfig::default();
config.allowed.hardlinks = true;
let config = config.validate().expect("valid config");
let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
for target in ["dir/foo.", "dir/foo ", "dir/a:b", "sub/NUL"] {
let mut tracker = HardlinkTracker::new();
assert_matches!(
tracker.validate_hardlink(&link, &PathBuf::from(target), &dest, &config),
Err(ArchiveError::SecurityViolation { .. }),
"{target}"
);
}
}
}