strict-path 0.2.2

Secure path handling for untrusted input. Prevents directory traversal, symlink escapes, and 19+ real-world CVE attack patterns.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
// Tests for symlink and junction escape detection/clamping, TOCTOU attacks,
// and archive extraction (zip/tar slip) scenarios.

#[cfg(feature = "virtual-path")]
use crate::PathBoundary;
#[cfg(all(feature = "virtual-path", unix))]
use crate::VirtualRoot;

#[cfg(feature = "virtual-path")]
#[test]
#[cfg(unix)]
fn test_symlink_escape_is_rejected() {
    use std::fs;
    use std::os::unix::fs as unixfs;

    let td = tempfile::tempdir().unwrap();
    let base = td.path();
    let restriction_dir = base.join("PathBoundary");
    let outside_dir = base.join("outside");
    fs::create_dir_all(&restriction_dir).unwrap();
    fs::create_dir_all(&outside_dir).unwrap();

    // Create symlink inside PathBoundary pointing to a directory outside the PathBoundary
    let link_in_restriction = restriction_dir.join("link");
    unixfs::symlink(&outside_dir, &link_in_restriction).unwrap();

    let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();

    // Attempt to validate a path that goes through the symlink to outside
    let err = restriction.strict_join("link/escape.txt").unwrap_err();
    match err {
        crate::StrictPathError::PathEscapesBoundary { .. } => {}
        other => panic!("Expected PathEscapesBoundary, got {other:?}"),
    }

    // VirtualRoot should CLAMP the symlink target to virtual root (new behavior in 0.4.0)
    let vroot: VirtualRoot = VirtualRoot::try_new(&restriction_dir).unwrap();
    let clamped = vroot
        .virtual_join("link/escape.txt")
        .expect("Virtual paths should clamp symlink targets to virtual root");

    // Verify the path is clamped within the virtual root
    // Canonicalize both paths for comparison (macOS has /var -> /private/var symlink)
    let clamped_system = clamped.interop_path();
    let canonical_restriction = fs::canonicalize(&restriction_dir).unwrap();
    assert!(
        AsRef::<std::path::Path>::as_ref(clamped_system).starts_with(&canonical_restriction),
        "Virtual path should be clamped within virtual root. Got: {:?}, Expected to start with: {:?}",
        clamped_system,
        canonical_restriction
    );
}

#[cfg(feature = "virtual-path")]
#[test]
#[cfg(unix)]
fn test_relative_symlink_escape_is_rejected() {
    use std::fs;
    use std::os::unix::fs as unixfs;

    let td = tempfile::tempdir().unwrap();
    let base = td.path();
    let restriction_dir = base.join("PathBoundary");
    let sibling = base.join("sibling");
    let outside_dir = sibling.join("outside");
    fs::create_dir_all(&restriction_dir).unwrap();
    fs::create_dir_all(&outside_dir).unwrap();

    // Create a relative symlink inside PathBoundary pointing to ../sibling/outside
    let link_in_restriction = restriction_dir.join("rel");
    unixfs::symlink("../sibling/outside", &link_in_restriction).unwrap();

    let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();

    let err = restriction.strict_join("rel/escape.txt").unwrap_err();
    match err {
        crate::StrictPathError::PathEscapesBoundary { .. } => {}
        other => panic!("Expected PathEscapesBoundary, got {other:?}"),
    }
}

