strict-path 0.2.1

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
#[cfg(feature = "virtual-path")]
use crate::VirtualRoot;
use crate::{PathBoundary, StrictPathError};

#[test]
#[cfg(windows)]
fn test_case_sensitivity_bypass_attack() {
    let temp = tempfile::tempdir().unwrap();
    let restriction_dir = temp.path();
    let data_dir = restriction_dir.join("data");
    std::fs::create_dir_all(&data_dir).unwrap();

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

    // Attacker uses different casing ("DATA") to try to obscure the traversal.
    let attack_path = r"DATA\..\..\windows";
    let result = restriction.strict_join(attack_path);

    match result {
        Err(StrictPathError::PathEscapesBoundary { .. }) => {
            // Correctly rejected.
        }
        Ok(p) => {
            panic!("SECURITY FAILURE: Case-sensitivity bypass was not detected. Path: {p:?}");
        }
        Err(e) => {
            panic!("Unexpected error for case-sensitivity attack: {e:?}");
        }
    }
}

#[test]
#[cfg(windows)]
fn test_ntfs_83_short_name_bypass_attack() {
    use std::process::Command;

    let temp = tempfile::tempdir().unwrap();
    let restriction_dir = temp.path();
    let long_name_dir = restriction_dir.join("long-directory-name");
    std::fs::create_dir_all(&long_name_dir).unwrap();

    // Get the 8.3 short name. This can fail if 8.3 name generation is disabled.
    let output = Command::new("cmd")
        .args(["/C", "dir /X"])
        .current_dir(restriction_dir)
        .output()
        .unwrap();

    let stdout = String::from_utf8_lossy(&output.stdout);
    let short_name = stdout
        .lines()
        .find(|line| line.contains("long-directory-name"))
        .and_then(|line| {
            line.split_whitespace()
                .find(|s| s.contains('~') && s.len() <= 12)
        });

    if let Some(short_name) = short_name {
        let restriction: PathBoundary = PathBoundary::try_new(restriction_dir).unwrap();
        let attack_path = format!(r"{short_name}\..\..\windows");
        let result = restriction.strict_join(&attack_path);

        match result {
            Err(StrictPathError::PathEscapesBoundary { .. }) => {
                // Correctly rejected by the traversal check.
                // The canonicalization expands the short name, and the boundary check
                // detects the ".." traversal attempts.
            }
            Ok(p) => {
                // With the new approach, the path might be allowed if it stays within the boundary.
                // Verify it's actually inside the restriction.
                let p_canon = std::fs::canonicalize(p.interop_path()).unwrap();
                assert!(
                    p_canon.starts_with(restriction_dir),
                    "Path {p_canon:?} should be inside boundary {restriction_dir:?}"
                );
            }
            Err(e) => {
                panic!("Unexpected error for 8.3 short name test: {e:?}");
            }
        }
    } else {
        eprintln!("Skipping 8.3 short name test: could not determine short name for 'long-directory-name'.");
    }
}

