Skip to main content

dream_path/
lib.rs

1//! Byte-first normalized virtual resource paths.
2//!
3//! `dream-path` owns the path normalization shared by archive readers, VFS,
4//! resource loading, rendering-side resource lookup, and tooling. The rules are
5//! intentionally the boring OpenMW-style virtual path rules:
6//!
7//! - `\` becomes `/`
8//! - ASCII uppercase letters become lowercase
9//! - repeated separators collapse
10//! - leading separators are discarded
11//! - arbitrary non-UTF-8 bytes are preserved
12//!
13//! The rules are byte-literal: `///` normalizes to an empty byte string, while a
14//! non-leading trailing separator is kept (`foo/` stays `foo/`).
15//!
16//! It does not decode legacy filename encodings, perform Unicode
17//! normalization, case-fold non-ASCII text, interpret host filesystem paths, or
18//! compute archive-format hashes. Those are separate jobs. Mixing them into the
19//! path type would be a small architectural crime, so naturally we avoid doing
20//! that.
21
22use std::{borrow::Borrow, str::Utf8Error};
23
24use bstr::{BStr, BString, ByteSlice as _};
25
26#[cfg(feature = "lua")]
27pub mod lua;
28
29/// A byte-first normalized virtual resource path.
30///
31/// [`NormalizedPath::new`] and the input [`From`] impls apply [`normalize_path`].
32/// The normalized-byte adoption constructors require the caller to provide bytes
33/// that are already normalized. This type is intended for repeated lookups where
34/// normalizing the query every time would allocate and burn cycles for no useful
35/// reason.
36#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub struct NormalizedPath(BString);
38
39impl NormalizedPath {
40    /// Normalize `path` into an owned virtual resource path.
41    #[must_use]
42    pub fn new(path: impl AsRef<[u8]>) -> Self {
43        Self(BString::from(normalize_path(path.as_ref())))
44    }
45
46    /// Build from bytes that are already normalized.
47    ///
48    /// Returns the original bytes on rejection so callers can log, repair, or
49    /// normalize them without cloning first. Use this when avoiding a second
50    /// normalization pass matters and the caller can handle rejection.
51    ///
52    /// # Errors
53    ///
54    /// Returns the original `path` when it does not satisfy
55    /// [`is_normalized_path`].
56    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    /// Build from bytes that are already normalized without checking them.
65    ///
66    /// `path` should satisfy [`is_normalized_path`]. Passing non-normalized bytes
67    /// breaks the logical invariant that every [`NormalizedPath`] contains
68    /// normalized virtual path spelling. That can produce cache misses and
69    /// duplicate keys. It is not memory-unsafe; it is just wrong, which is quite
70    /// bad enough.
71    #[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    /// Return this path as a [`BStr`].
78    #[must_use]
79    pub fn as_bstr(&self) -> &BStr {
80        self.0.as_bstr()
81    }
82
83    /// Return this path as raw bytes.
84    #[must_use]
85    pub fn as_bytes(&self) -> &[u8] {
86        &self.0
87    }
88
89    /// Return this path as UTF-8 when the normalized bytes are valid UTF-8.
90    ///
91    /// This is a convenience for string-oriented callers. It is not a promise
92    /// that virtual resource paths are Unicode, display-safe, C-string-safe, or
93    /// host filesystem paths. Valid UTF-8 paths may still contain NUL bytes.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`Utf8Error`] if the path contains invalid UTF-8 bytes.
98    pub fn to_str(&self) -> Result<&str, Utf8Error> {
99        std::str::from_utf8(self.as_bytes())
100    }
101
102    /// Return the final non-empty component of this virtual path.
103    ///
104    /// A trailing separator is ignored for component extraction, so `foo/bar/`
105    /// has file name `bar`.
106    #[must_use]
107    pub fn file_name(&self) -> Option<&BStr> {
108        file_name_normalized(self.as_bytes())
109    }
110
111    /// Return the parent portion of this virtual path.
112    ///
113    /// This is a byte-level virtual path operation. It does not interpret `.`,
114    /// `..`, drive prefixes, roots, or host filesystem rules.
115    #[must_use]
116    pub fn parent(&self) -> Option<&BStr> {
117        parent_normalized(self.as_bytes())
118    }
119
120    /// Return the extension of the final component, without the dot.
121    ///
122    /// Dotfiles such as `.hidden`, names ending in `.`, and paths ending in `/`
123    /// have no extension.
124    #[must_use]
125    pub fn extension(&self) -> Option<&BStr> {
126        extension_normalized(self.as_bytes())
127    }
128
129    /// Return true if the normalized path is empty.
130    #[must_use]
131    pub fn is_empty(&self) -> bool {
132        self.0.is_empty()
133    }
134
135    /// Return the normalized path length in bytes.
136    #[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/// Return true if `path` already matches this crate's normalized spelling.
215///
216/// This checks byte spelling only. It does not mean that `path` is a valid
217/// file-like resource, safe host path, URI, display string, or archive path.
218/// Empty paths, trailing separators, NUL bytes, invalid UTF-8, dot segments,
219/// and already-mangled host/URI-looking strings such as `c:/foo` or
220/// `http:/foo/bar` may all be normalized according to this predicate.
221#[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/// Return the final non-empty component of an already-normalized virtual path.
236///
237/// A trailing separator is ignored for component extraction, so `foo/bar/` has
238/// file name `bar`. The input is assumed to satisfy [`is_normalized_path`]; this
239/// function does not normalize, validate resource suitability, or interpret host
240/// filesystem syntax.
241#[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/// Return the parent portion of an already-normalized virtual path.
255///
256/// The input is assumed to satisfy [`is_normalized_path`]. This is a byte-level
257/// virtual path operation; it does not resolve `.`, `..`, roots, drive prefixes,
258/// URI schemes, or host filesystem rules.
259#[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/// Return the extension of the final component of an already-normalized virtual
267/// path, without the dot.
268///
269/// Dotfiles such as `.hidden`, names ending in `.`, and paths ending in `/` have
270/// no extension. The input is assumed to satisfy [`is_normalized_path`].
271#[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/// Normalize a virtual resource path into owned bytes.
285#[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/// Normalize an owned virtual resource path, reusing its allocation.
294///
295/// This is a convenience for callers that already own a byte buffer and do not
296/// need to preserve the original spelling. It has the same normalization rules
297/// as [`normalize_path`].
298#[must_use]
299pub fn normalize_path_owned(mut path: Vec<u8>) -> Vec<u8> {
300    normalize_path_in_place(&mut path);
301    path
302}
303
304/// Normalize an owned virtual resource path in place.
305///
306/// The buffer is rewritten using the same rules as [`normalize_path`]. Its
307/// allocation is reused; its length may shrink when leading or repeated
308/// separators are removed.
309pub 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
328/// Normalize a virtual resource path into an existing buffer.
329///
330/// `out` is cleared before writing. Its previous allocation is reused when
331/// possible. This crate does not enforce a maximum path length; callers handling
332/// untrusted archive, tool, or Lua input should enforce their own byte budget.
333/// A scratch buffer can retain a large allocation after a pathological input, so
334/// discard or shrink it at the caller boundary if that matters.
335pub 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}