#[cfg(feature = "virtual-path")]
#[test]
#[cfg(windows)]
fn test_symlink_escape_is_rejected() {
    use std::fs;
    use std::os::windows::fs as winfs;

    let td = tempfile::tempdir().unwrap();
    let base = td.path();

    // Create directories and canonicalize to resolve Windows short names (8.3)
    let restriction_dir = {
        let p = base.join("PathBoundary");
        fs::create_dir_all(&p).unwrap();
        fs::canonicalize(&p).unwrap()
    };
    let outside_dir = {
        let p = base.join("outside");
        fs::create_dir_all(&p).unwrap();
        fs::canonicalize(&p).unwrap()
    };

    // Create symlink inside PathBoundary pointing to a directory outside the restriction.
    // On Windows this may require Developer Mode or admin; if not available, skip.
    let link_in_restriction = restriction_dir.join("link");
    if let Err(e) = winfs::symlink_dir(&outside_dir, &link_in_restriction) {
        // Permission/privilege issues: skip the test gracefully.
        if e.kind() == std::io::ErrorKind::PermissionDenied || e.raw_os_error() == Some(1314) {
            return;
        }
        panic!("failed to create symlink: {e:?}");
    }

    let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();

    // Attempt to validate a path that goes through the symlink to outside
    let err = restriction.strict_join("link/escape.txt").unwrap_err();
    match err {
        crate::StrictPathError::PathEscapesBoundary { .. } => {}
        other => panic!("Expected PathEscapesBoundary, got {other:?}"),
    }

    // VirtualRoot should CLAMP the symlink target to virtual root (new behavior in 0.4.0)
    let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();
    let clamped = vroot.virtual_join("link/escape.txt").unwrap();

    // Verify the path is clamped within the virtual root
    // Since escape.txt doesn't exist, we need to find the deepest existing ancestor
    let clamped_path = clamped.as_unvirtual().clone().unstrict();
    let mut check_path = clamped_path;

    // Walk up until we find an existing path
    while !check_path.exists() && check_path.pop() {
        // Keep popping until we find something that exists
    }

    if check_path.exists() {
        let check_canonical = fs::canonicalize(&check_path).unwrap();
        let restriction_canonical = fs::canonicalize(&restriction_dir).unwrap();
        assert!(
            check_canonical.starts_with(&restriction_canonical),
            "Virtual path should be clamped within virtual root. Got: {check_canonical:?}, Expected to start with: {restriction_canonical:?}"
        );
    }
}

#[cfg(feature = "virtual-path")]
#[test]
#[cfg(windows)]
fn test_junction_escape_is_rejected() {
    use std::fs;
    use std::process::Command;

    let td = tempfile::tempdir().unwrap();
    let base = td.path();
    let restriction_dir = base.join("PathBoundary");
    let outside_dir = base.join("outside");
    fs::create_dir_all(&restriction_dir).unwrap();
    fs::create_dir_all(&outside_dir).unwrap();

    let link_in_restriction = restriction_dir.join("jlink");
    let status = Command::new("cmd")
        .args([
            "/C",
            "mklink",
            "/J",
            &link_in_restriction.to_string_lossy(),
            &outside_dir.to_string_lossy(),
        ])
        .status();
    match status {
        Ok(s) if s.success() => {}
        _ => {
            // Junction creation failed (environment/permissions); skip
            return;
        }
    }

    // StrictPath: System filesystem semantics - junction should be rejected
    let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();
    let err = restriction.strict_join("jlink/escape.txt").unwrap_err();
    match err {
        crate::StrictPathError::PathEscapesBoundary { .. } => {}
        other => panic!("Expected PathEscapesBoundary via junction, got {other:?}"),
    }

    // VirtualPath: Virtual filesystem semantics - junction target is CLAMPED (v0.4.0 behavior)
    // The junction target /outside/escape.txt is reinterpreted as vroot/outside/escape.txt
    let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();
    let clamped = vroot
        .virtual_join("jlink/escape.txt")
        .expect("VirtualPath should clamp junction target to virtual root");

    // Verify the junction was clamped within virtual root
    let vroot_canonical = std::fs::canonicalize(&restriction_dir).unwrap();
    assert!(
        clamped.as_unvirtual().strictpath_starts_with(&vroot_canonical),
        "Junction target should be clamped within virtual root. Got: {:?}, Expected within: {vroot_canonical:?}",
        clamped.as_unvirtual().strictpath_display()
    );
}

#[cfg(feature = "virtual-path")]
#[test]
fn test_toctou_symlink_parent_attack() {
    // TOCTOU = Time-of-Check-Time-of-Use attack
    // Scenario: Path is valid at creation time, but parent becomes malicious symlink later

    let temp = tempfile::tempdir().unwrap();
    let restriction_dir = temp.path().join("PathBoundary");
    let outside_dir = temp.path().join("outside");
    std::fs::create_dir_all(&restriction_dir).unwrap();
    std::fs::create_dir_all(&outside_dir).unwrap();

    // Step 1: Create legitimate directory structure
    let subdir = restriction_dir.join("subdir");
    std::fs::create_dir(&subdir).unwrap();
    std::fs::write(subdir.join("file.txt"), "content").unwrap();

    let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();

    // Step 2: Validate path when structure is legitimate
    let file_path = restriction.strict_join("subdir/file.txt").unwrap();

    // Verify it works initially
    assert!(file_path.exists());
    let initial_parent = file_path.strictpath_parent().unwrap();
    assert!(initial_parent.is_some());

    // Step 3: ATTACK - Replace subdir with symlink pointing outside PathBoundary
    std::fs::remove_dir_all(&subdir).unwrap();

    #[cfg(unix)]
    {
        use std::os::unix::fs as unixfs;
        unixfs::symlink(&outside_dir, &subdir).unwrap();
    }

    #[cfg(windows)]
    {
        use std::os::windows::fs as winfs;
        if let Err(e) = winfs::symlink_dir(&outside_dir, &subdir) {
            // Skip test if we can't create symlinks (insufficient permissions)
            eprintln!("Skipping TOCTOU test - symlink creation failed: {e:?}");
            return;
        }
    }

    // Step 4: Now strictpath_parent() should detect the escape and fail
    let parent_result = file_path.strictpath_parent();

    match parent_result {
        Err(crate::StrictPathError::PathEscapesBoundary { .. }) => {
            // Expected - parent operation detected symlink escape
        }
        Err(crate::StrictPathError::PathResolutionError { .. }) => {
            // Also acceptable - I/O error during symlink resolution
        }
        Ok(_) => {
            panic!("SECURITY FAILURE: strictpath_parent() should have detected symlink escape!");
        }
        Err(other) => {
            panic!("Unexpected error type: {other:?}");
        }
    }
}

