@@ -9,7 +9,16 @@ use std::fmt::{Display, Formatter};
99use std:: path:: { Component , PathBuf } ;
1010use std:: str:: FromStr ;
1111
12+ use caseless:: Caseless ;
1213use serde:: { Deserialize , Deserializer , Serialize } ;
14+ use unicode_normalization_v16:: UnicodeNormalization ;
15+
16+ /// Frozen ADR0005 portable filesystem ceilings. Both encodings are bounded so
17+ /// an accepted logical path is materializable on every supported host.
18+ pub const MAX_LOGICAL_PATH_COMPONENT_UTF8_BYTES : usize = 255 ;
19+ pub const MAX_LOGICAL_PATH_COMPONENT_UTF16_UNITS : usize = 255 ;
20+ pub const MAX_LOGICAL_PATH_UTF8_BYTES : usize = 1024 ;
21+ pub const MAX_LOGICAL_PATH_UTF16_UNITS : usize = 1024 ;
1322
1423use crate :: model:: { EntityKind , HydrationState , MountId , RemoteId , TreeEntry } ;
1524use crate :: planner:: {
@@ -136,6 +145,29 @@ impl LogicalPath {
136145 pub fn to_relative_path_buf ( & self ) -> PathBuf {
137146 PathBuf :: from ( & self . 0 )
138147 }
148+
149+ /// ADR0005 portable filesystem collision key.
150+ ///
151+ /// Each component uses Unicode 16 full default non-Turkic case folding
152+ /// followed by NFC. Separators remain structural, so this is safe to use
153+ /// for whole-tree collision detection without consulting the host OS.
154+ pub fn portable_collision_key ( & self ) -> String {
155+ portable_path_collision_key ( & self . 0 )
156+ }
157+ }
158+
159+ fn portable_path_collision_key ( value : & str ) -> String {
160+ value
161+ . split ( '/' )
162+ . map ( |component| {
163+ component
164+ . chars ( )
165+ . default_case_fold ( )
166+ . nfc ( )
167+ . collect :: < String > ( )
168+ } )
169+ . collect :: < Vec < _ > > ( )
170+ . join ( "/" )
139171}
140172
141173impl Display for LogicalPath {
@@ -181,6 +213,9 @@ pub enum LogicalPathError {
181213 ReservedMetadata ,
182214 ReservedName ( String ) ,
183215 UnsafeCharacter ,
216+ NotNfc ( String ) ,
217+ ComponentTooLong ( String ) ,
218+ PathTooLong ,
184219}
185220
186221impl Display for LogicalPathError {
@@ -206,6 +241,19 @@ impl Display for LogicalPathError {
206241 Self :: UnsafeCharacter => {
207242 formatter. write_str ( "logical path contains an unsafe character" )
208243 }
244+ Self :: NotNfc ( component) => {
245+ write ! (
246+ formatter,
247+ "logical path component is not NFC: `{component}`"
248+ )
249+ }
250+ Self :: ComponentTooLong ( component) => {
251+ write ! (
252+ formatter,
253+ "logical path component exceeds ADR0005 limits: `{component}`"
254+ )
255+ }
256+ Self :: PathTooLong => formatter. write_str ( "logical path exceeds ADR0005 limits" ) ,
209257 }
210258 }
211259}
@@ -222,17 +270,36 @@ fn validate_logical_path(value: &str) -> Result<(), LogicalPathError> {
222270 if value. contains ( '\\' ) {
223271 return Err ( LogicalPathError :: Backslash ) ;
224272 }
225- if value. eq_ignore_ascii_case ( RESERVED_EXPORT_METADATA_PATH ) {
273+ if portable_path_collision_key ( value)
274+ == portable_path_collision_key ( RESERVED_EXPORT_METADATA_PATH )
275+ {
226276 return Err ( LogicalPathError :: ReservedMetadata ) ;
227277 }
278+ if value. len ( ) > MAX_LOGICAL_PATH_UTF8_BYTES
279+ || value. encode_utf16 ( ) . count ( ) > MAX_LOGICAL_PATH_UTF16_UNITS
280+ {
281+ return Err ( LogicalPathError :: PathTooLong ) ;
282+ }
228283
229284 for ( index, component) in value. split ( '/' ) . enumerate ( ) {
230285 if component. is_empty ( ) || matches ! ( component, "." | ".." ) {
231286 return Err ( LogicalPathError :: NonNormalizedComponent (
232287 component. to_string ( ) ,
233288 ) ) ;
234289 }
235- if component. chars ( ) . any ( char:: is_control) || component. contains ( ':' ) {
290+ if component. nfc ( ) . ne ( component. chars ( ) ) {
291+ return Err ( LogicalPathError :: NotNfc ( component. to_string ( ) ) ) ;
292+ }
293+ if component. len ( ) > MAX_LOGICAL_PATH_COMPONENT_UTF8_BYTES
294+ || component. encode_utf16 ( ) . count ( ) > MAX_LOGICAL_PATH_COMPONENT_UTF16_UNITS
295+ {
296+ return Err ( LogicalPathError :: ComponentTooLong ( component. to_string ( ) ) ) ;
297+ }
298+ if component. chars ( ) . any ( char:: is_control)
299+ || component
300+ . chars ( )
301+ . any ( |character| matches ! ( character, '<' | '>' | ':' | '"' | '|' | '?' | '*' ) )
302+ {
236303 if index == 0
237304 && component. as_bytes ( ) . get ( 1 ) == Some ( & b':' )
238305 && component. as_bytes ( ) [ 0 ] . is_ascii_alphabetic ( )
@@ -250,14 +317,20 @@ fn validate_logical_path(value: &str) -> Result<(), LogicalPathError> {
250317 . split_once ( '.' )
251318 . map_or ( component, |( stem, _) | stem)
252319 . to_ascii_lowercase ( ) ;
253- if matches ! ( device_name. as_str( ) , "con" | "prn" | "aux" | "nul" )
254- || device_name. strip_prefix ( "com" ) . is_some_and ( |suffix| {
255- matches ! ( suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" )
256- } )
257- || device_name. strip_prefix ( "lpt" ) . is_some_and ( |suffix| {
258- matches ! ( suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" )
259- } )
260- {
320+ if matches ! (
321+ device_name. as_str( ) ,
322+ "con" | "prn" | "aux" | "nul" | "clock$" | "conin$" | "conout$"
323+ ) || device_name. strip_prefix ( "com" ) . is_some_and ( |suffix| {
324+ matches ! (
325+ suffix,
326+ "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
327+ )
328+ } ) || device_name. strip_prefix ( "lpt" ) . is_some_and ( |suffix| {
329+ matches ! (
330+ suffix,
331+ "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
332+ )
333+ } ) {
261334 return Err ( LogicalPathError :: ReservedName ( component. to_string ( ) ) ) ;
262335 }
263336 }
@@ -677,7 +750,13 @@ mod tests {
677750 "safe/./dot.md" ,
678751 ".loc/session.json" ,
679752 ".LOC/SESSION.JSON" ,
753+ ".loc/ſession.json" ,
680754 "safe/NUL.txt" ,
755+ "safe/COM¹.txt" ,
756+ "safe/CONOUT$.log" ,
757+ "safe/bad?.md" ,
758+ "safe/bad|name.md" ,
759+ "safe/e\u{301} .md" ,
681760 ] ;
682761
683762 for value in cases {
@@ -688,6 +767,32 @@ mod tests {
688767 }
689768 }
690769
770+ #[ test]
771+ fn reserved_export_metadata_uses_the_full_adr0005_collision_key ( ) {
772+ assert_eq ! (
773+ LogicalPath :: new( ".loc/ſession.json" ) ,
774+ Err ( LogicalPathError :: ReservedMetadata )
775+ ) ;
776+ }
777+
778+ #[ test]
779+ fn logical_path_enforces_utf8_and_utf16_adr0005_ceilings ( ) {
780+ assert ! ( LogicalPath :: new( "a" . repeat( 255 ) ) . is_ok( ) ) ;
781+ assert ! ( matches!(
782+ LogicalPath :: new( "a" . repeat( 256 ) ) ,
783+ Err ( LogicalPathError :: ComponentTooLong ( _) )
784+ ) ) ;
785+ assert ! ( matches!(
786+ LogicalPath :: new( "😀" . repeat( 128 ) ) ,
787+ Err ( LogicalPathError :: ComponentTooLong ( _) )
788+ ) ) ;
789+ let long_path = vec ! [ "a" . repeat( 255 ) ; 5 ] . join ( "/" ) ;
790+ assert_eq ! (
791+ LogicalPath :: new( long_path) ,
792+ Err ( LogicalPathError :: PathTooLong )
793+ ) ;
794+ }
795+
691796 #[ test]
692797 fn logical_path_deserialization_revalidates_input ( ) {
693798 let error = serde_json:: from_str :: < LogicalPath > ( r#""../escape.md""# )
0 commit comments