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. Rejecting here is free pre-release; loosening later
379    // is compatible, tightening later would strand stored names.
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::{name_key_for_display_name, 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    /// The characters Windows reserves are rejected the same way its
525    /// trailing dots and device names already were, and the diagnostic names
526    /// the one to fix.
527    #[test]
528    fn windows_reserved_characters_are_rejected() {
529        for (name, character) in [
530            ("c:drive", ':'),
531            ("what?", '?'),
532            ("glob*.txt", '*'),
533            ("a|b", '|'),
534            ("say \"hi\"", '"'),
535            ("<draft>", '<'),
536            ("out>", '>'),
537            ("back\\slash.txt", '\\'),
538        ] {
539            let error = DisplayName::parse(name).expect_err("`{name}` should be rejected");
540            assert_eq!(
541                error,
542                PathError::UnportableDisplayNameCharacter {
543                    display_name: name.to_owned(),
544                    character,
545                },
546                "`{name}` should name the character it broke on"
547            );
548            let message = error.to_string();
549            assert!(
550                message.contains(name) && message.contains(character),
551                "the diagnostic must name the name and the character, got: {message}"
552            );
553
554            // Path components enter through the same grammar.
555            assert_eq!(
556                AbsolutePath::parse(format!("/docs/{name}")),
557                Err(PathError::UnportableDisplayNameCharacter {
558                    display_name: name.to_owned(),
559                    character,
560                })
561            );
562        }
563
564        // The first reserved character is the one reported, so a name with
565        // several is fixed one clear step at a time.
566        assert_eq!(
567            DisplayName::parse("a?b:c"),
568            Err(PathError::UnportableDisplayNameCharacter {
569                display_name: "a?b:c".to_owned(),
570                character: '?',
571            })
572        );
573
574        // Punctuation Windows does allow stays legal; the rule is a fixed
575        // set, not a suspicion about symbols.
576        for name in [
577            "report;final.txt",
578            "hello!.txt",
579            "it's.txt",
580            "a+b=c.txt",
581            "~backup#1.txt",
582            "100%.txt",
583            "a&b.txt",
584            "notes (draft).txt",
585            "list[0].txt",
586            "set{a}.txt",
587            "a,b.txt",
588            "user@host.txt",
589            "a^b$c.txt",
590        ] {
591            assert!(
592                DisplayName::parse(name).is_ok(),
593                "`{name}` should be accepted"
594            );
595        }
596    }
597
598    #[test]
599    fn paths_are_bounded_in_bytes_and_depth() {
600        let deep = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH + 1].join("/"));
601        assert!(matches!(
602            AbsolutePath::parse(&deep),
603            Err(PathError::PathTooDeep { .. })
604        ));
605        let long_component = "a".repeat(200);
606        let mut long = String::new();
607        while long.len() <= super::MAX_PATH_BYTES {
608            long.push('/');
609            long.push_str(&long_component);
610        }
611        assert!(matches!(
612            AbsolutePath::parse(&long),
613            Err(PathError::PathTooLong { .. })
614        ));
615        let fine = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH].join("/"));
616        assert!(AbsolutePath::parse(&fine).is_ok());
617    }
618
619    #[test]
620    fn absolute_path_root_is_valid() {
621        let path = AbsolutePath::parse("/").expect("root should parse");
622
623        assert_eq!(path.as_str(), "/");
624        assert!(path.is_root());
625        assert!(path.components().is_empty());
626        assert!(path.parent().is_none());
627        assert!(path.final_component().is_none());
628    }
629
630    #[test]
631    fn absolute_path_rejects_dot_and_dotdot_components() {
632        assert!(matches!(
633            AbsolutePath::parse("/docs/./a.txt"),
634            Err(PathError::DotComponent { .. })
635        ));
636        assert!(matches!(
637            AbsolutePath::parse("/docs/../a.txt"),
638            Err(PathError::ParentComponent { .. })
639        ));
640    }
641
642    #[test]
643    fn absolute_path_rejects_noncanonical_spellings() {
644        assert_eq!(AbsolutePath::parse("//a"), Err(PathError::EmptyDisplayName));
645        assert_eq!(
646            AbsolutePath::parse("/a//b"),
647            Err(PathError::EmptyDisplayName)
648        );
649        assert_eq!(AbsolutePath::parse("/a/"), Err(PathError::EmptyDisplayName));
650        assert!(matches!(
651            AbsolutePath::parse("a"),
652            Err(PathError::RelativePath { .. })
653        ));
654        assert_eq!(AbsolutePath::parse(""), Err(PathError::EmptyPath));
655    }
656
657    #[test]
658    fn absolute_path_serde_is_a_validated_plain_string() {
659        let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
660
661        assert_eq!(
662            serde_json::to_string(&path).expect("serialize path"),
663            r#""/Docs/ReadMe.TXT""#
664        );
665        assert_eq!(
666            serde_json::from_str::<AbsolutePath>(r#""/Docs/ReadMe.TXT""#)
667                .expect("deserialize path"),
668            path
669        );
670        assert!(serde_json::from_str::<AbsolutePath>(r#""relative/path""#).is_err());
671    }
672
673    #[test]
674    fn absolute_path_parent_final_component_and_join_preserve_display_spelling() {
675        let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
676        let parent = path.parent().expect("non-root path should have parent");
677
678        assert_eq!(parent.as_str(), "/Docs");
679        assert_eq!(
680            path.final_component()
681                .expect("non-root path has a final component")
682                .as_str(),
683            "ReadMe.TXT"
684        );
685        assert_eq!(
686            parent
687                .join(&DisplayName::parse("Child.TXT").expect("display name should parse"))
688                .as_str(),
689            "/Docs/Child.TXT"
690        );
691    }
692
693    #[test]
694    fn display_name_rejects_invalid_spellings() {
695        assert_eq!(DisplayName::parse(""), Err(PathError::EmptyDisplayName));
696        assert!(matches!(
697            DisplayName::parse("a/b"),
698            Err(PathError::DisplayNameContainsSeparator { .. })
699        ));
700        assert!(matches!(
701            DisplayName::parse("."),
702            Err(PathError::ReservedDisplayName { .. })
703        ));
704    }
705
706    #[test]
707    fn display_name_rejects_control_characters() {
708        assert_eq!(
709            DisplayName::parse("a\u{0}b"),
710            Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
711        );
712        assert_eq!(
713            DisplayName::parse("line\nbreak"),
714            Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x0A })
715        );
716        assert_eq!(
717            DisplayName::parse("c1\u{85}"),
718            Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x85 })
719        );
720        // Format characters are not controls; names keep them as given.
721        DisplayName::parse("bidi\u{202E}name").expect("format characters are allowed");
722    }
723
724    #[test]
725    fn display_name_enforces_the_byte_cap_as_stored() {
726        DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES))
727            .expect("255 bytes is the maximum, inclusive");
728        assert_eq!(
729            DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES + 1)),
730            Err(PathError::DisplayNameTooLong { byte_length: 256 })
731        );
732        // The cap counts bytes, not characters: 128 two-byte characters
733        // exceed it.
734        assert_eq!(
735            DisplayName::parse("é".repeat(128)),
736            Err(PathError::DisplayNameTooLong { byte_length: 256 })
737        );
738    }
739
740    #[test]
741    fn maximal_casefold_expansion_stays_within_the_name_key_cap() {
742        // U+0390 case-folds to three code points (six bytes from two): the
743        // worst byte expansion in the fold tables. A maximum-length name of
744        // it folds to 762 bytes, inside the 768-byte key cap — the
745        // headroom [`crate::ids::MAX_NAME_KEY_BYTES`] documents.
746        let display_name =
747            DisplayName::parse("\u{0390}".repeat(127)).expect("maximal expander parses");
748        let key = NameKey::for_display_name(&display_name);
749        assert!(key.as_str().len() <= crate::ids::MAX_NAME_KEY_BYTES);
750    }
751
752    #[test]
753    fn absolute_path_components_satisfy_the_display_name_grammar() {
754        assert!(matches!(
755            AbsolutePath::parse("/docs/bad\u{0}name"),
756            Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
757        ));
758        assert!(matches!(
759            AbsolutePath::parse(format!("/docs/{}", "a".repeat(256))),
760            Err(PathError::DisplayNameTooLong { .. })
761        ));
762    }
763
764    #[test]
765    fn name_key_matches_folding_helper() {
766        let display_name = DisplayName::parse("Cafe\u{301}.TXT").expect("display name");
767        let key = NameKey::for_display_name(&display_name);
768
769        assert_eq!(
770            key.as_str(),
771            name_key_for_display_name(display_name.as_str())
772        );
773    }
774}