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