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