soft-canonicalize 0.5.6

Path canonicalization that works with non-existing paths.
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
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
//! Windows-specific platform tests for soft_canonicalize
//!
//! Tests Windows path formats: drive letters, UNC paths, extended-length prefixes,
//! device namespace, short filenames, and symlink ancestors.

#[cfg(windows)]
use crate::soft_canonicalize;

#[cfg(windows)]
use std::path::PathBuf;

#[cfg(windows)]
#[test]
fn test_windows_specific_paths() -> std::io::Result<()> {
    // Test Windows-specific path formats to validate cross-platform claims

    // Test UNC path format (if possible)
    let unc_style = r"\\?\C:\temp\non\existing\file.txt";
    if let Ok(result) = soft_canonicalize(unc_style) {
        assert!(result.is_absolute());
        assert!(result.to_string_lossy().contains("file.txt"));
    }

    // Test drive letter paths
    let drive_path = "C:/non/existing/file.txt";
    let result = soft_canonicalize(drive_path)?;
    assert!(result.is_absolute());
    assert!(result.to_string_lossy().contains("file.txt"));

    // Test mixed separators (Windows should handle both / and \)
    let mixed_path = r"C:\non/existing\file.txt";
    let result = soft_canonicalize(mixed_path)?;
    assert!(result.is_absolute());
    assert!(result.to_string_lossy().contains("file.txt"));

    Ok(())
}

