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
// Unicode, null byte, and encoding edge case tests
use crate::soft_canonicalize;
use std::path::Path;
use tempfile::TempDir;
#[test]
fn test_unicode_path_edge_cases() -> std::io::Result<()> {
// WHITE-BOX: Test Unicode edge cases that might break path handling
let temp_dir = TempDir::new()?;
let base = temp_dir.path();
// Test various Unicode edge cases
let unicode_paths = vec![
"café/naïve.txt", // Accented characters
"🦀/rust.txt", // Emoji
"test\u{200B}hidden.txt", // Zero-width space
"file\u{FEFF}bom.txt", // BOM character
"résumé/тест.txt", // Mixed scripts
"🏴/flag.txt", // Complex emoji sequence
];
for unicode_path in unicode_paths {
let full_path = base.join(unicode_path);
let result = soft_canonicalize(&full_path);
match result {
Ok(canonical) => {
assert!(canonical.is_absolute());
// Should preserve Unicode correctly
assert!(canonical
.to_string_lossy()
.contains(unicode_path.split('/').next_back().unwrap()));
}
Err(e) => {
// Some Unicode might be rejected by filesystem, that's OK
println!("Unicode path rejected (acceptable): {unicode_path} - {e}");
}
}
}
Ok(())
}
#[test]
fn test_null_byte_injection() -> std::io::Result<()> {
// WHITE-BOX: Test null byte injection attempts
#[cfg(unix)]
{
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
// Try to create path with embedded null byte
let null_path = OsStr::from_bytes(b"test\0hidden.txt");
let path = Path::new(null_path);
let result = soft_canonicalize(path);
assert!(result.is_err(), "Should reject null bytes in path");
let error = result.unwrap_err();
assert!(
error.to_string().contains("null byte")
|| error.kind() == std::io::ErrorKind::InvalidInput
);
}
#[cfg(windows)]
{
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
// Try to create path with embedded null wide character
let null_path: OsString = OsString::from_wide(&[116, 101, 115, 116, 0, 46, 116, 120, 116]); // "test\0.txt"
let path = Path::new(&null_path);
let result = soft_canonicalize(path);
assert!(result.is_err(), "Should reject null wide chars in path");
}
Ok(())
}
#[test]
fn test_null_byte_injection_extended() {
// A path containing a null byte.
let path_with_null = "a/b\0c/d";
let result = soft_canonicalize(path_with_null);
// The behavior of `soft_canonicalize` should be to either return an error
// or to handle the path correctly without truncation.
// On Unix, this will likely result in an `InvalidInput` error.
// On Windows, the behavior might differ, but it should not truncate the path.
if let Ok(canonical_path) = result {
// If it succeeds, it should not be truncated at the null byte.
assert!(!canonical_path.to_str().unwrap().contains('\0'));
} else {
// An error is an acceptable outcome.
assert!(result.is_err());
}
}
#[test]
fn test_null_byte_error_consistency() {
// Test that soft_canonicalize behaves consistently with std::fs::canonicalize
// for null byte handling
let path_with_null = "test\0path";
// Get the error from std::fs::canonicalize
let std_result = std::fs::canonicalize(path_with_null);
let soft_result = soft_canonicalize(path_with_null);
// Both should fail
assert!(
std_result.is_err(),
"std::fs::canonicalize should reject null bytes"
);
assert!(
soft_result.is_err(),
"soft_canonicalize should reject null bytes"
);
let std_error = std_result.unwrap_err();
let soft_error = soft_result.unwrap_err();
// Both should use the same error kind
assert_eq!(
std_error.kind(),
soft_error.kind(),
"Error kinds should match: std={:?}, soft={:?}",
std_error.kind(),
soft_error.kind()
);
// Both should be InvalidInput
assert_eq!(std_error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(soft_error.kind(), std::io::ErrorKind::InvalidInput);
// The error messages should be meaningful (though they may differ slightly)
let std_msg = std_error.to_string().to_lowercase();
let soft_msg = soft_error.to_string().to_lowercase();
// Both should mention null or nul in some form
assert!(
std_msg.contains("nul") || soft_msg.contains("null"),
"Error messages should reference null bytes: std='{std_error}', soft='{soft_error}'"
);
}
#[test]
fn test_unicode_normalization_bypass_prevention() {
// Test protection against Unicode normalization bypass attacks
// Some path canonicalization functions can be bypassed using Unicode
// characters that normalize differently
// Test with Unicode that could potentially bypass path restrictions
let unicode_path = "documents/user\u{0041}\u{0301}/file.txt"; // A with combining acute accent
let normalized_path = "documents/userÁ/file.txt"; // Precomposed A with acute
let result1 = soft_canonicalize(unicode_path);
let result2 = soft_canonicalize(normalized_path);
assert!(result1.is_ok());
assert!(result2.is_ok());
// Our function should handle Unicode consistently
// (The exact behavior may vary by OS, but should be consistent)
let path1 = result1.unwrap();
let path2 = result2.unwrap();
// Both should be absolute paths
assert!(path1.is_absolute());
assert!(path2.is_absolute());
}
#[test]
fn test_double_encoding_bypass_prevention() {
// Test protection against double-encoding bypass attacks
// Attackers sometimes use URL encoding or other encoding schemes
// to bypass path validation
// Test with various encoding attempts that should not be automatically decoded
let encoded_paths = [
"documents/%2e%2e/%2e%2e/etc/passwd", // URL encoded ../..
"documents/..%2f..%2fetc%2fpasswd", // Mixed encoding
"documents/../%2e%2e/etc/passwd", // Partial encoding
];
for encoded_path in &encoded_paths {
let result = soft_canonicalize(encoded_path);
assert!(result.is_ok(), "Should handle encoded path: {encoded_path}");
let resolved = result.unwrap();
assert!(resolved.is_absolute());
// The resolved path should contain the encoded characters as-is
// (not automatically decoded, which would be a security vulnerability)
let path_str = resolved.to_string_lossy();
assert!(
path_str.contains("%2e") || path_str.contains("%2f") || path_str.contains(".."),
"Encoded characters should not be automatically decoded"
);
}
}
#[test]
fn test_case_sensitivity_bypass_prevention() {
// Test protection against case sensitivity bypass attacks
// On case-insensitive filesystems, attackers might use case variations
// to bypass path restrictions
let paths = [
"Documents/file.txt",
"documents/file.txt",
"DOCUMENTS/file.txt",
"Documents/FILE.TXT",
];
let results: Vec<_> = paths.iter().map(soft_canonicalize).collect();
// All should succeed
for (i, result) in results.iter().enumerate() {
assert!(result.is_ok(), "Path should canonicalize: {}", paths[i]);
}
// On case-insensitive systems, these should normalize to the same path
// On case-sensitive systems, they'll be different
// Either way, the behavior should be consistent with the filesystem
#[cfg(windows)]
{
// Windows is case-insensitive - paths should normalize
let canonical_results: Vec<_> = results.iter().map(|r| r.as_ref().unwrap()).collect();
// Check that case normalization happens consistently
for result in &canonical_results {
assert!(result.is_absolute());
}
}
}
#[test]
fn test_zero_width_and_control_character_handling() {
// Test handling of zero-width and control characters that might
// be used to confuse path parsing or display
let malicious_chars = [
"\u{200B}", // Zero-width space
"\u{200C}", // Zero-width non-joiner
"\u{200D}", // Zero-width joiner
"\u{FEFF}", // Zero-width no-break space (BOM)
"\u{0001}", // Start of heading (control char)
"\u{007F}", // Delete character
];
for &char in &malicious_chars {
let malicious_path = format!("documents/file{char}.txt");
let result = soft_canonicalize(&malicious_path);
assert!(result.is_ok(), "Should handle control character path");
let resolved = result.unwrap();
assert!(resolved.is_absolute());
// The path should preserve the character (not strip it, which could
// lead to unexpected path collisions)
let path_str = resolved.to_string_lossy();
assert!(
path_str.contains(char),
"Control character should be preserved, not stripped"
);
}
}