Skip to main content

loonfs_api/
path.rs

1//! The absolute-path grammar: parsing, components, and display names.
2
3use crate::ids::string_id;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use thiserror::Error;
7
8/// Canonical absolute path plus its parsed components.
9///
10/// Richer than the string-id newtypes (it carries segments), so it exposes
11/// only `Display`/`AsRef<str>` on top of its structural accessors instead of
12/// the full `string_id!` suite.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct AbsolutePath {
15    normalized: String,
16    components: Vec<PathComponent>,
17}
18
19/// One path segment as stored, preserving display spelling.
20///
21/// Components are only produced by parsing an [`AbsolutePath`] or joining a
22/// [`DisplayName`], so there is no fallible string constructor.
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct PathComponent(String);
25
26string_id! {
27    /// User-facing spelling of one path component.
28    DisplayName,
29    error = PathError,
30    validate = validate_display_name
31}
32
33/// Maximum stored display-name length in UTF-8 bytes: the 255-byte
34/// component cap of mainstream filesystems (ext4, APFS, NTFS components)
35/// and drives. Names are stored as given, so the cap applies to the bytes
36/// as given.
37pub const MAX_DISPLAY_NAME_BYTES: usize = 255;
38
39/// Largest canonical absolute path, in UTF-8 bytes. Bounded so every real
40/// filesystem, archive format, and sync client can materialize any stored
41/// tree; per-component limits alone allowed paths no target could hold.
42pub const MAX_PATH_BYTES: usize = 4_096;
43
44/// Deepest directory nesting one path may express.
45pub const MAX_PATH_DEPTH: usize = 128;
46
47/// Describes why caller-supplied path or display-name text is not admissible.
48#[derive(Debug, Clone, PartialEq, Eq, Error)]
49pub enum PathError {
50    /// Reports an empty string where an absolute path was required.
51    #[error("absolute path must not be empty")]
52    EmptyPath,
53    /// Reports a path that does not begin at the namespace root.
54    #[error("path `{path:?}` is not absolute")]
55    RelativePath {
56        /// Rejected path, preserved for an escaped diagnostic.
57        path: String,
58    },
59    /// Reports an explicit current-directory component, which normalization never accepts.
60    #[error("path `{path:?}` contains `.` component")]
61    DotComponent {
62        /// Rejected path, preserved for an escaped diagnostic.
63        path: String,
64    },
65    /// Reports a parent-directory component, which could otherwise escape the requested path.
66    #[error("path `{path:?}` contains `..` component")]
67    ParentComponent {
68        /// Rejected path, preserved for an escaped diagnostic.
69        path: String,
70    },
71    /// Reports an empty string where one stored path component was required.
72    #[error("display name must not be empty")]
73    EmptyDisplayName,
74    /// Reports a display name containing the path-component separator.
75    #[error("display name `{display_name:?}` contains `/`")]
76    DisplayNameContainsSeparator {
77        /// Rejected component spelling, preserved for an escaped diagnostic.
78        display_name: String,
79    },
80    /// Reports `.` or `..`, whose navigation meaning prevents storing them as names.
81    #[error("display name `{display_name:?}` is reserved")]
82    ReservedDisplayName {
83        /// Reserved spelling supplied by the caller.
84        display_name: String,
85    },
86    /// Reports a Unicode control character that cannot appear in a stored display name.
87    #[error("display name contains control character U+{code_point:04X}")]
88    DisplayNameContainsControlCharacter {
89        /// Unicode scalar value of the first rejected control character.
90        code_point: u32,
91    },
92    /// Reports a display name exceeding the stored UTF-8 component limit.
93    #[error("display name is {byte_length} bytes; the maximum is {MAX_DISPLAY_NAME_BYTES} bytes")]
94    DisplayNameTooLong {
95        /// UTF-8 byte length of the rejected display name.
96        byte_length: usize,
97    },
98    /// Reports a path exceeding the total canonical byte bound.
99    #[error("path is {byte_length} bytes; the maximum is {MAX_PATH_BYTES} bytes")]
100    PathTooLong {
101        /// UTF-8 byte length of the rejected canonical path.
102        byte_length: usize,
103    },
104    /// Reports a path nested deeper than the depth bound.
105    #[error("path has {depth} components; the maximum is {MAX_PATH_DEPTH}")]
106    PathTooDeep {
107        /// Component count of the rejected path.
108        depth: usize,
109    },
110    /// Reports a display name no portable target filesystem can hold.
111    #[error("display name `{display_name}` {reason}")]
112    UnportableDisplayName {
113        /// The rejected spelling.
114        display_name: String,
115        /// Which portability rule it broke.
116        reason: &'static str,
117    },
118    /// Reports a display name holding a character Windows reserves.
119    #[error(
120        "display name `{display_name}` contains `{character}`, which Windows cannot store; \
121         the reserved characters are `:` `?` `*` `|` `\"` `<` `>` `\\`"
122    )]
123    UnportableDisplayNameCharacter {
124        /// The rejected spelling.
125        display_name: String,
126        /// The first reserved character in the name, reading left to right.
127        character: char,
128    },
129    /// Reports a valid display spelling whose canonical lookup key exceeds its durable bound.
130    #[error(
131        "display name folds to a {byte_length}-byte name key; the maximum is \
132         {max} bytes",
133        max = crate::ids::MAX_NAME_KEY_BYTES
134    )]
135    FoldedNameKeyTooLong {
136        /// UTF-8 byte length after normalization and case folding.
137        byte_length: usize,
138    },
139}
140
141impl AbsolutePath {
142    /// Parses a canonical absolute path while preserving each component's display spelling.
143    ///
144    /// Empty and relative paths, repeated or trailing separators, explicit `.`
145    /// or `..` components, and components outside the [`DisplayName`] grammar
146    /// are rejected.
147    pub fn parse(value: impl AsRef<str>) -> Result<Self, PathError> {
148        let value = value.as_ref();
149        if value.is_empty() {
150            return Err(PathError::EmptyPath);
151        }
152        if !value.starts_with('/') {
153            return Err(PathError::RelativePath {
154                path: value.to_owned(),
155            });
156        }
157        if value == "/" {
158            return Ok(Self::root());
159        }
160
161        let mut components = Vec::new();
162        for component in value[1..].split('/') {
163            if component.is_empty() {
164                return Err(PathError::EmptyDisplayName);
165            }
166            if component == "." {
167                return Err(PathError::DotComponent {
168                    path: value.to_owned(),
169                });
170            }
171            if component == ".." {
172                return Err(PathError::ParentComponent {
173                    path: value.to_owned(),
174                });
175            }
176            // Every component must satisfy the display-name grammar: path
177            // parsing is the other door components enter through, and
178            // [`PathComponent::to_display_name`] converts without re-parsing.
179            validate_display_name(component)?;
180            components.push(PathComponent(component.to_owned()));
181        }
182        validate_path_bounds(value.len(), components.len())?;
183
184        Ok(Self::from_components(components))
185    }
186
187    /// Constructs the namespace root path without parsing caller input.
188    pub fn root() -> Self {
189        Self {
190            normalized: "/".to_owned(),
191            components: Vec::new(),
192        }
193    }
194
195    /// Returns the canonical absolute spelling, with `/` as the sole root representation.
196    pub fn as_str(&self) -> &str {
197        &self.normalized
198    }
199
200    /// Reports whether the path has no components.
201    pub fn is_root(&self) -> bool {
202        self.components.is_empty()
203    }
204
205    /// Returns components in root-to-leaf order with their original display spelling.
206    pub fn components(&self) -> &[PathComponent] {
207        &self.components
208    }
209
210    /// Returns the path one component above this one, or `None` at the root.
211    pub fn parent(&self) -> Option<Self> {
212        if self.is_root() {
213            return None;
214        }
215        if self.components.len() == 1 {
216            return Some(Self::root());
217        }
218
219        Some(Self::from_components(
220            self.components[..self.components.len() - 1].to_vec(),
221        ))
222    }
223
224    /// Returns the leaf component, or `None` when this path is the root.
225    pub fn final_component(&self) -> Option<&PathComponent> {
226        self.components.last()
227    }
228
229    /// Appends an already-validated display name without changing existing component spelling.
230    pub fn join(&self, display_name: &DisplayName) -> Self {
231        let mut components = self.components.clone();
232        components.push(PathComponent(display_name.as_str().to_owned()));
233        Self::from_components(components)
234    }
235
236    fn from_components(components: Vec<PathComponent>) -> Self {
237        let normalized = normalized_path(&components);
238        Self {
239            normalized,
240            components,
241        }
242    }
243}
244
245impl AsRef<str> for AbsolutePath {
246    fn as_ref(&self) -> &str {
247        self.as_str()
248    }
249}
250
251impl std::ops::Deref for AbsolutePath {
252    type Target = str;
253
254    fn deref(&self) -> &Self::Target {
255        self.as_str()
256    }
257}
258
259impl PartialEq<&str> for AbsolutePath {
260    fn eq(&self, other: &&str) -> bool {
261        self.as_str() == *other
262    }
263}
264
265impl fmt::Display for AbsolutePath {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        f.write_str(&self.normalized)
268    }
269}
270
271#[cfg(feature = "openapi")]
272impl utoipa::PartialSchema for AbsolutePath {
273    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
274        utoipa::openapi::schema::Object::builder()
275            .schema_type(utoipa::openapi::schema::Type::String)
276            .description(Some(
277                "Validated complete absolute namespace path, serialized as a plain string.",
278            ))
279            .into()
280    }
281}
282
283#[cfg(feature = "openapi")]
284impl utoipa::ToSchema for AbsolutePath {}
285
286impl Serialize for AbsolutePath {
287    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
288    where
289        S: serde::Serializer,
290    {
291        serializer.serialize_str(&self.normalized)
292    }
293}
294
295impl<'de> Deserialize<'de> for AbsolutePath {
296    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297    where
298        D: serde::Deserializer<'de>,
299    {
300        let value = String::deserialize(deserializer)?;
301        Self::parse(value).map_err(serde::de::Error::custom)
302    }
303}
304
305fn normalized_path(components: &[PathComponent]) -> String {
306    if components.is_empty() {
307        "/".to_owned()
308    } else {
309        format!(
310            "/{}",
311            components
312                .iter()
313                .map(PathComponent::as_str)
314                .collect::<Vec<_>>()
315                .join("/")
316        )
317    }
318}
319
320impl PathComponent {
321    /// Returns the display spelling retained when the containing path was parsed.
322    pub fn as_str(&self) -> &str {
323        &self.0
324    }
325
326    /// Copies this parser-validated component into the equivalent `DisplayName`.
327    pub fn to_display_name(&self) -> DisplayName {
328        DisplayName(self.0.clone())
329    }
330}
331
332impl AsRef<str> for PathComponent {
333    fn as_ref(&self) -> &str {
334        self.as_str()
335    }
336}
337
338impl fmt::Display for PathComponent {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        f.write_str(&self.0)
341    }
342}
343
344fn validate_display_name(value: &str) -> Result<(), PathError> {
345    if value.is_empty() {
346        return Err(PathError::EmptyDisplayName);
347    }
348    if value.contains('/') {
349        return Err(PathError::DisplayNameContainsSeparator {
350            display_name: value.to_owned(),
351        });
352    }
353    if value == "." || value == ".." {
354        return Err(PathError::ReservedDisplayName {
355            display_name: value.to_owned(),
356        });
357    }
358    if let Some(control) = value.chars().find(|character| character.is_control()) {
359        return Err(PathError::DisplayNameContainsControlCharacter {
360            code_point: control as u32,
361        });
362    }
363    if value.len() > MAX_DISPLAY_NAME_BYTES {
364        return Err(PathError::DisplayNameTooLong {
365            byte_length: value.len(),
366        });
367    }
368    // Portability floor: names every target filesystem can hold. Windows
369    // cannot materialize its reserved characters, trailing dots or spaces,
370    // or its reserved device names, and an all-whitespace name is invisible
371    // in every listing. Rejecting here is free pre-release; loosening later
372    // is compatible, tightening later would strand stored names.
373    if let Some(character) = first_windows_reserved_character(value) {
374        return Err(PathError::UnportableDisplayNameCharacter {
375            display_name: value.to_owned(),
376            character,
377        });
378    }
379    if value.chars().all(char::is_whitespace) {
380        return Err(PathError::UnportableDisplayName {
381            display_name: value.to_owned(),
382            reason: "is entirely whitespace",
383        });
384    }
385    if value.ends_with(' ') {
386        return Err(PathError::UnportableDisplayName {
387            display_name: value.to_owned(),
388            reason: "ends with a space, which Windows cannot store",
389        });
390    }
391    if value.ends_with('.') {
392        return Err(PathError::UnportableDisplayName {
393            display_name: value.to_owned(),
394            reason: "ends with a dot, which Windows cannot store",
395        });
396    }
397    if is_windows_reserved_device_name(value) {
398        return Err(PathError::UnportableDisplayName {
399            display_name: value.to_owned(),
400            reason: "is a Windows reserved device name",
401        });
402    }
403    // Every stored name key is derived from an admitted display name, and
404    // the derivation site treats an invalid derived key as an invariant
405    // violation — so admission must guarantee the derived key stays within
406    // the name-key grammar. v0 folds one way for every namespace; if a
407    // second rule ever arrives this check moves to the boundary that knows
408    // the namespace.
409    let folded_length = crate::name_policy::name_key_for_display_name(value).len();
410    if folded_length > crate::ids::MAX_NAME_KEY_BYTES {
411        return Err(PathError::FoldedNameKeyTooLong {
412            byte_length: folded_length,
413        });
414    }
415    Ok(())
416}
417
418fn validate_path_bounds(byte_length: usize, depth: usize) -> Result<(), PathError> {
419    if byte_length > MAX_PATH_BYTES {
420        return Err(PathError::PathTooLong { byte_length });
421    }
422    if depth > MAX_PATH_DEPTH {
423        return Err(PathError::PathTooDeep { depth });
424    }
425    Ok(())
426}
427
428/// Characters Windows reserves inside a path component. Each one means
429/// something else there — a drive or stream separator, a wildcard, a pipe, a
430/// quote, a redirect — so a name holding one cannot be written to an NTFS
431/// volume at all, and a tree containing it could not be materialized,
432/// archived, or synced on Windows.
433///
434/// This is the same portability floor the trailing-dot and device-name rules
435/// enforce; they were already Windows-motivated, and admitting these
436/// characters while rejecting `CON` would be the policy disagreeing with
437/// itself.
438const WINDOWS_RESERVED_CHARACTERS: [char; 8] = [':', '?', '*', '|', '"', '<', '>', '\\'];
439
440/// The first reserved character in a name, reading left to right, so the
441/// diagnostic can name the one the caller has to fix first.
442fn first_windows_reserved_character(value: &str) -> Option<char> {
443    value
444        .chars()
445        .find(|character| WINDOWS_RESERVED_CHARACTERS.contains(character))
446}
447
448/// Device names Windows reserves regardless of extension or letter case:
449/// a file called `CON`, `con.txt`, or `Com1.log` cannot exist there.
450fn is_windows_reserved_device_name(value: &str) -> bool {
451    let stem = value.split('.').next().unwrap_or(value);
452    let stem = stem.trim_end_matches(' ');
453    let upper = stem.to_ascii_uppercase();
454    matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
455        || (upper.len() == 4
456            && (upper.starts_with("COM") || upper.starts_with("LPT"))
457            && upper[3..].chars().all(|digit| digit.is_ascii_digit())
458            && &upper[3..] != "0")
459}
460
461impl PathError {
462    /// Returns rejected path text suitable for an API error, omitting hostile or oversized names.
463    pub fn invalid_path_input(&self) -> &str {
464        match self {
465            Self::EmptyPath => "",
466            Self::RelativePath { path }
467            | Self::DotComponent { path }
468            | Self::ParentComponent { path } => path,
469            Self::EmptyDisplayName => "",
470            Self::DisplayNameContainsSeparator { display_name }
471            | Self::ReservedDisplayName { display_name } => display_name,
472            Self::UnportableDisplayName { display_name, .. }
473            | Self::UnportableDisplayNameCharacter { display_name, .. } => display_name,
474            // Length and control failures do not carry the offending name:
475            // an oversized or hostile name must not ride along in error
476            // payloads that serialize onto the wire.
477            Self::DisplayNameContainsControlCharacter { .. }
478            | Self::DisplayNameTooLong { .. }
479            | Self::FoldedNameKeyTooLong { .. }
480            | Self::PathTooLong { .. }
481            | Self::PathTooDeep { .. } => "",
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::{AbsolutePath, DisplayName, PathError};
489    use crate::{name_key_for_display_name, NameKey};
490
491    #[test]
492    fn unportable_names_are_rejected() {
493        for name in [
494            "   ",
495            "report ",
496            "archive.",
497            "CON",
498            "con.txt",
499            "Com1.log",
500            "lpt9",
501            "aux.files.d",
502        ] {
503            assert!(
504                DisplayName::parse(name).is_err(),
505                "`{name}` should be rejected"
506            );
507        }
508        // Names that merely resemble the reserved set stay legal.
509        for name in ["CONSOLE", "com10", "lpt10.txt", ".hidden", "a.b"] {
510            assert!(
511                DisplayName::parse(name).is_ok(),
512                "`{name}` should be accepted"
513            );
514        }
515    }
516
517    /// The characters Windows reserves are rejected the same way its
518    /// trailing dots and device names already were, and the diagnostic names
519    /// the one to fix.
520    #[test]
521    fn windows_reserved_characters_are_rejected() {
522        for (name, character) in [
523            ("c:drive", ':'),
524            ("what?", '?'),
525            ("glob*.txt", '*'),
526            ("a|b", '|'),
527            ("say \"hi\"", '"'),
528            ("<draft>", '<'),
529            ("out>", '>'),
530            ("back\\slash.txt", '\\'),
531        ] {
532            let error = DisplayName::parse(name).expect_err("`{name}` should be rejected");
533            assert_eq!(
534                error,
535                PathError::UnportableDisplayNameCharacter {
536                    display_name: name.to_owned(),
537                    character,
538                },
539                "`{name}` should name the character it broke on"
540            );
541            let message = error.to_string();
542            assert!(
543                message.contains(name) && message.contains(character),
544                "the diagnostic must name the name and the character, got: {message}"
545            );
546
547            // Path components enter through the same grammar.
548            assert_eq!(
549                AbsolutePath::parse(format!("/docs/{name}")),
550                Err(PathError::UnportableDisplayNameCharacter {
551                    display_name: name.to_owned(),
552                    character,
553                })
554            );
555        }
556
557        // The first reserved character is the one reported, so a name with
558        // several is fixed one clear step at a time.
559        assert_eq!(
560            DisplayName::parse("a?b:c"),
561            Err(PathError::UnportableDisplayNameCharacter {
562                display_name: "a?b:c".to_owned(),
563                character: '?',
564            })
565        );
566
567        // Punctuation Windows does allow stays legal; the rule is a fixed
568        // set, not a suspicion about symbols.
569        for name in [
570            "report;final.txt",
571            "hello!.txt",
572            "it's.txt",
573            "a+b=c.txt",
574            "~backup#1.txt",
575            "100%.txt",
576            "a&b.txt",
577            "notes (draft).txt",
578            "list[0].txt",
579            "set{a}.txt",
580            "a,b.txt",
581            "user@host.txt",
582            "a^b$c.txt",
583        ] {
584            assert!(
585                DisplayName::parse(name).is_ok(),
586                "`{name}` should be accepted"
587            );
588        }
589    }
590
591    #[test]
592    fn paths_are_bounded_in_bytes_and_depth() {
593        let deep = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH + 1].join("/"));
594        assert!(matches!(
595            AbsolutePath::parse(&deep),
596            Err(PathError::PathTooDeep { .. })
597        ));
598        let long_component = "a".repeat(200);
599        let mut long = String::new();
600        while long.len() <= super::MAX_PATH_BYTES {
601            long.push('/');
602            long.push_str(&long_component);
603        }
604        assert!(matches!(
605            AbsolutePath::parse(&long),
606            Err(PathError::PathTooLong { .. })
607        ));
608        let fine = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH].join("/"));
609        assert!(AbsolutePath::parse(&fine).is_ok());
610    }
611
612    #[test]
613    fn absolute_path_root_is_valid() {
614        let path = AbsolutePath::parse("/").expect("root should parse");
615
616        assert_eq!(path.as_str(), "/");
617        assert!(path.is_root());
618        assert!(path.components().is_empty());
619        assert!(path.parent().is_none());
620        assert!(path.final_component().is_none());
621    }
622
623    #[test]
624    fn absolute_path_rejects_dot_and_dotdot_components() {
625        assert!(matches!(
626            AbsolutePath::parse("/docs/./a.txt"),
627            Err(PathError::DotComponent { .. })
628        ));
629        assert!(matches!(
630            AbsolutePath::parse("/docs/../a.txt"),
631            Err(PathError::ParentComponent { .. })
632        ));
633    }
634
635    #[test]
636    fn absolute_path_rejects_noncanonical_spellings() {
637        assert_eq!(AbsolutePath::parse("//a"), Err(PathError::EmptyDisplayName));
638        assert_eq!(
639            AbsolutePath::parse("/a//b"),
640            Err(PathError::EmptyDisplayName)
641        );
642        assert_eq!(AbsolutePath::parse("/a/"), Err(PathError::EmptyDisplayName));
643        assert!(matches!(
644            AbsolutePath::parse("a"),
645            Err(PathError::RelativePath { .. })
646        ));
647        assert_eq!(AbsolutePath::parse(""), Err(PathError::EmptyPath));
648    }
649
650    #[test]
651    fn absolute_path_serde_is_a_validated_plain_string() {
652        let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
653
654        assert_eq!(
655            serde_json::to_string(&path).expect("serialize path"),
656            r#""/Docs/ReadMe.TXT""#
657        );
658        assert_eq!(
659            serde_json::from_str::<AbsolutePath>(r#""/Docs/ReadMe.TXT""#)
660                .expect("deserialize path"),
661            path
662        );
663        assert!(serde_json::from_str::<AbsolutePath>(r#""relative/path""#).is_err());
664    }
665
666    #[test]
667    fn absolute_path_parent_final_component_and_join_preserve_display_spelling() {
668        let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
669        let parent = path.parent().expect("non-root path should have parent");
670
671        assert_eq!(parent.as_str(), "/Docs");
672        assert_eq!(
673            path.final_component()
674                .expect("non-root path has a final component")
675                .as_str(),
676            "ReadMe.TXT"
677        );
678        assert_eq!(
679            parent
680                .join(&DisplayName::parse("Child.TXT").expect("display name should parse"))
681                .as_str(),
682            "/Docs/Child.TXT"
683        );
684    }
685
686    #[test]
687    fn display_name_rejects_invalid_spellings() {
688        assert_eq!(DisplayName::parse(""), Err(PathError::EmptyDisplayName));
689        assert!(matches!(
690            DisplayName::parse("a/b"),
691            Err(PathError::DisplayNameContainsSeparator { .. })
692        ));
693        assert!(matches!(
694            DisplayName::parse("."),
695            Err(PathError::ReservedDisplayName { .. })
696        ));
697    }
698
699    #[test]
700    fn display_name_rejects_control_characters() {
701        assert_eq!(
702            DisplayName::parse("a\u{0}b"),
703            Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
704        );
705        assert_eq!(
706            DisplayName::parse("line\nbreak"),
707            Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x0A })
708        );
709        assert_eq!(
710            DisplayName::parse("c1\u{85}"),
711            Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x85 })
712        );
713        // Format characters are not controls; names keep them as given.
714        DisplayName::parse("bidi\u{202E}name").expect("format characters are allowed");
715    }
716
717    #[test]
718    fn display_name_enforces_the_byte_cap_as_stored() {
719        DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES))
720            .expect("255 bytes is the maximum, inclusive");
721        assert_eq!(
722            DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES + 1)),
723            Err(PathError::DisplayNameTooLong { byte_length: 256 })
724        );
725        // The cap counts bytes, not characters: 128 two-byte characters
726        // exceed it.
727        assert_eq!(
728            DisplayName::parse("é".repeat(128)),
729            Err(PathError::DisplayNameTooLong { byte_length: 256 })
730        );
731    }
732
733    #[test]
734    fn maximal_casefold_expansion_stays_within_the_name_key_cap() {
735        // U+0390 case-folds to three code points (six bytes from two): the
736        // worst byte expansion in the fold tables. A maximum-length name of
737        // it folds to 762 bytes, inside the 768-byte key cap — the
738        // headroom [`crate::ids::MAX_NAME_KEY_BYTES`] documents.
739        let display_name =
740            DisplayName::parse("\u{0390}".repeat(127)).expect("maximal expander parses");
741        let key = NameKey::for_display_name(&display_name);
742        assert!(key.as_str().len() <= crate::ids::MAX_NAME_KEY_BYTES);
743    }
744
745    #[test]
746    fn absolute_path_components_satisfy_the_display_name_grammar() {
747        assert!(matches!(
748            AbsolutePath::parse("/docs/bad\u{0}name"),
749            Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
750        ));
751        assert!(matches!(
752            AbsolutePath::parse(format!("/docs/{}", "a".repeat(256))),
753            Err(PathError::DisplayNameTooLong { .. })
754        ));
755    }
756
757    #[test]
758    fn name_key_matches_folding_helper() {
759        let display_name = DisplayName::parse("Cafe\u{301}.TXT").expect("display name");
760        let key = NameKey::for_display_name(&display_name);
761
762        assert_eq!(
763            key.as_str(),
764            name_key_for_display_name(display_name.as_str())
765        );
766    }
767}