Skip to main content

exarch_core/security/
hardlink.rs

1//! Hardlink security validation and tracking.
2
3use 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/// Tracks hardlink targets during extraction.
14///
15/// Hardlinks in archives can be used for attacks:
16/// 1. Link to files outside the extraction directory
17/// 2. Create multiple hardlinks to the same file (resource exhaustion)
18/// 3. Link to sensitive files (if absolute paths allowed)
19///
20/// This tracker ensures:
21/// - Hardlinks are allowed in the security configuration
22/// - Targets are relative paths
23/// - Targets resolve within the destination directory
24/// - Duplicate hardlinks are detected
25///
26/// # Two-Pass Validation
27///
28/// Hardlinks require two-pass validation:
29/// 1. **First pass (during validation):** Track target paths, verify they're
30///    within bounds
31/// 2. **Second pass (after extraction):** Verify targets actually exist
32///
33/// This is necessary because hardlink targets may appear later in the archive.
34///
35/// # Examples
36///
37/// ```no_run
38/// use exarch_core::SecurityConfig;
39/// use exarch_core::security::HardlinkTracker;
40/// use exarch_core::types::DestDir;
41/// use exarch_core::types::SafePath;
42/// use std::path::Path;
43/// use std::path::PathBuf;
44///
45/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
46/// let dest = DestDir::new(PathBuf::from("/tmp"))?;
47/// let mut config = SecurityConfig::default();
48/// config.allowed.hardlinks = true;
49///
50/// let mut tracker = HardlinkTracker::new();
51/// let link = SafePath::validate(&PathBuf::from("link"), &dest, &config)?;
52/// let target = Path::new("target.txt");
53///
54/// tracker.validate_hardlink(&link, target, &dest, &config)?;
55/// # Ok(())
56/// # }
57/// ```
58#[derive(Debug, Default)]
59pub struct HardlinkTracker {
60    /// Maps target path to the first link path that referenced it
61    seen_targets: HashMap<PathBuf, PathBuf>,
62}
63
64impl HardlinkTracker {
65    /// Creates a new hardlink tracker.
66    #[must_use]
67    pub fn new() -> Self {
68        Self {
69            seen_targets: HashMap::new(),
70        }
71    }
72
73    /// Validates that a hardlink target is safe and tracks it.
74    ///
75    /// # Performance
76    ///
77    /// Typical execution time: ~1-5 μs (`HashMap` insert + path validation)
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if:
82    /// - Hardlinks are not allowed in configuration
83    /// - Target is an absolute path
84    /// - Target would escape the destination directory
85    ///
86    /// # Examples
87    ///
88    /// ```no_run
89    /// use exarch_core::SecurityConfig;
90    /// use exarch_core::security::HardlinkTracker;
91    /// use exarch_core::types::DestDir;
92    /// use exarch_core::types::SafePath;
93    /// use std::path::Path;
94    /// use std::path::PathBuf;
95    ///
96    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
97    /// let dest = DestDir::new(PathBuf::from("/tmp"))?;
98    /// let mut config = SecurityConfig::default();
99    /// config.allowed.hardlinks = true;
100    ///
101    /// let mut tracker = HardlinkTracker::new();
102    /// let link = SafePath::validate(&PathBuf::from("link"), &dest, &config)?;
103    /// let target = Path::new("target.txt");
104    ///
105    /// tracker.validate_hardlink(&link, target, &dest, &config)?;
106    /// # Ok(())
107    /// # }
108    /// ```
109    #[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        // Check if hardlinks are allowed
118        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        // H-SEC-2: Reject Windows-specific absolute path components in target (before
127        // resolution) This prevents bypasses on Windows like C:\ or
128        // \\server\share
129        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        // Also reject absolute targets (redundant with above, but keeps existing check)
138        if target.is_absolute() {
139            return Err(ExtractionError::HardlinkEscape {
140                path: link_path.as_path().to_path_buf(),
141            });
142        }
143
144        // Resolve target against destination
145        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            // Path is already normalized, just verify it's within destination
153            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            // Track this hardlink target (H-PERF-6: use entry API)
160            self.seen_targets
161                .entry(resolved)
162                .or_insert_with(|| link_path.as_path().to_path_buf());
163
164            return Ok(());
165        }
166
167        // Normalize path: resolve .. and . components, detect escape attempts
168        let mut normalized = PathBuf::new();
169        for component in resolved.components() {
170            match component {
171                Component::ParentDir => {
172                    if !normalized.pop() {
173                        // Tried to go above root - escape attempt
174                        return Err(ExtractionError::HardlinkEscape {
175                            path: link_path.as_path().to_path_buf(),
176                        });
177                    }
178                }
179                Component::CurDir => {
180                    // Skip current directory markers
181                }
182                // Keep Prefix/RootDir from resolved path (they come from dest, which is trusted)
183                _ => {
184                    normalized.push(component);
185                }
186            }
187        }
188
189        // Verify the normalized path is within destination
190        // Note: We need to canonicalize the destination for proper comparison
191        // since dest might have symlinks
192        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        // Track this hardlink target using normalized path (H-PERF-6: use entry API)
200        self.seen_targets
201            .entry(normalized)
202            .or_insert_with(|| link_path.as_path().to_path_buf());
203
204        Ok(())
205    }
206
207    /// Returns the number of tracked hardlinks.
208    #[inline]
209    #[must_use]
210    pub fn count(&self) -> usize {
211        self.seen_targets.len()
212    }
213
214    /// Checks if a target path has been seen before.
215    #[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(); // hardlinks disabled by default
265
266        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        // Safe relative path within destination
359        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    // H-TEST-2: Duplicate hardlink to same target test
366    #[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        // Create multiple hardlinks to the same target
376        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        // All three links point to the same target, so only 1 unique target tracked
388        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        // Create hardlinks to different targets
404        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        // Three different targets
415        assert_eq!(tracker.count(), 3, "should track each unique target");
416    }
417}