#[cfg(feature = "virtual-path")]
#[test]
fn test_toctou_virtual_parent_attack() {
    // Same TOCTOU attack but for VirtualPath

    let temp = tempfile::tempdir().unwrap();
    let restriction_dir = temp.path().join("PathBoundary");
    let outside_dir = temp.path().join("outside");
    std::fs::create_dir_all(&restriction_dir).unwrap();
    std::fs::create_dir_all(&outside_dir).unwrap();

    // Step 1: Create legitimate directory structure
    let subdir = restriction_dir.join("subdir");
    std::fs::create_dir(&subdir).unwrap();
    std::fs::write(subdir.join("file.txt"), "content").unwrap();

    let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();

    // Step 2: Validate virtual path when structure is legitimate
    let vfile_path = vroot.virtual_join("subdir/file.txt").unwrap();

    // Verify it works initially
    assert!(vfile_path.exists());
    let initial_parent = vfile_path.virtualpath_parent().unwrap();
    assert!(initial_parent.is_some());

    // Step 3: ATTACK - Replace subdir with symlink pointing outside PathBoundary
    std::fs::remove_dir_all(&subdir).unwrap();

    #[cfg(unix)]
    {
        use std::os::unix::fs as unixfs;
        unixfs::symlink(&outside_dir, &subdir).unwrap();
    }

    #[cfg(windows)]
    {
        use std::os::windows::fs as winfs;
        if let Err(e) = winfs::symlink_dir(&outside_dir, &subdir) {
            // Skip test if we can't create symlinks (insufficient permissions)
            eprintln!("Skipping virtual TOCTOU test - symlink creation failed: {e:?}");
            return;
        }
    }

    // Step 4: With clamping behavior (soft-canonicalize 0.4.0), virtualpath_parent()
    // should CLAMP the symlink target to virtual root instead of rejecting
    let parent_result = vfile_path.virtualpath_parent();

    match parent_result {
        Ok(Some(parent)) => {
            // New expected behavior: parent is clamped within virtual root
            // Canonicalize both paths for comparison (macOS has /var -> /private/var symlink)
            let canonical_restriction = std::fs::canonicalize(&restriction_dir).unwrap();
            assert!(
                parent.as_unvirtual().strictpath_starts_with(&canonical_restriction),
                "Parent should be clamped within virtual root. Got: {:?}, Expected to start with: {canonical_restriction:?}",
                parent.as_unvirtual().strictpath_display()
            );
        }
        Err(crate::StrictPathError::PathResolutionError { .. }) => {
            // Also acceptable - I/O error during symlink resolution
        }
        Ok(None) => {
            panic!("SECURITY FAILURE: virtualpath_parent() returned None unexpectedly!");
        }
        Err(other) => {
            panic!("Unexpected error type: {other:?}");
        }
    }
}

