1use std::{borrow::Borrow, str::Utf8Error};
23
24use bstr::{BStr, BString, ByteSlice as _};
25
26#[cfg(feature = "lua")]
27pub mod lua;
28
29#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub struct NormalizedPath(BString);
38
39impl NormalizedPath {
40 #[must_use]
42 pub fn new(path: impl AsRef<[u8]>) -> Self {
43 Self(BString::from(normalize_path(path.as_ref())))
44 }
45
46 pub fn try_from_normalized_bytes(path: Vec<u8>) -> Result<Self, Vec<u8>> {
57 if is_normalized_path(&path) {
58 Ok(Self(BString::from(path)))
59 } else {
60 Err(path)
61 }
62 }
63
64 #[must_use]
72 pub fn from_normalized_bytes_unchecked(path: Vec<u8>) -> Self {
73 debug_assert!(is_normalized_path(&path));
74 Self(BString::from(path))
75 }
76
77 #[must_use]
79 pub fn as_bstr(&self) -> &BStr {
80 self.0.as_bstr()
81 }
82
83 #[must_use]
85 pub fn as_bytes(&self) -> &[u8] {
86 &self.0
87 }
88
89 pub fn to_str(&self) -> Result<&str, Utf8Error> {
99 std::str::from_utf8(self.as_bytes())
100 }
101
102 #[must_use]
107 pub fn file_name(&self) -> Option<&BStr> {
108 file_name_normalized(self.as_bytes())
109 }
110
111 #[must_use]
116 pub fn parent(&self) -> Option<&BStr> {
117 parent_normalized(self.as_bytes())
118 }
119
120 #[must_use]
125 pub fn extension(&self) -> Option<&BStr> {
126 extension_normalized(self.as_bytes())
127 }
128
129 #[must_use]
131 pub fn is_empty(&self) -> bool {
132 self.0.is_empty()
133 }
134
135 #[must_use]
137 pub fn len(&self) -> usize {
138 self.0.len()
139 }
140}
141
142impl AsRef<[u8]> for NormalizedPath {
143 fn as_ref(&self) -> &[u8] {
144 self.as_bytes()
145 }
146}
147
148impl AsRef<BStr> for NormalizedPath {
149 fn as_ref(&self) -> &BStr {
150 self.as_bstr()
151 }
152}
153
154impl Borrow<[u8]> for NormalizedPath {
155 fn borrow(&self) -> &[u8] {
156 self.as_bytes()
157 }
158}
159
160impl Borrow<BStr> for NormalizedPath {
161 fn borrow(&self) -> &BStr {
162 self.as_bstr()
163 }
164}
165
166impl From<&[u8]> for NormalizedPath {
167 fn from(path: &[u8]) -> Self {
168 Self::new(path)
169 }
170}
171
172impl From<&str> for NormalizedPath {
173 fn from(path: &str) -> Self {
174 Self::new(path)
175 }
176}
177
178impl From<&BStr> for NormalizedPath {
179 fn from(path: &BStr) -> Self {
180 Self::new(path)
181 }
182}
183
184impl From<Vec<u8>> for NormalizedPath {
185 fn from(path: Vec<u8>) -> Self {
186 Self::new(path)
187 }
188}
189
190impl From<String> for NormalizedPath {
191 fn from(path: String) -> Self {
192 Self::new(path)
193 }
194}
195
196impl From<BString> for NormalizedPath {
197 fn from(path: BString) -> Self {
198 Self::new(path)
199 }
200}
201
202impl From<NormalizedPath> for BString {
203 fn from(path: NormalizedPath) -> Self {
204 path.0
205 }
206}
207
208impl From<NormalizedPath> for Vec<u8> {
209 fn from(path: NormalizedPath) -> Self {
210 path.0.into()
211 }
212}
213
214#[must_use]
222pub fn is_normalized_path(path: &[u8]) -> bool {
223 let mut previous_was_separator = true;
224 for &byte in path {
225 match byte {
226 b'\\' | b'A'..=b'Z' => return false,
227 b'/' if previous_was_separator => return false,
228 b'/' => previous_was_separator = true,
229 _ => previous_was_separator = false,
230 }
231 }
232 true
233}
234
235#[must_use]
242pub fn file_name_normalized(path: &[u8]) -> Option<&BStr> {
243 let bytes = without_trailing_separator(path);
244 if bytes.is_empty() {
245 return None;
246 }
247 let start = bytes
248 .iter()
249 .rposition(|byte| *byte == b'/')
250 .map_or(0, |pos| pos + 1);
251 Some(bytes[start..].as_bstr())
252}
253
254#[must_use]
260pub fn parent_normalized(path: &[u8]) -> Option<&BStr> {
261 let bytes = without_trailing_separator(path);
262 let end = bytes.iter().rposition(|byte| *byte == b'/')?;
263 Some(bytes[..end].as_bstr())
264}
265
266#[must_use]
272pub fn extension_normalized(path: &[u8]) -> Option<&BStr> {
273 if path.ends_with(b"/") {
274 return None;
275 }
276 let file_name = file_name_normalized(path)?.as_bytes();
277 let dot = file_name.iter().rposition(|byte| *byte == b'.')?;
278 if dot == 0 || dot + 1 == file_name.len() {
279 return None;
280 }
281 Some(file_name[dot + 1..].as_bstr())
282}
283
284#[must_use]
286pub fn normalize_path(path: impl AsRef<[u8]>) -> Vec<u8> {
287 let path = path.as_ref();
288 let mut out = Vec::with_capacity(path.len());
289 normalize_path_into(&mut out, path);
290 out
291}
292
293#[must_use]
299pub fn normalize_path_owned(mut path: Vec<u8>) -> Vec<u8> {
300 normalize_path_in_place(&mut path);
301 path
302}
303
304pub fn normalize_path_in_place(path: &mut Vec<u8>) {
310 let mut write = 0;
311 let mut previous_was_separator = true;
312 for read in 0..path.len() {
313 let byte = match path[read] {
314 b'\\' => b'/',
315 b'A'..=b'Z' => path[read] + 32,
316 byte => byte,
317 };
318 if byte == b'/' && previous_was_separator {
319 continue;
320 }
321 path[write] = byte;
322 write += 1;
323 previous_was_separator = byte == b'/';
324 }
325 path.truncate(write);
326}
327
328pub fn normalize_path_into(out: &mut Vec<u8>, path: &[u8]) {
336 out.clear();
337 out.reserve(path.len());
338 for byte in path.iter().copied() {
339 let byte = match byte {
340 b'\\' => b'/',
341 b'A'..=b'Z' => byte + 32,
342 _ => byte,
343 };
344 if byte == b'/' && (out.is_empty() || out.last() == Some(&b'/')) {
345 continue;
346 }
347 out.push(byte);
348 }
349}
350
351fn without_trailing_separator(bytes: &[u8]) -> &[u8] {
352 bytes.strip_suffix(b"/").unwrap_or(bytes)
353}
354
355#[cfg(test)]
356mod tests {
357 use std::collections::HashMap;
358
359 use bstr::{BStr, BString};
360
361 use super::{
362 NormalizedPath, extension_normalized, file_name_normalized, is_normalized_path,
363 normalize_path, normalize_path_in_place, normalize_path_into, normalize_path_owned,
364 parent_normalized,
365 };
366
367 #[test]
368 fn leaves_empty_path_empty() {
369 assert_eq!(normalize_path(b""), b"");
370 }
371
372 #[test]
373 fn removes_leading_separators() {
374 assert_eq!(normalize_path(b"/foo"), b"foo");
375 assert_eq!(normalize_path(b"///foo//bar"), b"foo/bar");
376 }
377
378 #[test]
379 fn all_separators_normalize_to_empty() {
380 assert_eq!(normalize_path(b"/"), b"");
381 assert_eq!(normalize_path(br"\\\/"), b"");
382 }
383
384 #[test]
385 fn keeps_non_leading_trailing_separator() {
386 assert_eq!(normalize_path(b"foo/"), b"foo/");
387 assert_eq!(normalize_path(br"foo\\"), b"foo/");
388 }
389
390 #[test]
391 fn folds_backslashes_and_ascii_case() {
392 assert_eq!(normalize_path(br"FOO\BaR"), b"foo/bar");
393 }
394
395 #[test]
396 fn collapses_repeated_separators_after_backslash_folding() {
397 assert_eq!(normalize_path(br"foo\\//bar"), b"foo/bar");
398 }
399
400 #[test]
401 fn preserves_non_ascii_bytes() {
402 assert_eq!(normalize_path("Café/Ä".as_bytes()), "café/Ä".as_bytes());
403 }
404
405 #[test]
406 fn only_ascii_uppercase_is_folded() {
407 assert_eq!(normalize_path(b"ABC[\\]^_`XYZ"), b"abc[/]^_`xyz");
408 }
409
410 #[test]
411 fn preserves_invalid_utf8_bytes() {
412 assert_eq!(normalize_path(b"DIR/\xff/FILE"), b"dir/\xff/file");
413 }
414
415 #[test]
416 fn preserves_nul_bytes() {
417 assert_eq!(normalize_path(b"FOO\0BAR"), b"foo\0bar");
418 assert_eq!(normalize_path(b"DIR/\0/FILE"), b"dir/\0/file");
419 }
420
421 #[test]
422 fn does_not_resolve_dot_segments() {
423 assert_eq!(normalize_path(b"A/./B"), b"a/./b");
424 assert_eq!(normalize_path(b"A/../B"), b"a/../b");
425 assert_eq!(
426 NormalizedPath::new(b"Foo/../BAR").parent(),
427 Some(BStr::new(b"foo/.."))
428 );
429 }
430
431 #[test]
432 fn does_not_preserve_uri_or_host_path_syntax() {
433 assert_eq!(normalize_path(b"HTTP://Foo/Bar"), b"http:/foo/bar");
434 assert_eq!(normalize_path(br"C:\Foo"), b"c:/foo");
435 assert_eq!(
436 normalize_path(br"\\Server\Share\File"),
437 b"server/share/file"
438 );
439 }
440
441 #[test]
442 fn trailing_separator_remains_part_of_key() {
443 let file = NormalizedPath::new("textures/foo.dds");
444 let directory_like = NormalizedPath::new("textures/foo.dds/");
445
446 assert_ne!(file, directory_like);
447 assert_eq!(directory_like.as_bytes(), b"textures/foo.dds/");
448 }
449
450 #[test]
451 fn normalization_is_idempotent() {
452 let once = normalize_path(br"//Foo\\BAR///baz");
453 let twice = normalize_path(&once);
454 assert_eq!(once, twice);
455 }
456
457 #[test]
458 fn normalization_invariants_hold_for_byte_corpus() {
459 let mut cases: Vec<Vec<u8>> = (0u8..=u8::MAX).map(|byte| vec![byte]).collect();
460 cases.extend([
461 b"".to_vec(),
462 br"//Foo\\BAR///baz".to_vec(),
463 b"HTTP://Foo/Bar".to_vec(),
464 br"C:\Foo".to_vec(),
465 br"\\Server\Share\File".to_vec(),
466 b"A/./B".to_vec(),
467 b"A/../B".to_vec(),
468 b"DIR/\0/FILE".to_vec(),
469 b"DIR/\xff/FILE".to_vec(),
470 br"/A//B\\C/".to_vec(),
471 ]);
472
473 for case in cases {
474 let normalized = normalize_path(&case);
475 assert!(
476 is_normalized_path(&normalized),
477 "normalized output failed predicate: {case:?}"
478 );
479 assert_eq!(
480 normalize_path(&normalized),
481 normalized,
482 "normalization was not idempotent: {case:?}"
483 );
484 assert!(
485 !normalized.contains(&b'\\'),
486 "backslash survived normalization: {case:?}"
487 );
488 assert!(
489 !normalized.iter().any(u8::is_ascii_uppercase),
490 "uppercase ASCII survived normalization: {case:?}"
491 );
492 assert!(
493 !normalized.starts_with(b"/"),
494 "leading slash survived normalization: {case:?}"
495 );
496 assert!(
497 !normalized.windows(2).any(|window| window == b"//"),
498 "repeated slash survived normalization: {case:?}"
499 );
500 }
501 }
502
503 #[test]
504 fn detects_already_normalized_paths() {
505 assert!(is_normalized_path(b""));
506 assert!(is_normalized_path(b"textures/foo.dds"));
507 assert!(is_normalized_path(b"textures/foo/"));
508 assert!(is_normalized_path(b"textures/\xff/file"));
509 assert!(is_normalized_path(b"foo\0bar"));
510 assert!(is_normalized_path(b"foo/../bar"));
511 assert!(is_normalized_path(b"c:/foo"));
512
513 assert!(!is_normalized_path(b"/textures/foo.dds"));
514 assert!(!is_normalized_path(b"textures//foo.dds"));
515 assert!(!is_normalized_path(br"textures\foo.dds"));
516 assert!(!is_normalized_path(b"textures/FOO.dds"));
517 }
518
519 #[test]
520 fn normalize_into_reuses_and_clears_output() {
521 let mut out = b"stale".to_vec();
522 let capacity = out.capacity();
523 normalize_path_into(&mut out, br"/Foo\Bar");
524 assert_eq!(out, b"foo/bar");
525 assert!(out.capacity() >= capacity);
526 }
527
528 #[test]
529 fn normalize_owned_and_in_place_reuse_existing_storage() {
530 let mut path = br"//Textures\\Foo///BAR.DDS".to_vec();
531 let capacity = path.capacity();
532 normalize_path_in_place(&mut path);
533 assert_eq!(path, b"textures/foo/bar.dds");
534 assert_eq!(path.capacity(), capacity);
535
536 assert_eq!(
537 normalize_path_owned(br"//Meshes\\Door.NIF".to_vec()),
538 b"meshes/door.nif"
539 );
540 }
541
542 #[test]
543 fn normalized_path_exposes_bytes_and_length() {
544 let path = NormalizedPath::new(br"/Meshes\Thing.NIF");
545 assert_eq!(path.as_bytes(), b"meshes/thing.nif");
546 assert_eq!(path.as_bstr(), b"meshes/thing.nif".as_slice());
547 assert_eq!(path.len(), b"meshes/thing.nif".len());
548 assert!(!path.is_empty());
549 }
550
551 #[test]
552 fn normalized_path_reports_utf8_only_when_valid() {
553 assert_eq!(
554 NormalizedPath::new("Textures/Foo.DDS").to_str(),
555 Ok("textures/foo.dds")
556 );
557 assert!(NormalizedPath::new(b"textures/\xff.dds").to_str().is_err());
558 assert_eq!(NormalizedPath::new(b"A\0B").to_str(), Ok("a\0b"));
559 }
560
561 #[test]
562 fn normalized_path_exposes_virtual_components() {
563 let path = NormalizedPath::new(br"/Textures/Architecture/Wall.DDS");
564 assert_eq!(path.parent(), Some(BStr::new(b"textures/architecture")));
565 assert_eq!(path.file_name(), Some(BStr::new(b"wall.dds")));
566 assert_eq!(path.extension(), Some(BStr::new(b"dds")));
567
568 let directory_like = NormalizedPath::new("textures/foo/");
569 assert_eq!(directory_like.parent(), Some(BStr::new(b"textures")));
570 assert_eq!(directory_like.file_name(), Some(BStr::new(b"foo")));
571 assert_eq!(directory_like.extension(), None);
572 }
573
574 #[test]
575 fn normalized_path_extension_is_byte_literal() {
576 assert_eq!(
577 NormalizedPath::new("foo.tar.gz").extension(),
578 Some(BStr::new(b"gz"))
579 );
580 assert_eq!(NormalizedPath::new(".hidden").extension(), None);
581 assert_eq!(NormalizedPath::new("foo.").extension(), None);
582 assert_eq!(NormalizedPath::new("foo.dds/").extension(), None);
583 assert_eq!(
584 NormalizedPath::new(b"foo.\xff").extension(),
585 Some(BStr::new(b"\xff"))
586 );
587 }
588
589 #[test]
590 fn normalized_component_helpers_operate_on_borrowed_bytes() {
591 let path = b"textures/architecture/wall.dds";
592
593 assert_eq!(
594 parent_normalized(path),
595 Some(BStr::new(b"textures/architecture"))
596 );
597 assert_eq!(file_name_normalized(path), Some(BStr::new(b"wall.dds")));
598 assert_eq!(extension_normalized(path), Some(BStr::new(b"dds")));
599 assert_eq!(extension_normalized(b"textures/foo.dds/"), None);
600 }
601
602 #[test]
603 fn checked_normalized_constructor_rejects_unnormalized_bytes() {
604 let path = NormalizedPath::try_from_normalized_bytes(b"textures/foo.dds".to_vec())
605 .expect("path is already normalized");
606 assert_eq!(path.as_bytes(), b"textures/foo.dds");
607 assert_eq!(
608 NormalizedPath::try_from_normalized_bytes(b"textures/foo/".to_vec())
609 .expect("trailing separator is normalized")
610 .as_bytes(),
611 b"textures/foo/"
612 );
613
614 for path in [
615 b"Textures/Foo.DDS".as_slice(),
616 b"/textures/foo.dds".as_slice(),
617 b"textures//foo.dds".as_slice(),
618 br"textures\foo.dds".as_slice(),
619 ] {
620 let rejected = NormalizedPath::try_from_normalized_bytes(path.to_vec())
621 .expect_err("path is not normalized");
622 assert_eq!(rejected, path);
623 }
624 }
625
626 #[test]
627 fn normalized_path_borrows_as_normalized_bytes_for_lookup() {
628 let mut values = HashMap::new();
629 values.insert(NormalizedPath::new(br"/Meshes\Thing.NIF"), 7);
630
631 assert_eq!(values.get(b"meshes/thing.nif".as_slice()), Some(&7));
632 assert_eq!(values.get(BStr::new(b"meshes/thing.nif")), Some(&7));
633 }
634
635 #[test]
636 fn normalized_path_as_ref_supports_byte_and_bstr_views() {
637 let path = NormalizedPath::new(br"/Textures\Foo.DDS");
638 let bytes: &[u8] = path.as_ref();
639 let bstr: &BStr = path.as_ref();
640
641 assert_eq!(bytes, b"textures/foo.dds");
642 assert_eq!(bstr, b"textures/foo.dds".as_slice());
643 }
644
645 #[test]
646 fn normalized_path_converts_into_owned_byte_strings() {
647 let path = NormalizedPath::new(br"/Icons\Foo.TGA");
648 let bstring = BString::from(path.clone());
649 let bytes = Vec::<u8>::from(path);
650
651 assert_eq!(bstring, b"icons/foo.tga".as_slice());
652 assert_eq!(bytes, b"icons/foo.tga");
653 }
654
655 #[test]
656 fn from_impls_normalize() {
657 let bstring = BString::from(b"/Foo".to_vec());
658
659 assert_eq!(NormalizedPath::from("/Foo").as_bytes(), b"foo");
660 assert_eq!(NormalizedPath::from(b"/Foo".as_slice()).as_bytes(), b"foo");
661 assert_eq!(NormalizedPath::from(BStr::new(&bstring)).as_bytes(), b"foo");
662 assert_eq!(
663 NormalizedPath::from(String::from("/Foo")).as_bytes(),
664 b"foo"
665 );
666 assert_eq!(NormalizedPath::from(b"/Foo".to_vec()).as_bytes(), b"foo");
667 assert_eq!(
668 NormalizedPath::from(BString::from(b"/Foo".to_vec())).as_bytes(),
669 b"foo"
670 );
671 }
672}