#[cfg(windows)]
#[test]
fn test_windows_unc_nonexistent_absolute_has_expected_prefix() {
    // Explicitly validate the extended-length prefix formatting for a non-existent absolute path
    let got = soft_canonicalize(r"C:\NonExistent\Path\That\Does\Not\Exist").unwrap();

    #[cfg(not(feature = "dunce"))]
    {
        // WITHOUT dunce: MUST return UNC format (\\?\C:\...)
        let expected = PathBuf::from(r"\\?\C:\\NonExistent\Path\That\Does\Not\Exist");
        assert_eq!(
            got, expected,
            "Without dunce feature, must return extended-length UNC format"
        );
        assert!(
            got.to_string_lossy().starts_with(r"\\?\"),
            "Expected UNC prefix \\\\?\\, got: {}",
            got.display()
        );
    }

    #[cfg(feature = "dunce")]
    {
        // WITH dunce: Safe path (no reserved names, <260 chars, no ..) should be simplified
        let got_str = got.to_string_lossy();
        assert!(
            !got_str.starts_with(r"\\?\"),
            "With dunce feature, safe paths should be simplified (no \\\\?\\), got: {}",
            got_str
        );
        // Verify it ends with the expected path components
        assert!(got_str.ends_with(r"NonExistent\Path\That\Does\Not\Exist"));
    }
}
#[cfg(windows)]
#[test]
fn test_windows_nonexistent_jail_starts_with_consistency() {
    // Given a non-existent absolute jail, canonicalize jail and an inside child
    let jail_raw = r"C:\NonExistent\Path\That\Does\Not\Exist";
    let child_raw = format!("{jail_raw}\\foo.txt");

    let jail = soft_canonicalize(jail_raw).expect("canonicalize jail");
    let child = soft_canonicalize(child_raw).expect("canonicalize child");
    let expected = jail.join("foo.txt");
    assert_eq!(child, expected);
}

#[cfg(windows)]
#[test]
fn test_windows_extended_prefix_idempotent_for_nonexistent() {
    // Canonicalizing a verbatim (\\?\) path should be idempotent or simplified
    let verbatim = PathBuf::from(r"\\?\C:\\NonExistent\Path\That\Does\Not\Exist");
    let again = soft_canonicalize(&verbatim).expect("canonicalize verbatim");

    #[cfg(not(feature = "dunce"))]
    {
        // WITHOUT dunce: Verbatim path stays verbatim (idempotent)
        assert_eq!(
            again, verbatim,
            "Without dunce, verbatim path canonicalization must be idempotent"
        );
    }

    #[cfg(feature = "dunce")]
    {
        // WITH dunce: May simplify safe paths
        let again_str = again.to_string_lossy();
        let verbatim_str = verbatim.to_string_lossy();

        let again_normalized = PathBuf::from(again_str.strip_prefix(r"\\?\").unwrap_or(&again_str));
        let verbatim_normalized =
            PathBuf::from(verbatim_str.strip_prefix(r"\\?\").unwrap_or(&verbatim_str));

        assert_eq!(
            again_normalized, verbatim_normalized,
            "With dunce, normalized paths must be equivalent"
        );
    }
}
#[cfg(windows)]
#[test]
fn test_windows_unc_server_share_nonexistent_starts_with() {
    // UNC server/share should become \\?\UNC\server\share and preserve starts_with
    let jail_raw = r"\\server\share\nonexistent";
    let child_raw = format!("{jail_raw}\\foo.txt");

    let jail = soft_canonicalize(jail_raw).expect("canonicalize UNC jail");
    let child = soft_canonicalize(child_raw).expect("canonicalize UNC child");
    let expected = jail.join("foo.txt");
    assert_eq!(child, expected);
}

#[cfg(windows)]
#[test]
fn test_windows_unc_root_canonicalizes_to_verbatim_unc() {
    // Bare UNC server/share should become an extended-length UNC prefix
    let input = r"\\server\share";
    let got = soft_canonicalize(input).expect("canonicalize UNC root");
    assert_eq!(got, PathBuf::from(r"\\?\UNC\server\share"));
}

#[cfg(windows)]
#[test]
fn test_windows_unc_root_with_trailing_separator_idempotent() {
    // Adding trailing separators or . should not change the UNC root semantics
    let base = PathBuf::from(r"\\server\share");
    let variants = [
        PathBuf::from(r"\\server\share\\"),
        PathBuf::from(r"\\server\share\."),
        PathBuf::from(r"\\?\UNC\server\share\\"),
        PathBuf::from(r"\\?\UNC\server\share\."),
    ];

    let canonical_base = soft_canonicalize(&base).expect("canonicalize UNC base");

    #[cfg(not(feature = "dunce"))]
    {
        // WITHOUT dunce: All should normalize to \\?\UNC\server\share (no trailing separator)
        let expected = PathBuf::from(r"\\?\UNC\server\share");
        assert_eq!(
            canonical_base, expected,
            "Base should be \\\\?\\UNC\\server\\share"
        );

        for v in variants {
            let got = soft_canonicalize(&v).expect("canonicalize UNC root variant");
            assert_eq!(
                got, expected,
                "Variant {v:?} should normalize to {expected:?}"
            );
        }
    }

    #[cfg(feature = "dunce")]
    {
        // WITH dunce: All variants should normalize to the same result (may be simplified)
        let canonical_base_str = canonical_base.to_string_lossy();
        let canonical_base_normalized = PathBuf::from(
            canonical_base_str
                .strip_prefix(r"\\?\")
                .unwrap_or(&canonical_base_str),
        );

        for v in variants {
            let got = soft_canonicalize(&v).expect("canonicalize UNC root variant");
            let got_str = got.to_string_lossy();
            let got_normalized = PathBuf::from(got_str.strip_prefix(r"\\?\").unwrap_or(&got_str));
            assert_eq!(
                got_normalized, canonical_base_normalized,
                "Variant {v:?} should normalize to same as base"
            );
        }
    }
}

#[cfg(windows)]
#[test]
fn test_windows_unc_very_deep_stress_fast() {
    // Stress-lite: deep UNC path to guard against regressions, tuned to be fast in CI.
    let mut p = PathBuf::from(r"\\?\UNC\server\share");
    for i in 0..400u32 {
        // ~400 components keeps runtime low while still deep
        p.push(format!("dir{i:04}"));
    }
    p.push("leaf.txt");

    let got = soft_canonicalize(&p).expect("canonicalize very deep UNC");
    assert_eq!(got, p);
}

#[cfg(windows)]
#[test]
fn test_windows_verbatim_unc_idempotent() {
    // Already verbatim UNC should be returned unchanged
    let input = PathBuf::from(r"\\?\UNC\server\share\path\to\file.txt");
    let got = soft_canonicalize(&input).expect("canonicalize verbatim UNC");
    assert_eq!(got, input);
}

#[cfg(windows)]
#[test]
fn test_windows_unc_mixed_separators_are_normalized() {
    // Mixed separators should normalize and preserve UNC semantics
    let input = r"\\server\share/mixed\\seps/dir\file.txt";
    let got = soft_canonicalize(input).expect("canonicalize UNC with mixed separators");
    let expected = PathBuf::from(r"\\?\UNC\server\share\mixed\seps\dir\file.txt");
    assert_eq!(got, expected);
}

#[cfg(windows)]
#[test]
fn test_windows_unc_dotdot_does_not_escape_share_root() {
    // ".." cannot climb above the share root; it should clamp at \\server\share
    let input = r"\\server\share\folder\..\..\sibling\file.txt";
    let got = soft_canonicalize(input).expect("canonicalize UNC with dotdot");
    // Should clamp to the share root and resolve to this exact path
    assert_eq!(got, PathBuf::from(r"\\?\UNC\server\share\sibling\file.txt"));
}

#[cfg(windows)]
#[test]
fn test_windows_unc_preserves_shortname_like_component_for_nonexistent() {
    // For non-existing paths, 8.3-like components are preserved (no expansion)
    let input = r"\\server\share\PROGRA~1\foo.txt";
    let got = soft_canonicalize(input).expect("canonicalize UNC with shortname-like component");
    assert!(got.ends_with(PathBuf::from(r"PROGRA~1\foo.txt")));
}

#[cfg(windows)]
#[test]
fn test_windows_unc_preserves_trailing_dot_and_space_in_names() {
    // With extended-length prefix, Windows does not strip trailing dots/spaces
    let input = r"\\server\share\dir. \file. txt"; // component names ending with dot/space
    let got = soft_canonicalize(input).expect("canonicalize UNC with trailing dot/space");
    assert!(got.ends_with(PathBuf::from(r"dir. \file. txt")));
}

#[cfg(windows)]
#[test]
fn test_windows_multiple_drive_letters_produce_verbatim_disk_prefix() {
    // Validate we format extended-length prefixes for a range of drive letters
    for drive in ['C', 'D', 'E', 'Z'] {
        let input = format!(r"{drive}:\nonexistent\child.txt");
        let got = soft_canonicalize(&input).expect("canonicalize drive letter path");
        let got_str = got.to_string_lossy();

        #[cfg(not(feature = "dunce"))]
        {
            // WITHOUT dunce: MUST have UNC prefix
            let expected_starts = format!(r"\\?\{drive}:\");
            assert!(
                got_str.starts_with(&expected_starts),
                "Without dunce, expected \\\\?\\{drive}:\\ prefix, got: {got_str}"
            );
        }

        #[cfg(feature = "dunce")]
        {
            // WITH dunce: Safe paths should be simplified (no UNC prefix)
            assert!(
                !got_str.starts_with(r"\\?\"),
                "With dunce, safe paths should not have \\\\?\\ prefix, got: {got_str}"
            );
            let expected_starts = format!(r"{drive}:\");
            assert!(
                got_str.starts_with(&expected_starts),
                "Expected {drive}:\\ prefix, got: {got_str}"
            );
        }

        // Both cases: should end with the non-existing suffix
        assert!(
            got.ends_with(PathBuf::from(r"nonexistent\child.txt")),
            "Result should end with suffix for drive {drive}: {got:?}"
        );
    }
}
#[cfg(windows)]
#[test]
fn test_windows_raw_vs_canonicalized_starts_with_is_false() {
    // Documented behavior: mixing raw jail with canonicalized child should not pass starts_with
    let jail_raw = PathBuf::from(r"C:\NonExistent\Path\That\Does\Not\Exist");
    let child = soft_canonicalize(jail_raw.join("foo.txt")).expect("canonicalize child");

    #[cfg(not(feature = "dunce"))]
    {
        // WITHOUT dunce: Raw (C:\...) vs canonical (\\?\C:\...) are different formats
        assert!(
            !child.starts_with(&jail_raw),
            "Raw jail (C:\\...) must not match canonicalized child (\\\\?\\C:\\...) in starts_with"
        );

        // Verify child IS in UNC format
        assert!(child.to_string_lossy().starts_with(r"\\?\"));
    }

    #[cfg(feature = "dunce")]
    {
        // WITH dunce: Both may be simplified to same format, so we need to canonicalize jail too
        let jail_canonical = soft_canonicalize(&jail_raw).expect("canonicalize jail");

        // Verify semantic relationship (normalize for comparison)
        let child_str = child.to_string_lossy();
        let jail_canonical_str = jail_canonical.to_string_lossy();

        let child_normalized = PathBuf::from(child_str.strip_prefix(r"\\?\").unwrap_or(&child_str));
        let jail_normalized = PathBuf::from(
            jail_canonical_str
                .strip_prefix(r"\\?\")
                .unwrap_or(&jail_canonical_str),
        );

        assert!(
            child_normalized.starts_with(jail_normalized),
            "Child should be under canonicalized jail (normalized)"
        );
    }
}

#[cfg(windows)]
#[test]
fn test_windows_relative_path_becomes_absolute_with_extended_prefix() {
    use std::env;
    let rel = r".\non\existent\file.txt";
    let abs = soft_canonicalize(rel).expect("canonicalize relative");
    assert!(abs.is_absolute());

    let cwd = soft_canonicalize(env::current_dir().unwrap()).expect("canonicalize cwd");
    let expected = cwd.join(r"non\existent\file.txt");
    assert_eq!(abs, expected);
}

#[cfg(windows)]
#[test]
fn test_windows_nonexistent_shortname_component_preserved() {
    // Non-existent 8.3-like component should be preserved (no expansion attempt)
    let p = r"C:\NonExistent\PROGRA~1\foo.txt";
    let got = soft_canonicalize(p).expect("canonicalize with shortname component");
    assert!(
        got.ends_with(PathBuf::from(r"PROGRA~1\foo.txt")),
        "Expected to preserve shortname component in non-existent path: {got:?}"
    );
}

#[cfg(windows)]
#[test]
fn test_windows_false_positive_tilde_names_not_treated_as_short() {
    // Ensure that legitimate filenames with tildes are not treated as 8.3 short names
    let test_cases = vec![
        r"C:\Users\test\hello~world.txt",
        r"C:\Projects\backup~file.doc",
        r"C:\Config\settings~old.json",
        r"C:\Temp\test~project\file.txt",
    ];

    for test_path in test_cases {
        let got = soft_canonicalize(test_path).expect("canonicalize regular tilde filename");
        // These should be processed normally without any special short name handling
        assert!(
            got.to_string_lossy().contains('~'),
            "Tilde should be preserved in regular filename: {got:?}"
        );
    }
}

#[cfg(windows)]
#[test]
fn test_windows_actual_short_name_detection() {
    // Test that actual 8.3 patterns are correctly identified
    let short_name_paths = vec![
        r"C:\PROGRA~1\MyApp\config.txt",
        r"C:\Users\RUNNER~1\Documents\file.txt",
        r"C:\Temp\LONGFI~1.TXT",
    ];

    for test_path in short_name_paths {
        let got = soft_canonicalize(test_path).expect("canonicalize short name path");
        // The path should be processed (exact result depends on filesystem state)
        // but the important thing is it doesn't crash and produces a valid result
        assert!(got.is_absolute(), "Result should be absolute: {got:?}");
    }
}

#[cfg(windows)]
#[test]
fn test_windows_device_namespace_lexical_only_pipe() {
    // Device namespace paths should be treated lexically: preserve prefix, normalize dot/dotdot
    let input = r"\\.\PIPE\name\..\other";
    let got = soft_canonicalize(input).expect("canonicalize device namespace (PIPE)");
    assert_eq!(got, PathBuf::from(r"\\.\PIPE\other"));
}

#[cfg(windows)]
#[test]
fn test_windows_device_namespace_globalroot_lexical() {
    let input = r"\\?\GLOBALROOT\Device\HarddiskVolume1\foo\.\bar\..\baz";
    let got = soft_canonicalize(input).expect("canonicalize GLOBALROOT path lexically");
    assert_eq!(
        got,
        PathBuf::from(r"\\?\GLOBALROOT\Device\HarddiskVolume1\foo\baz")
    );
}

#[cfg(windows)]
#[test]
fn test_windows_device_namespace_colon_is_rejected_as_ads() {
    // Colon-containing component must be final even under DeviceNS; reject malformed ADS placement
    let input = r"\\.\PIPE\name:stream\..\other";
    let err = soft_canonicalize(input).expect_err("colon in non-final component must be invalid");
    assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}

#[cfg(windows)]
#[test]
fn test_windows_device_namespace_idempotent_for_physicaldrive() {
    let input = PathBuf::from(r"\\.\PhysicalDrive0");
    let got = soft_canonicalize(&input).expect("canonicalize PhysicalDrive0 lexically");
    assert_eq!(got, input);
}

#[cfg(windows)]
#[test]
fn test_windows_device_namespace_parent_clamps_at_prefix() {
    // Parent traversal is lexical and clamps at the device prefix (\\. or \\?\GLOBALROOT)
    // It is NOT clamped at device class (e.g., PIPE); components can be popped until the prefix.
    let input = r"\\.\PIPE\name\..\..\other";
    let got = soft_canonicalize(input).expect("canonicalize device namespace with double dotdot");
    // In DeviceNS, the device class (e.g., PIPE) is part of the prefix per std::path parsing,
    // so parent traversal cannot pop it. Expected: \\.\PIPE\other
    assert_eq!(got, PathBuf::from(r"\\.\PIPE\other"));
}

#[cfg(windows)]
#[test]
fn test_windows_trailing_spaces_and_dots_preserved_verbatim() {
    // Non-existing verbatim-safe cases: trailing spaces/dots must be preserved; no dunce simplification
    let p1 = r"C:\NonExistent\trailing\file. ";
    let p2 = r"C:\NonExistent\trailing\file..";

    let got1 = soft_canonicalize(p1).expect("canonicalize trailing space");
    let got2 = soft_canonicalize(p2).expect("canonicalize trailing dots");

    let expected1 = PathBuf::from(r"\\?\C:\\NonExistent\trailing\file. ");
    let expected2 = PathBuf::from(r"\\?\C:\\NonExistent\trailing\file..");

    assert_eq!(got1, expected1);
    assert_eq!(got2, expected2);
}

#[cfg(windows)]
#[test]
fn test_windows_symlink_ancestor_nonexisting_tail_matches_std() -> std::io::Result<()> {
    // Create a directory symlink to an existing target dir. If symlink creation is denied,
    // skip the test to avoid flakiness on environments without privilege.
    use std::fs;
    let td = tempfile::tempdir()?;
    let base = td.path();
    let target = base.join("target_dir");
    fs::create_dir(&target)?;
    let link = base.join("link_dir");

    match std::os::windows::fs::symlink_dir(&target, &link) {
        Ok(()) => {
            let leaf = "child_nonexist.txt";
            let got = soft_canonicalize(link.join(leaf))?;
            let expected = std::fs::canonicalize(&target)?.join(leaf);

            #[cfg(not(feature = "dunce"))]
            {
                // Without dunce: exact UNC match with std::fs::canonicalize
                assert_eq!(got, expected);
            }

            #[cfg(feature = "dunce")]
            {
                // With dunce: our result is simplified, std is UNC
                let got_str = got.to_string_lossy();
                let expected_str = expected.to_string_lossy();
                assert!(!got_str.starts_with(r"\\?\"), "dunce should simplify path");
                assert!(expected_str.starts_with(r"\\?\"), "std returns UNC");
                // Verify same logical path (strip UNC prefix for comparison)
                assert_eq!(got_str.as_ref(), expected_str.trim_start_matches(r"\\?\"));
            }
        }
        Err(e) => {
            eprintln!("Skipping symlink ancestor test due to symlink privilege error: {e}");
        }
    }
    Ok(())
}