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();
169 for component in resolved.components() {
170 match component {
171 Component::ParentDir => {
172 if !normalized.pop() {
173 return Err(ExtractionError::HardlinkEscape {
175 path: link_path.as_path().to_path_buf(),
176 });
177 }
178 }
179 Component::CurDir => {
180 }
182 _ => {
184 normalized.push(component);
185 }
186 }
187 }
188
189 let dest_canonical = dest.as_path();
193 if !normalized.starts_with(dest_canonical) {
194 return Err(ExtractionError::HardlinkEscape {
195 path: link_path.as_path().to_path_buf(),
196 });
197 }
198
199 self.seen_targets
201 .entry(normalized)
202 .or_insert_with(|| link_path.as_path().to_path_buf());
203
204 Ok(())
205 }
206
207 #[inline]
209 #[must_use]
210 pub fn count(&self) -> usize {
211 self.seen_targets.len()
212 }
213
214 #[must_use]
216 pub fn has_target(&self, target: &Path) -> bool {
217 self.seen_targets.contains_key(target)
218 }
219}
220
221#[cfg(test)]
222#[allow(
223 clippy::unwrap_used,
224 clippy::expect_used,
225 clippy::field_reassign_with_default
226)]
227mod tests {
228 use super::*;
229 use tempfile::TempDir;
230
231 fn create_test_dest() -> (TempDir, DestDir) {
232 let temp = TempDir::new().expect("failed to create temp dir");
233 let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
234 (temp, dest)
235 }
236
237 #[test]
238 fn test_hardlink_tracker_new() {
239 let tracker = HardlinkTracker::new();
240 assert_eq!(tracker.count(), 0);
241 }
242
243 #[test]
244 fn test_validate_hardlink_allowed() {
245 let (_temp, dest) = create_test_dest();
246 let mut config = SecurityConfig::default();
247 config.allowed.hardlinks = true;
248
249 let mut tracker = HardlinkTracker::new();
250 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
251 let target = PathBuf::from("target.txt");
252
253 assert!(
254 tracker
255 .validate_hardlink(&link, &target, &dest, &config)
256 .is_ok()
257 );
258 assert_eq!(tracker.count(), 1);
259 }
260
261 #[test]
262 fn test_validate_hardlink_disabled() {
263 let (_temp, dest) = create_test_dest();
264 let config = SecurityConfig::default(); let mut tracker = HardlinkTracker::new();
267 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
268 let target = PathBuf::from("target.txt");
269
270 assert!(
271 tracker
272 .validate_hardlink(&link, &target, &dest, &config)
273 .is_err()
274 );
275 }
276
277 #[test]
278 fn test_validate_hardlink_absolute_target() {
279 let (_temp, dest) = create_test_dest();
280 let mut config = SecurityConfig::default();
281 config.allowed.hardlinks = true;
282
283 let mut tracker = HardlinkTracker::new();
284 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
285 let target = PathBuf::from("/etc/passwd");
286
287 let result = tracker.validate_hardlink(&link, &target, &dest, &config);
288 assert!(matches!(
289 result,
290 Err(ExtractionError::HardlinkEscape { .. })
291 ));
292 }
293
294 #[test]
295 fn test_validate_hardlink_escape() {
296 let (_temp, dest) = create_test_dest();
297 let mut config = SecurityConfig::default();
298 config.allowed.hardlinks = true;
299
300 let mut tracker = HardlinkTracker::new();
301 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
302 let target = PathBuf::from("../../etc/passwd");
303
304 let result = tracker.validate_hardlink(&link, &target, &dest, &config);
305 assert!(matches!(
306 result,
307 Err(ExtractionError::HardlinkEscape { .. })
308 ));
309 }
310
311 #[test]
312 fn test_hardlink_tracker_multiple() {
313 let (_temp, dest) = create_test_dest();
314 let mut config = SecurityConfig::default();
315 config.allowed.hardlinks = true;
316
317 let mut tracker = HardlinkTracker::new();
318
319 let link1 = SafePath::validate(&PathBuf::from("link1"), &dest, &config).unwrap();
320 let link2 = SafePath::validate(&PathBuf::from("link2"), &dest, &config).unwrap();
321
322 tracker
323 .validate_hardlink(&link1, &PathBuf::from("target1.txt"), &dest, &config)
324 .unwrap();
325 tracker
326 .validate_hardlink(&link2, &PathBuf::from("target2.txt"), &dest, &config)
327 .unwrap();
328
329 assert_eq!(tracker.count(), 2);
330 }
331
332 #[test]
333 fn test_hardlink_tracker_has_target() {
334 let (_temp, dest) = create_test_dest();
335 let mut config = SecurityConfig::default();
336 config.allowed.hardlinks = true;
337
338 let mut tracker = HardlinkTracker::new();
339 let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
340 let target = PathBuf::from("target.txt");
341
342 tracker
343 .validate_hardlink(&link, &target, &dest, &config)
344 .unwrap();
345
346 let resolved_target = dest.as_path().join(&target);
347 assert!(tracker.has_target(&resolved_target));
348 }
349
350 #[test]
351 fn test_hardlink_tracker_relative_safe() {
352 let (_temp, dest) = create_test_dest();
353 let mut config = SecurityConfig::default();
354 config.allowed.hardlinks = true;
355
356 let mut tracker = HardlinkTracker::new();
357 let link = SafePath::validate(&PathBuf::from("foo/link"), &dest, &config).unwrap();
358 let target = PathBuf::from("target.txt");
360
361 let result = tracker.validate_hardlink(&link, &target, &dest, &config);
362 assert!(result.is_ok());
363 }
364
365 #[test]
367 fn test_duplicate_hardlink_to_same_target() {
368 let (_temp, dest) = create_test_dest();
369 let mut config = SecurityConfig::default();
370 config.allowed.hardlinks = true;
371
372 let mut tracker = HardlinkTracker::new();
373 let target = PathBuf::from("target.txt");
374
375 for i in 0..3 {
377 let link =
378 SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config).unwrap();
379
380 let result = tracker.validate_hardlink(&link, &target, &dest, &config);
381 assert!(
382 result.is_ok(),
383 "multiple hardlinks to same target should be allowed"
384 );
385 }
386
387 assert_eq!(
389 tracker.count(),
390 1,
391 "should track unique targets, not individual links"
392 );
393 }
394
395 #[test]
396 fn test_hardlink_different_targets() {
397 let (_temp, dest) = create_test_dest();
398 let mut config = SecurityConfig::default();
399 config.allowed.hardlinks = true;
400
401 let mut tracker = HardlinkTracker::new();
402
403 for i in 0..3 {
405 let link =
406 SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config).unwrap();
407 let target = PathBuf::from(format!("target{i}.txt"));
408
409 tracker
410 .validate_hardlink(&link, &target, &dest, &config)
411 .unwrap();
412 }
413
414 assert_eq!(tracker.count(), 3, "should track each unique target");
416 }
417}