#[cfg(feature = "virtual-path")]
#[test]
fn test_advanced_toctou_read_race_condition() {
    let temp = tempfile::tempdir().unwrap();

    // Create directories - on Windows, we need to handle 8.3 short names
    // VirtualRoot will canonicalize internally (expanding short names and adding \\?\)
    // We need to create symlinks using the SAME canonical form to avoid path mismatches
    let restriction_dir = {
        let p = temp.path().join("restriction");
        std::fs::create_dir_all(&p).unwrap();

        #[cfg(not(windows))]
        {
            std::fs::canonicalize(&p).unwrap()
        }
        #[cfg(windows)]
        {
            // On Windows: canonicalize to expand 8.3 short names (RUNNER~1 → runneradmin)
            // but strip the \\?\ prefix to keep symlink creation working
            let canonical = std::fs::canonicalize(&p).unwrap();
            let canonical_str = canonical.to_string_lossy();

            if let Some(stripped) = canonical_str.strip_prefix(r"\\?\") {
                std::path::PathBuf::from(stripped)
            } else {
                canonical
            }
        }
    };

    let safe_dir = restriction_dir.join("safe");
    std::fs::create_dir_all(&safe_dir).unwrap();

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

    // Create the files - no need to canonicalize since we're using relative symlink targets
    let _safe_file = safe_dir.join("file.txt");
    std::fs::write(&_safe_file, "safe content").unwrap();

    let _outside_file = outside_dir.join("secret.txt");
    std::fs::write(&_outside_file, "secret content").unwrap();

    let link_path = restriction_dir.join("link");

    // Initially, link points to the safe file via a RELATIVE target.
    // Using relative targets avoids absolute path quirks on Windows runners (short names/privileges).
    #[cfg(unix)]
    {
        let rel_target = std::path::Path::new("safe").join("file.txt");
        std::os::unix::fs::symlink(&rel_target, &link_path).unwrap();
    }
    #[cfg(windows)]
    {
        let rel_target = std::path::Path::new("safe").join("file.txt");
        if let Err(e) = std::os::windows::fs::symlink_file(&rel_target, &link_path) {
            eprintln!("Skipping TOCTOU test - symlink creation failed: {e:?}");
            return;
        }
    }

    let vroot: VirtualRoot = VirtualRoot::try_new(&restriction_dir).unwrap();
    let path_object = vroot.virtual_join("link").unwrap();

    // Verify it points to the safe file initially.
    // On some Windows setups, relative symlink resolution may transiently return NotFound;
    // treat that as acceptable for the initial sanity check to avoid spurious panics.

    match path_object.read_to_string() {
        Ok(content) => {
            assert_eq!(
                content, "safe content",
                "Initial TOCTOU read returned unexpected data; expected safe content"
            );
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            // Acceptable on some Windows setups - continuing test
        }
        Err(e) => {
            panic!("Unexpected error for initial TOCTOU read: {e:?}");
        }
    }

    // ATTACK: In another thread, swap the symlink to point outside (use RELATIVE escape).
    #[cfg(unix)]
    {
        std::fs::remove_file(&link_path).unwrap();
        let rel_escape = std::path::Path::new("..")
            .join("outside")
            .join("secret.txt");
        std::os::unix::fs::symlink(&rel_escape, &link_path).unwrap();
    }
    #[cfg(windows)]
    {
        std::fs::remove_file(&link_path).unwrap();
        let rel_escape = std::path::Path::new("..")
            .join("outside")
            .join("secret.txt");
        if let Err(e) = std::os::windows::fs::symlink_file(&rel_escape, &link_path) {
            eprintln!("Skipping TOCTOU test - symlink re-creation failed: {e:?}");
            return;
        }
    }

    // With symlink clamping (0.4.0), the swapped symlink is clamped to virtual root.
    // The outside file path gets clamped to restriction_dir/outside/secret.txt (doesn't exist).
    // Expected outcomes:
    // 1. NotFound error (clamped path doesn't exist) - acceptable, shows clamping worked
    // 2. Safe content (if symlink swap happened after validation) - acceptable
    // 3. PathEscapesBoundary (if escape detected before clamping) - acceptable

    let result = path_object.read_to_string();

    match result {
        Err(e) if e.kind() == std::io::ErrorKind::Other => {
            let inner_err = e.into_inner().unwrap();
            if let Some(strict_err) = inner_err.downcast_ref::<StrictPathError>() {
                assert!(
                    matches!(strict_err, StrictPathError::PathEscapesBoundary { .. }),
                    "Expected PathEscapesBoundary but got {strict_err:?}",
                );
            } else {
                panic!("Expected StrictPathError but got a different error type.");
            }
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            // Acceptable: symlink was clamped to virtual root, resulting in non-existent path
        }
        Ok(content) => {
            assert_eq!(
                content, "safe content",
                "TOCTOU read returned unexpected data; possible escape"
            );
        }
        Err(e) => {
            panic!("Unexpected error for TOCTOU read race: {e:?}");
        }
    }
}

#[test]
fn test_environment_variable_injection() {
    let temp = tempfile::tempdir().unwrap();
    let restriction: PathBoundary = PathBoundary::try_new(temp.path()).unwrap();

    let patterns = if cfg!(windows) {
        vec![r"..\%WINDIR%\System32", r"..\%TEMP%\file"]
    } else {
        vec!["../$HOME/.ssh/id_rsa", "../$TMPDIR/file"]
    };

    for pattern in patterns {
        let result = restriction.strict_join(pattern);
        // The path should be treated literally. Since it contains '..', it should be rejected
        // as a traversal attempt, NOT expanded.
        match result {
            Err(StrictPathError::PathEscapesBoundary { .. }) => {
                // Correctly rejected as a literal traversal.
            }
            Ok(p) => {
                panic!("SECURITY FAILURE: Environment variable was likely expanded. Path: {p:?}");
            }
            Err(e) => {
                // On some platforms, characters like '$' or '%' might be invalid in paths,
                // leading to a different error. This is also a safe outcome.
                assert!(matches!(e, StrictPathError::PathResolutionError { .. }));
            }
        }
    }
}

