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;
12use crate::types::safe_symlink::resolve_through_symlinks;
13
14/// Tracks hardlink targets during extraction.
15///
16/// Hardlinks in archives can be used for attacks:
17/// 1. Link to files outside the extraction directory
18/// 2. Create multiple hardlinks to the same file (resource exhaustion)
19/// 3. Link to sensitive files (if absolute paths allowed)
20///
21/// This tracker ensures:
22/// - Hardlinks are allowed in the security configuration
23/// - Targets are relative paths
24/// - Targets resolve within the destination directory
25/// - Duplicate hardlinks are detected
26///
27/// # Two-Pass Validation
28///
29/// Hardlinks require two-pass validation:
30/// 1. **First pass (during validation):** Track target paths, verify they're
31///    within bounds
32/// 2. **Second pass (after extraction):** Verify targets actually exist
33///
34/// This is necessary because hardlink targets may appear later in the archive.
35///
36/// # Examples
37///
38/// ```no_run
39/// use exarch_core::SecurityConfig;
40/// use exarch_core::security::HardlinkTracker;
41/// use exarch_core::types::DestDir;
42/// use exarch_core::types::SafePath;
43/// use std::path::Path;
44/// use std::path::PathBuf;
45///
46/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
47/// let dest = DestDir::new(PathBuf::from("/tmp"))?;
48/// let mut config = SecurityConfig::default();
49/// config.allowed.hardlinks = true;
50///
51/// let mut tracker = HardlinkTracker::new();
52/// let link = SafePath::validate(&PathBuf::from("link"), &dest, &config)?;
53/// let target = Path::new("target.txt");
54///
55/// tracker.validate_hardlink(&link, target, &dest, &config)?;
56/// # Ok(())
57/// # }
58/// ```
59#[derive(Debug, Default)]
60pub struct HardlinkTracker {
61    /// Maps target path to the first link path that referenced it
62    seen_targets: HashMap<PathBuf, PathBuf>,
63}
64
65impl HardlinkTracker {
66    /// Creates a new hardlink tracker.
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            seen_targets: HashMap::new(),
71        }
72    }
73
74    /// Validates that a hardlink target is safe and tracks it.
75    ///
76    /// # Performance
77    ///
78    /// Typical execution time: ~1-5 μs (`HashMap` insert + path validation)
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if:
83    /// - Hardlinks are not allowed in configuration
84    /// - Target is an absolute path
85    /// - Target would escape the destination directory
86    ///
87    /// # Examples
88    ///
89    /// ```no_run
90    /// use exarch_core::SecurityConfig;
91    /// use exarch_core::security::HardlinkTracker;
92    /// use exarch_core::types::DestDir;
93    /// use exarch_core::types::SafePath;
94    /// use std::path::Path;
95    /// use std::path::PathBuf;
96    ///
97    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
98    /// let dest = DestDir::new(PathBuf::from("/tmp"))?;
99    /// let mut config = SecurityConfig::default();
100    /// config.allowed.hardlinks = true;
101    ///
102    /// let mut tracker = HardlinkTracker::new();
103    /// let link = SafePath::validate(&PathBuf::from("link"), &dest, &config)?;
104    /// let target = Path::new("target.txt");
105    ///
106    /// tracker.validate_hardlink(&link, target, &dest, &config)?;
107    /// # Ok(())
108    /// # }
109    /// ```
110    #[allow(clippy::items_after_statements)]
111    pub fn validate_hardlink(
112        &mut self,
113        link_path: &SafePath,
114        target: &Path,
115        dest: &DestDir,
116        config: &SecurityConfig,
117    ) -> Result<()> {
118        // Check if hardlinks are allowed
119        if !config.allowed.hardlinks {
120            return Err(ExtractionError::SecurityViolation {
121                reason: "hardlinks not allowed".into(),
122            });
123        }
124
125        use std::path::Component;
126
127        // H-SEC-2: Reject Windows-specific absolute path components in target (before
128        // resolution) This prevents bypasses on Windows like C:\ or
129        // \\server\share
130        for component in target.components() {
131            if matches!(component, Component::Prefix(_) | Component::RootDir) {
132                return Err(ExtractionError::HardlinkEscape {
133                    path: link_path.as_path().to_path_buf(),
134                });
135            }
136        }
137
138        // Also reject absolute targets (redundant with above, but keeps existing check)
139        if target.is_absolute() {
140            return Err(ExtractionError::HardlinkEscape {
141                path: link_path.as_path().to_path_buf(),
142            });
143        }
144
145        // Resolve target against destination, following any on-disk symlinks
146        // encountered during traversal.
147        //
148        // String-based normalization is insufficient: if a previously extracted
149        // symlink lies on the target path, a hardlink target can escape the
150        // extraction root via on-disk resolution even though string normalization
151        // would pass (two-hop chain bypass, GHSA-83g3-92jg-28cx variant — #116).
152        let resolved =
153            resolve_through_symlinks(dest.as_path(), target, dest.as_path(), link_path.as_path())
154                .map_err(|_| ExtractionError::HardlinkEscape {
155                path: link_path.as_path().to_path_buf(),
156            })?;
157
158        // Track this hardlink target (H-PERF-6: use entry API)
159        self.seen_targets
160            .entry(resolved)
161            .or_insert_with(|| link_path.as_path().to_path_buf());
162
163        Ok(())
164    }
165
166    /// Returns the number of tracked hardlinks.
167    #[inline]
168    #[must_use]
169    pub fn count(&self) -> usize {
170        self.seen_targets.len()
171    }
172
173    /// Checks if a target path has been seen before.
174    #[must_use]
175    pub fn has_target(&self, target: &Path) -> bool {
176        self.seen_targets.contains_key(target)
177    }
178}
179
180#[cfg(test)]
181#[allow(
182    clippy::unwrap_used,
183    clippy::expect_used,
184    clippy::field_reassign_with_default
185)]
186mod tests {
187    use super::*;
188    use tempfile::TempDir;
189
190    fn create_test_dest() -> (TempDir, DestDir) {
191        let temp = TempDir::new().expect("failed to create temp dir");
192        let dest = DestDir::new(temp.path().to_path_buf()).expect("failed to create dest");
193        (temp, dest)
194    }
195
196    #[test]
197    fn test_hardlink_tracker_new() {
198        let tracker = HardlinkTracker::new();
199        assert_eq!(tracker.count(), 0);
200    }
201
202    #[test]
203    fn test_validate_hardlink_allowed() {
204        let (_temp, dest) = create_test_dest();
205        let mut config = SecurityConfig::default();
206        config.allowed.hardlinks = true;
207
208        let mut tracker = HardlinkTracker::new();
209        let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
210        let target = PathBuf::from("target.txt");
211
212        assert!(
213            tracker
214                .validate_hardlink(&link, &target, &dest, &config)
215                .is_ok()
216        );
217        assert_eq!(tracker.count(), 1);
218    }
219
220    #[test]
221    fn test_validate_hardlink_disabled() {
222        let (_temp, dest) = create_test_dest();
223        let config = SecurityConfig::default(); // hardlinks disabled by default
224
225        let mut tracker = HardlinkTracker::new();
226        let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
227        let target = PathBuf::from("target.txt");
228
229        assert!(
230            tracker
231                .validate_hardlink(&link, &target, &dest, &config)
232                .is_err()
233        );
234    }
235
236    #[test]
237    fn test_validate_hardlink_absolute_target() {
238        let (_temp, dest) = create_test_dest();
239        let mut config = SecurityConfig::default();
240        config.allowed.hardlinks = true;
241
242        let mut tracker = HardlinkTracker::new();
243        let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
244        let target = PathBuf::from("/etc/passwd");
245
246        let result = tracker.validate_hardlink(&link, &target, &dest, &config);
247        assert!(matches!(
248            result,
249            Err(ExtractionError::HardlinkEscape { .. })
250        ));
251    }
252
253    #[test]
254    fn test_validate_hardlink_escape() {
255        let (_temp, dest) = create_test_dest();
256        let mut config = SecurityConfig::default();
257        config.allowed.hardlinks = true;
258
259        let mut tracker = HardlinkTracker::new();
260        let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
261        let target = PathBuf::from("../../etc/passwd");
262
263        let result = tracker.validate_hardlink(&link, &target, &dest, &config);
264        assert!(matches!(
265            result,
266            Err(ExtractionError::HardlinkEscape { .. })
267        ));
268    }
269
270    #[test]
271    fn test_hardlink_tracker_multiple() {
272        let (_temp, dest) = create_test_dest();
273        let mut config = SecurityConfig::default();
274        config.allowed.hardlinks = true;
275
276        let mut tracker = HardlinkTracker::new();
277
278        let link1 = SafePath::validate(&PathBuf::from("link1"), &dest, &config).unwrap();
279        let link2 = SafePath::validate(&PathBuf::from("link2"), &dest, &config).unwrap();
280
281        tracker
282            .validate_hardlink(&link1, &PathBuf::from("target1.txt"), &dest, &config)
283            .unwrap();
284        tracker
285            .validate_hardlink(&link2, &PathBuf::from("target2.txt"), &dest, &config)
286            .unwrap();
287
288        assert_eq!(tracker.count(), 2);
289    }
290
291    #[test]
292    fn test_hardlink_tracker_has_target() {
293        let (_temp, dest) = create_test_dest();
294        let mut config = SecurityConfig::default();
295        config.allowed.hardlinks = true;
296
297        let mut tracker = HardlinkTracker::new();
298        let link = SafePath::validate(&PathBuf::from("link"), &dest, &config).unwrap();
299        let target = PathBuf::from("target.txt");
300
301        tracker
302            .validate_hardlink(&link, &target, &dest, &config)
303            .unwrap();
304
305        let resolved_target = dest.as_path().join(&target);
306        assert!(tracker.has_target(&resolved_target));
307    }
308
309    #[test]
310    fn test_hardlink_tracker_relative_safe() {
311        let (_temp, dest) = create_test_dest();
312        let mut config = SecurityConfig::default();
313        config.allowed.hardlinks = true;
314
315        let mut tracker = HardlinkTracker::new();
316        let link = SafePath::validate(&PathBuf::from("foo/link"), &dest, &config).unwrap();
317        // Safe relative path within destination
318        let target = PathBuf::from("target.txt");
319
320        let result = tracker.validate_hardlink(&link, &target, &dest, &config);
321        assert!(result.is_ok());
322    }
323
324    // H-TEST-2: Duplicate hardlink to same target test
325    #[test]
326    fn test_duplicate_hardlink_to_same_target() {
327        let (_temp, dest) = create_test_dest();
328        let mut config = SecurityConfig::default();
329        config.allowed.hardlinks = true;
330
331        let mut tracker = HardlinkTracker::new();
332        let target = PathBuf::from("target.txt");
333
334        // Create multiple hardlinks to the same target
335        for i in 0..3 {
336            let link =
337                SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config).unwrap();
338
339            let result = tracker.validate_hardlink(&link, &target, &dest, &config);
340            assert!(
341                result.is_ok(),
342                "multiple hardlinks to same target should be allowed"
343            );
344        }
345
346        // All three links point to the same target, so only 1 unique target tracked
347        assert_eq!(
348            tracker.count(),
349            1,
350            "should track unique targets, not individual links"
351        );
352    }
353
354    #[test]
355    fn test_hardlink_different_targets() {
356        let (_temp, dest) = create_test_dest();
357        let mut config = SecurityConfig::default();
358        config.allowed.hardlinks = true;
359
360        let mut tracker = HardlinkTracker::new();
361
362        // Create hardlinks to different targets
363        for i in 0..3 {
364            let link =
365                SafePath::validate(&PathBuf::from(format!("link{i}")), &dest, &config).unwrap();
366            let target = PathBuf::from(format!("target{i}.txt"));
367
368            tracker
369                .validate_hardlink(&link, &target, &dest, &config)
370                .unwrap();
371        }
372
373        // Three different targets
374        assert_eq!(tracker.count(), 3, "should track each unique target");
375    }
376
377    /// Hardlink target that traverses through an already-extracted symlink must
378    /// be rejected (two-hop chain, GHSA-83g3-92jg-28cx variant — issue #116).
379    ///
380    /// Entry 2 (symlink a/b/c/up -> ../..) is on disk when hardlink is
381    /// validated. Target a/b/escape/../../etc/passwd: string normalization
382    /// → dest/a/etc/passwd (looks safe), but if a/b/escape is an on-disk
383    /// symlink resolving outside dest, the hardlink must be rejected.
384    #[test]
385    #[cfg(unix)]
386    #[allow(clippy::unwrap_used)]
387    fn test_hardlink_two_hop_chain_rejected() {
388        use std::fs;
389        use std::os::unix;
390
391        let (temp, dest) = create_test_dest();
392        let mut config = SecurityConfig::default();
393        config.allowed.hardlinks = true;
394
395        // Simulate on-disk state after extracting the two symlink entries.
396        let a = temp.path().join("a");
397        let b = a.join("b");
398        let c = b.join("c");
399        fs::create_dir_all(&c).unwrap();
400        // a/b/c/up -> ../..  (first hop: resolves to a/)
401        unix::fs::symlink("../..", c.join("up")).unwrap();
402        // a/b/escape -> c/up/../..  (second hop: resolves outside dest on disk)
403        unix::fs::symlink("c/up/../..", b.join("escape")).unwrap();
404
405        let mut tracker = HardlinkTracker::new();
406        let link = SafePath::validate(&PathBuf::from("exfil"), &dest, &config).unwrap();
407        // Target traverses through the escape symlink
408        let target = PathBuf::from("a/b/escape/../../etc/passwd");
409
410        let result = tracker.validate_hardlink(&link, &target, &dest, &config);
411        assert!(
412            matches!(result, Err(ExtractionError::HardlinkEscape { .. })),
413            "hardlink through two-hop symlink chain must be rejected"
414        );
415    }
416}