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;
12use crate::types::safe_symlink::resolve_through_symlinks;
13
14#[derive(Debug, Default)]
60pub struct HardlinkTracker {
61 seen_targets: HashMap<PathBuf, PathBuf>,
63}
64
65impl HardlinkTracker {
66 #[must_use]
68 pub fn new() -> Self {
69 Self {
70 seen_targets: HashMap::new(),
71 }
72 }
73
74 #[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 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 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 if target.is_absolute() {
140 return Err(ExtractionError::HardlinkEscape {
141 path: link_path.as_path().to_path_buf(),
142 });
143 }
144
145 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 self.seen_targets
160 .entry(resolved)
161 .or_insert_with(|| link_path.as_path().to_path_buf());
162
163 Ok(())
164 }
165
166 #[inline]
168 #[must_use]
169 pub fn count(&self) -> usize {
170 self.seen_targets.len()
171 }
172
173 #[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(); 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 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 #[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 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 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 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 assert_eq!(tracker.count(), 3, "should track each unique target");
375 }
376
377 #[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 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 unix::fs::symlink("../..", c.join("up")).unwrap();
402 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 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}