/// Simulates GitHub Windows runner environment where parent directories contain 8.3 short names.
/// This test verifies that existing paths with 8.3 names in parent dirs are handled correctly.
#[test]
#[cfg(windows)]
fn test_github_runner_short_name_scenario_existing_paths() {
    use std::process::Command;

    // Create a temp directory and a subdirectory with a long name
    let temp = tempfile::tempdir().unwrap();
    let long_name_dir = temp
        .path()
        .join("very-long-directory-name-that-triggers-8dot3");
    std::fs::create_dir_all(&long_name_dir).unwrap();

    // Try to get the 8.3 short name using Windows dir command
    let output = Command::new("cmd")
        .args(["/C", "dir", "/X"])
        .current_dir(temp.path())
        .output();

    let short_name = if let Ok(output) = output {
        let stdout = String::from_utf8_lossy(&output.stdout);
        stdout
            .lines()
            .find(|line| line.contains("very-long-directory-name"))
            .and_then(|line| {
                line.split_whitespace()
                    .find(|s| s.contains('~') && s.len() <= 12)
            })
            .map(|s| s.to_string())
    } else {
        None
    };

    if let Some(short_name) = short_name {
        eprintln!("Found 8.3 short name: {short_name}");

        // Test 1: PathBoundary should work with the long name (path exists)
        let test_dir: PathBoundary = PathBoundary::try_new(&long_name_dir)
            .expect("Should create boundary from existing long-named directory");

        // Create a file inside
        let test_file = long_name_dir.join("test.txt");
        std::fs::write(&test_file, "content").unwrap();

        // Test 2: strict_join with regular path should work
        let joined = test_dir
            .strict_join("test.txt")
            .expect("Should join to existing file");
        assert!(joined.exists());

        // Test 3: Using short name in INPUT should also work if path exists
        // (because canonicalization will expand it)
        let short_path = temp.path().join(&short_name).join("test.txt");
        eprintln!("Attempting to access via short path: {short_path:?}");

        // Verify the short path actually works at OS level
        if short_path.exists() {
            eprintln!("Short path exists at OS level, testing strict_join...");
            // If we try to create a boundary using the short name path
            let short_name_dir_result: Result<PathBoundary, _> =
                PathBoundary::try_new(temp.path().join(&short_name));
            match short_name_dir_result {
                Ok(short_name_dir) => {
                    eprintln!("Created boundary via short name (canonicalization expanded it)");
                    // Should be able to join
                    let via_short = short_name_dir.strict_join("test.txt");
                    assert!(via_short.is_ok(), "Should handle short name in parent path");
                }
                Err(e) => {
                    eprintln!("Could not create boundary via short name: {e:?}");
                }
            }
        }
    } else {
        eprintln!("Skipping test: Could not determine 8.3 short name (might be disabled)");
    }
}

/// Tests that paths containing Windows 8.3 short name patterns are handled correctly
/// through canonicalization + boundary check, without explicit short name rejection.
/// The security is maintained by the mathematical property that canonicalized paths
/// can't escape their canonicalized boundary.
#[test]
#[cfg(windows)]
fn test_short_name_patterns_handled_via_canonicalization() {
    let temp = tempfile::tempdir().unwrap();
    let test_dir: PathBoundary = PathBoundary::try_new(temp.path()).unwrap();

    // Create a real directory with a pattern that looks like a short name
    let dir_with_tilde = temp.path().join("TEST~1");
    std::fs::create_dir_all(&dir_with_tilde).unwrap();
    std::fs::write(dir_with_tilde.join("file.txt"), b"content").unwrap();

    // This should succeed - canonicalization handles it
    let result = test_dir.strict_join("TEST~1/file.txt");
    assert!(
        result.is_ok(),
        "Should accept paths with ~N pattern when they exist: {result:?}"
    );

    // Try a non-existent path with short name pattern
    let result = test_dir.strict_join("ABCDEF~1/file.txt");
    // Will fail during canonicalization (path doesn't exist), which is fine
    if let Err(e) = result {
        assert!(
            matches!(e, StrictPathError::PathResolutionError { .. }),
            "Non-existent paths fail during canonicalization: {e:?}"
        );
    }
}

/// Tests symlink clamping with 8.3 short names in the clamped path.
/// When a symlink points outside and gets clamped, the clamped path might not exist
/// and could contain unexpanded 8.3 short names. This is acceptable - the path
/// validation allows it, and the I/O operation will naturally fail.
#[cfg(feature = "virtual-path")]
#[test]
#[cfg(windows)]
fn test_github_runner_clamped_symlink_with_short_names() {
    use std::os::windows::fs as winfs;

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

    // Create a file outside
    let outside_file = outside_dir.join("secret.txt");
    std::fs::write(&outside_file, "secret").unwrap();

    // Create symlink inside pointing outside
    let link_inside = restriction_dir.join("link");
    if let Err(e) = winfs::symlink_file(&outside_file, &link_inside) {
        eprintln!("Skipping test - symlink creation failed: {e:?}");
        return;
    }

    // Create VirtualRoot - should succeed
    let vroot: VirtualRoot = VirtualRoot::try_new(&restriction_dir)
        .expect("Should create VirtualRoot even if temp path has short names in parents");

    // Try to access the symlink through virtual join
    // This should succeed (clamping behavior), but the clamped path won't exist
    let result = vroot.virtual_join("link");

    match result {
        Ok(clamped_path) => {
            let clamped = clamped_path.virtualpath_display();
            eprintln!("Symlink was clamped successfully: {clamped}");

            // The clamped path should be within the boundary - just verify it's accessible
            eprintln!("Clamped virtual path: {clamped}");

            // Try to read - should fail because clamped path doesn't exist
            let read_result = clamped_path.read_to_string();
            assert!(
                read_result.is_err(),
                "Reading clamped symlink should fail (doesn't exist)"
            );
            eprintln!("Read correctly failed: {:?}", read_result.unwrap_err());
        }
        Err(StrictPathError::PathResolutionError { .. }) => {
            // Acceptable: I/O error during resolution (e.g., on GitHub runners)
            eprintln!("Test passed: PathResolutionError during symlink resolution");
        }
        Err(e) => {
            panic!("Unexpected error: {e:?}");
        }
    }
}