1use std::collections::HashMap;
4use std::path::Path;
5use std::path::PathBuf;
6
7use crate::ExtractionError;
8use crate::Result;
9use crate::SecurityConfig;
10use crate::types::DestDir;
11use crate::types::SafePath;
12
13#[derive(Debug, Default)]
59pub struct HardlinkTracker {
60 seen_targets: HashMap<PathBuf, PathBuf>,
62}
63
64impl HardlinkTracker {
65 #[must_use]
67 pub fn new() -> Self {
68 Self {
69 seen_targets: HashMap::new(),
70 }
71 }
72
73 #[allow(clippy::items_after_statements)]
110 pub fn validate_hardlink(
111 &mut self,
112 link_path: &SafePath,
113 target: &Path,
114 dest: &DestDir,
115 config: &SecurityConfig,
116 ) -> Result<()> {
117 if !config.allowed.hardlinks {
119 return Err(ExtractionError::SecurityViolation {
120 reason: "hardlinks not allowed".into(),
121 });
122 }
123
124 use std::path::Component;
125
126 for component in target.components() {
130 if matches!(component, Component::Prefix(_) | Component::RootDir) {
131 return Err(ExtractionError::HardlinkEscape {
132 path: link_path.as_path().to_path_buf(),
133 });
134 }
135 }
136
137 if target.is_absolute() {
139 return Err(ExtractionError::HardlinkEscape {
140 path: link_path.as_path().to_path_buf(),
141 });
142 }
143
144 let resolved = dest.as_path().join(target);
146
147 let needs_normalization = resolved
148 .components()
149 .any(|c| matches!(c, Component::ParentDir | Component::CurDir));
150
151 if !needs_normalization {
152 if !resolved.starts_with(dest.as_path()) {
154 return Err(ExtractionError::HardlinkEscape {
155 path: link_path.as_path().to_path_buf(),
156 });
157 }
158
159 self.seen_targets
161 .entry(resolved)
162 .or_insert_with(|| link_path.as_path().to_path_buf());
163
164 return Ok(());
165 }
166
167 let mut normalized = PathBuf::new();
193 for component in resolved.components() {
194 match component {
195 Component::ParentDir => {
196 if !normalized.pop() {
197 return Err(ExtractionError::HardlinkEscape {
199 path: link_path.as_path().to_path_buf(),
200 });
201 }
202 }
203 Component::CurDir => {
204 }
206 _ => {
208 normalized.push(component);
209 }
210 }
211 }
212
213 let dest_canonical = dest.as_path();
217 if !normalized.starts_with(dest_canonical) {
218 return Err(ExtractionError::HardlinkEscape {
219 path: link_path.as_path().to_path_buf(),
220 });
221 }
222
223 self.seen_targets
225 .entry(normalized)
226 .or_insert_with(|| link_path.as_path().to_path_buf());
227
228 Ok(())
229 }
230
231 #[inline]
233 #[must_use]
234 pub fn count(&self) -> usize {
235 self.seen_targets.len()
236 }
237
238 #[must_use]
240 pub fn has_target(&self, target: &Path) -> bool {
241 self.seen_targets.contains_key(target)
242 }
243}
244
245#[cfg(test)]
246#[allow(
247 clippy::unwrap_used,
248 clippy::expect_used,
249 clippy::field_reassign_with_default
250)]
251mod tests {
252 use super::*;
253 use tempfile::TempDir;
254
255 fn create_test_dest() -> (TempDir, DestDir) {
256 let temp = TempDir::new().expect("failed to create temp dir");
257 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
258 (temp, dest)
259 }
260
261 #[test]
262 fn test_hardlink_tracker_new() {
263 let tracker = HardlinkTracker::new();
264 assert_eq!(tracker.count(), 0);
265 }
266
267 #[test]
268 fn test_validate_hardlink_allowed() {
269 let (_temp, dest) = create_test_dest();
270 let mut config = SecurityConfig::default();
271 config.allowed.hardlinks = true;
272
273 let mut tracker = HardlinkTracker::new();
274 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
275 let target = PathBuf::from("target.txt");
276
277 assert!(
278 tracker
279 .validate_hardlink(&link, &target, &dest, &config)
280 .is_ok()
281 );
282 assert_eq!(tracker.count(), 1);
283 }
284
285 #[test]
286 fn test_validate_hardlink_disabled() {
287 let (_temp, dest) = create_test_dest();
288 let config = SecurityConfig::default(); let mut tracker = HardlinkTracker::new();
291 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
292 let target = PathBuf::from("target.txt");
293
294 assert!(
295 tracker
296 .validate_hardlink(&link, &target, &dest, &config)
297 .is_err()
298 );
299 }
300
301 #[test]
302 fn test_validate_hardlink_absolute_target() {
303 let (_temp, dest) = create_test_dest();
304 let mut config = SecurityConfig::default();
305 config.allowed.hardlinks = true;
306
307 let mut tracker = HardlinkTracker::new();
308 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
309 let target = PathBuf::from("/etc/passwd");
310
311 let result = tracker.validate_hardlink(&link, &target, &dest, &config);
312 assert!(matches!(
313 result,
314 Err(ExtractionError::HardlinkEscape { .. })
315 ));
316 }
317
318 #[test]
319 fn test_validate_hardlink_escape() {
320 let (_temp, dest) = create_test_dest();
321 let mut config = SecurityConfig::default();
322 config.allowed.hardlinks = true;
323
324 let mut tracker = HardlinkTracker::new();
325 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
326 let target = PathBuf::from("../../etc/passwd");
327
328 let result = tracker.validate_hardlink(&link, &target, &dest, &config);
329 assert!(matches!(
330 result,
331 Err(ExtractionError::HardlinkEscape { .. })
332 ));
333 }
334
335 #[test]
336 fn test_hardlink_tracker_multiple() {
337 let (_temp, dest) = create_test_dest();
338 let mut config = SecurityConfig::default();
339 config.allowed.hardlinks = true;
340
341 let mut tracker = HardlinkTracker::new();
342
343 let link1 = SafePath::validate(&PathBuf::from("link1"), &dest, &config).unwrap();
344 let link2 = SafePath::validate(&PathBuf::from("link2"), &dest, &config).unwrap();
345
346 tracker
347 .validate_hardlink(&link1, &PathBuf::from("target1.txt"), &dest, &config)
348 .unwrap();
349 tracker
350 .validate_hardlink(&link2, &PathBuf::from("target2.txt"), &dest, &config)
351 .unwrap();
352
353 assert_eq!(tracker.count(), 2);
354 }
355
356 #[test]
357 fn test_hardlink_tracker_has_target() {
358 let (_temp, dest) = create_test_dest();
359 let mut config = SecurityConfig::default();
360 config.allowed.hardlinks = true;
361
362 let mut tracker = HardlinkTracker::new();
363 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
364 let target = PathBuf::from("target.txt");
365
366 tracker
367 .validate_hardlink(&link, &target, &dest, &config)
368 .unwrap();
369
370 let resolved_target = dest.as_path().join(&target);
371 assert!(tracker.has_target(&resolved_target));
372 }
373
374 #[test]
375 fn test_hardlink_tracker_relative_safe() {
376 let (_temp, dest) = create_test_dest();
377 let mut config = SecurityConfig::default();
378 config.allowed.hardlinks = true;
379
380 let mut tracker = HardlinkTracker::new();
381 let link = SafePath::validate(&PathBuf::from("foo/link"), &dest, &config).unwrap();
382 let target = PathBuf::from("target.txt");
384
385 let result = tracker.validate_hardlink(&link, &target, &dest, &config);
386 assert!(result.is_ok());
387 }
388
389 #[test]
391 fn test_duplicate_hardlink_to_same_target() {
392 let (_temp, dest) = create_test_dest();
393 let mut config = SecurityConfig::default();
394 config.allowed.hardlinks = true;
395
396 let mut tracker = HardlinkTracker::new();
397 let target = PathBuf::from("target.txt");
398
399 for i in 0..3 {
401 let link =
402 SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config).unwrap();
403
404 let result = tracker.validate_hardlink(&link, &target, &dest, &config);
405 assert!(
406 result.is_ok(),
407 "multiple hardlinks to same target should be allowed"
408 );
409 }
410
411 assert_eq!(
413 tracker.count(),
414 1,
415 "should track unique targets, not individual links"
416 );
417 }
418
419 #[test]
420 fn test_hardlink_different_targets() {
421 let (_temp, dest) = create_test_dest();
422 let mut config = SecurityConfig::default();
423 config.allowed.hardlinks = true;
424
425 let mut tracker = HardlinkTracker::new();
426
427 for i in 0..3 {
429 let link =
430 SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config).unwrap();
431 let target = PathBuf::from(format!("target{i}.txt"));
432
433 tracker
434 .validate_hardlink(&link, &target, &dest, &config)
435 .unwrap();
436 }
437
438 assert_eq!(tracker.count(), 3, "should track each unique target");
440 }
441}