@@ -3,7 +3,9 @@ use serde::{
33 Deserialize , Deserializer , Serialize , Serializer ,
44 de:: value:: StringDeserializer ,
55} ;
6- use typed_path:: { Utf8Component , Utf8TypedPathBuf , Utf8UnixPathBuf } ;
6+ use typed_path:: {
7+ Utf8Component , Utf8TypedPathBuf , Utf8UnixComponent , Utf8UnixPathBuf ,
8+ } ;
79
810#[ derive(
911 Eq , PartialEq , Hash , Debug , Clone , derive_more:: Display , derive_more:: Deref ,
@@ -25,53 +27,24 @@ impl<'de> Deserialize<'de> for SafeRelativeUtf8UnixPathBuf {
2527 ) ) ;
2628 } ;
2729
28- // At this point, we may have a pseudo-Unix path like `my\directory`, which we should reject
29- // to guarantee consistent cross-platform behavior when interpreting component separators
30- if path. as_str ( ) . contains ( '\\' ) {
31- return Err ( serde:: de:: Error :: custom (
32- "File path must not contain backslashes" ,
33- ) ) ;
34- }
35-
3630 let mut path_components = path. components ( ) . peekable ( ) ;
3731
3832 if path_components. peek ( ) . is_none ( ) {
3933 return Err ( serde:: de:: Error :: custom ( "File path cannot be empty" ) ) ;
4034 }
4135
42- // All components should be normal: a file or directory name, not `/`, `.`, or `..`
43- if path_components. any ( |component| !component. is_normal ( ) ) {
44- return Err ( serde:: de:: Error :: custom (
45- "File path cannot contain any special component or prefix" ,
46- ) ) ;
47- }
48-
49- if path_components. any ( |component| {
50- let file_name = component. as_str ( ) . to_ascii_uppercase ( ) ;
51-
52- // Windows reserves some special DOS device names in every directory, which may be optionally
53- // followed by an extension or alternate data stream name and be case insensitive. Trying to
54- // write, read, or delete these files is usually not that useful even for malware, since they
55- // mostly refer to console and printer devices, but it's best to avoid them entirely anyway.
56- // References:
57- // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
58- // https://devblogs.microsoft.com/oldnewthing/20031022-00/?p=42073
59- // https://github.com/wine-mirror/wine/blob/01269452e0fbb1f081d506bd64996590a553e2b9/dlls/ntdll/path.c#L66
60- const RESERVED_WINDOWS_DEVICE_NAMES : & [ & str ] = & [
61- "CON" , "PRN" , "AUX" , "NUL" , "COM1" , "COM2" , "COM3" , "COM4" ,
62- "COM5" , "COM6" , "COM7" , "COM8" , "COM9" , "COM¹" , "COM²" , "COM³" ,
63- "LPT1" , "LPT2" , "LPT3" , "LPT4" , "LPT5" , "LPT6" , "LPT7" , "LPT8" ,
64- "LPT9" , "LPT¹" , "LPT²" , "LPT³" , "CONIN$" , "CONOUT$" ,
65- ] ;
66-
67- RESERVED_WINDOWS_DEVICE_NAMES . iter ( ) . any ( |name| {
68- file_name == * name
69- || file_name. starts_with ( & format ! ( "{name}." ) )
70- || file_name. starts_with ( & format ! ( "{name}:" ) )
71- } )
36+ // All components should be normal: a file or directory name, not `/`, or `..`,
37+ // and not refer to any reserved Windows device name. Also, at this point we may have
38+ // a pseudo-Unix path like `my\directory`, which we should reject by filtering out
39+ // backslashes to guarantee consistent cross-platform behavior when interpreting component
40+ // separators
41+ if !path_components. all ( |component| {
42+ ( component. is_normal ( ) || component. is_current ( ) )
43+ && !component. as_str ( ) . contains ( '\\' )
44+ && !is_reserved_windows_device_name ( & component)
7245 } ) {
7346 return Err ( serde:: de:: Error :: custom (
74- "File path contains a reserved Windows device name" ,
47+ "File path cannot contain any special component, prefix, reserved Windows device name, or backslashes " ,
7548 ) ) ;
7649 }
7750
@@ -90,9 +63,13 @@ impl Serialize for SafeRelativeUtf8UnixPathBuf {
9063 return Err ( serde:: ser:: Error :: custom ( "File path cannot be empty" ) ) ;
9164 }
9265
93- if path_components. any ( |component| !component. is_normal ( ) ) {
66+ if !path_components. all ( |component| {
67+ ( component. is_normal ( ) || component. is_current ( ) )
68+ && !component. as_str ( ) . contains ( '\\' )
69+ && !is_reserved_windows_device_name ( & component)
70+ } ) {
9471 return Err ( serde:: ser:: Error :: custom (
95- "File path cannot contain any special component or prefix " ,
72+ "File path cannot contain any special component, prefix, reserved Windows device name, or backslashes " ,
9673 ) ) ;
9774 }
9875
@@ -110,3 +87,62 @@ impl TryFrom<String> for SafeRelativeUtf8UnixPathBuf {
11087 Self :: deserialize ( StringDeserializer :: new ( s) )
11188 }
11289}
90+
91+ fn is_reserved_windows_device_name ( component : & Utf8UnixComponent ) -> bool {
92+ let file_name = component. as_str ( ) . to_ascii_uppercase ( ) ;
93+
94+ // Windows reserves some special DOS device names in every directory, which may be optionally
95+ // followed by an extension or alternate data stream name and be case insensitive. Trying to
96+ // write, read, or delete these files is usually not that useful even for malware, since they
97+ // mostly refer to console and printer devices, but it's best to avoid them entirely anyway.
98+ // References:
99+ // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
100+ // https://devblogs.microsoft.com/oldnewthing/20031022-00/?p=42073
101+ // https://github.com/wine-mirror/wine/blob/01269452e0fbb1f081d506bd64996590a553e2b9/dlls/ntdll/path.c#L66
102+ const RESERVED_WINDOWS_DEVICE_NAMES : & [ & str ] = & [
103+ "CON" , "PRN" , "AUX" , "NUL" , "COM1" , "COM2" , "COM3" , "COM4" , "COM5" ,
104+ "COM6" , "COM7" , "COM8" , "COM9" , "COM¹" , "COM²" , "COM³" , "LPT1" , "LPT2" ,
105+ "LPT3" , "LPT4" , "LPT5" , "LPT6" , "LPT7" , "LPT8" , "LPT9" , "LPT¹" , "LPT²" ,
106+ "LPT³" , "CONIN$" , "CONOUT$" ,
107+ ] ;
108+
109+ RESERVED_WINDOWS_DEVICE_NAMES . iter ( ) . any ( |name| {
110+ file_name. starts_with ( name)
111+ && ( file_name[ name. len ( ) ..] . is_empty ( )
112+ || file_name[ name. len ( ) ..] . starts_with ( '.' )
113+ || file_name[ name. len ( ) ..] . starts_with ( ':' ) )
114+ } )
115+ }
116+
117+ #[ test]
118+ fn safe_relative_path_deserialization_contract ( ) {
119+ let valid_paths = [
120+ "file.txt" ,
121+ "directory/file.txt" ,
122+ "my-directory/file.name.with.dots.tar.gz" ,
123+ "my_directory/123_456-789.file" ,
124+ "./my/file.txt" ,
125+ "my/./file.txt" ,
126+ ] ;
127+ for path in valid_paths {
128+ SafeRelativeUtf8UnixPathBuf :: try_from ( path. to_string ( ) )
129+ . expect ( "Path should be considered valid" ) ;
130+ }
131+
132+ let invalid_paths = [
133+ "" , // Empty path
134+ "/absolute/file.txt" , // Absolute path
135+ "C:/absolute/file.txt" , // Absolute path with common Windows prefix
136+ "//server/share/file.txt" , // Absolute path with Windows UNC prefix
137+ "directory/../file.txt" , // Path with `..` component
138+ "CON.txt" , // Reserved Windows device name
139+ "NUL/file.txt" , // Reserved Windows device name "directory"
140+ "COM1.txt:ads" , // Reserved Windows device name with ADS name
141+ "file\\ name.txt" , // Backslash in file name
142+ "my\\ directory/file.txt" , // Backslash in directory name
143+ ] ;
144+ for path in invalid_paths {
145+ SafeRelativeUtf8UnixPathBuf :: try_from ( path. to_string ( ) )
146+ . expect_err ( "Path should be considered invalid" ) ;
147+ }
148+ }
0 commit comments