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 the path by removing . and .. components
168        //
169        // # Normalization Algorithm (L-DOC-1)
170        //
171        // The algorithm processes each path component sequentially:
172        //
173        // 1. **ParentDir (..)**: Pop last component from normalized path
174        //    - If pop fails (no components to remove), it's an escape attempt → Error
175        //    - Example: `/tmp/a/b/..` → `/tmp/a`
176        //    - Example: `/tmp/../..` → Error (escapes /tmp)
177        //
178        // 2. **CurDir (.)**: Skipped, doesn't change path
179        //    - Example: `/tmp/./a` → `/tmp/a`
180        //
181        // 3. **Normal**: Pushed to normalized path
182        //    - Example: `file.txt` → added to path
183        //
184        // 4. **Prefix/RootDir**: Kept from resolved path (comes from dest, not target)
185        //    - These are safe because they come from the trusted destination path
186        //
187        // This approach:
188        // - Prevents path traversal attacks (../../../etc/passwd)
189        // - Handles Windows absolute paths in target (checked above)
190        // - Works without filesystem access (targets may not exist yet)
191        // - Time complexity: O(n) where n is number of components
192        let mut normalized = PathBuf::new();
193        for component in resolved.components() {
194            match component {
195                Component::ParentDir => {
196                    if !normalized.pop() {
197                        // Tried to go above root - escape attempt
198                        return Err(ExtractionError::HardlinkEscape {
199                            path: link_path.as_path().to_path_buf(),
200                        });
201                    }
202                }
203                Component::CurDir => {
204                    // Skip current directory markers
205                }
206                // Keep Prefix/RootDir from resolved path (they come from dest, which is trusted)
207                _ => {
208                    normalized.push(component);
209                }
210            }
211        }
212
213        // Verify the normalized path is within destination
214        // Note: We need to canonicalize the destination for proper comparison
215        // since dest might have symlinks
216        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        // Track this hardlink target using normalized path (H-PERF-6: use entry API)
224        self.seen_targets
225            .entry(normalized)
226            .or_insert_with(|| link_path.as_path().to_path_buf());
227
228        Ok(())
229    }
230
231    /// Returns the number of tracked hardlinks.
232    #[inline]
233    #[must_use]
234    pub fn count(&self) -> usize {
235        self.seen_targets.len()
236    }
237
238    /// Checks if a target path has been seen before.
239    #[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(); // hardlinks disabled by default
289
290        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        // Safe relative path within destination
383        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    // H-TEST-2: Duplicate hardlink to same target test
390    #[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        // Create multiple hardlinks to the same target
400        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        // All three links point to the same target, so only 1 unique target tracked
412        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        // Create hardlinks to different targets
428        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        // Three different targets
439        assert_eq!(tracker.count(), 3, "should track each unique target");
440    }
441}