// Black-box: Simulate a Zip Slip-style extraction routine using VirtualRoot
// to map archive entry names to safe jailed paths. Ensure traversal-style
// entries are rejected and nothing is written outside the restriction.
#[cfg(feature = "virtual-path")]
#[test]
fn test_zip_slip_style_extraction() {
    let td = tempfile::tempdir().unwrap();
    let base = td.path();
    let restriction_dir = base.join("PathBoundary");
    std::fs::create_dir_all(&restriction_dir).unwrap();

    let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();

    // Candidate archive entries (mix of valid and malicious)
    let mut entries = vec![
        ("ok/file.txt", true),
        ("nested/dir/ok2.bin", true),
        ("../escape.txt", true),
        ("../../outside/evil.txt", true),
        ("/abs/should/fail", true),
        ("..\\..\\win\\escape.txt", true),
    ];
    // On Windows, absolute and drive-relative inputs are treated as virtual-rooted
    // requests, thus should succeed while staying inside the restriction.
    if cfg!(windows) {
        entries.push(("C:..\\Windows\\win.ini", true));
        entries.push(("C:\\Windows\\win.ini", true));
    }

    for (name, should_succeed) in entries {
        let res = vroot.virtual_join(name);
        match res {
            Ok(vp) => {
                if should_succeed {
                    // Simulate extraction: ensure parents and write
                    vp.create_parent_dir_all().unwrap();
                    vp.write("data").unwrap();
                    assert!(vp.exists());
                    // Ensure the resolved system path lives under PathBoundary
                    // Compare against the canonical PathBoundary path to avoid Windows verbatim prefix issues
                    assert!(vp
                        .as_unvirtual()
                        .strictpath_starts_with(vroot.interop_path()));
                } else {
                    panic!(
                        "Expected rejection for '{name}', but joined to {}",
                        vp.as_unvirtual().strictpath_display()
                    );
                }
            }
            Err(e) => {
                if should_succeed {
                    panic!("Expected success for '{name}', got {e:?}");
                }
                // For should_succeed == false: any error is an acceptable rejection
            }
        }
    }

    // Sanity: no files created outside PathBoundary
    assert!(!base.join("escape.txt").exists());
    assert!(!base.join("outside/evil.txt").exists());
}

// TAR-like extraction semantics: handle ./, leading /, and deep ../ entries.
#[cfg(feature = "virtual-path")]
#[test]
fn test_tar_slip_style_extraction() {
    let td = tempfile::tempdir().unwrap();
    let base = td.path();
    let restriction_dir = base.join("PathBoundary");
    std::fs::create_dir_all(&restriction_dir).unwrap();

    let vroot: crate::VirtualRoot<()> = crate::VirtualRoot::try_new(&restriction_dir).unwrap();

    let entries = vec![
        ("./ok.txt", true),
        ("./nested/./dir/file.bin", true),
        ("/abs/should/not/escape", true),
        ("../../outside/evil.txt", true),
        ("./../sneaky", true),
    ];

    for (name, should_succeed) in entries {
        match vroot.virtual_join(name) {
            Ok(vp) => {
                if should_succeed {
                    vp.create_parent_dir_all().unwrap();
                    vp.write("data").unwrap();
                    assert!(vp
                        .as_unvirtual()
                        .strictpath_starts_with(vroot.interop_path()));
                } else {
                    panic!("unexpected success for {name}");
                }
            }
            Err(e) => {
                if should_succeed {
                    panic!("expected success for {name}, got {e:?}");
                }
            }
        }
    }

    // Sanity: nothing outside base was written
    assert!(!base.join("outside/evil.txt").exists());
}

// White-box: Hard-link behavior demonstration (expected limitation).
// If a hard link inside the PathBoundary points to a file outside, writes will
// affect the outside target. This test documents the behavior.
#[cfg(feature = "virtual-path")]
#[test]
#[cfg(unix)]
fn test_hard_link_inside_to_outside_documents_limitation() {
    use std::fs::hard_link;

    let td = tempfile::tempdir().unwrap();
    let base = td.path();
    let restriction_dir = base.join("PathBoundary");
    let outside_dir = base.join("outside");
    std::fs::create_dir_all(&restriction_dir).unwrap();
    std::fs::create_dir_all(&outside_dir).unwrap();

    // Prepare an outside target file
    let outside_file = outside_dir.join("target.txt");
    std::fs::write(&outside_file, b"original").unwrap();

    // Place a hard link to it inside the PathBoundary
    let inside_link = restriction_dir.join("alias.txt");
    hard_link(&outside_file, &inside_link).unwrap();

    let restriction: PathBoundary = PathBoundary::try_new(&restriction_dir).unwrap();
    let vp = restriction
        .clone()
        .virtualize()
        .virtual_join("alias.txt")
        .expect("join should succeed within PathBoundary");

    // Write via jailed API
    vp.write("modified").unwrap();

    // Outside file reflects the change (documented limitation)
    let out = std::fs::read_to_string(&outside_file).unwrap();
    assert_eq!(out, "modified");

    // Still, the path is inside the PathBoundary from a path-boundary perspective
    assert!(vp
        .as_unvirtual()
        .strictpath_starts_with(restriction.interop_path()));
}