@@ -40,19 +40,32 @@ fn is_binary_file(path: &Path) -> io::Result<bool> {
4040/// the workspace boundary (e.g. via `../` traversal or symlink).
4141#[ allow( dead_code) ]
4242fn validate_workspace_boundary ( resolved : & Path , workspace_root : & Path ) -> io:: Result < ( ) > {
43- let resolved = normalize_for_comparison ( resolved) ;
4443 let workspace_root = normalize_for_comparison ( workspace_root) ;
45- if !resolved. starts_with ( & workspace_root) {
46- return Err ( io:: Error :: new (
47- io:: ErrorKind :: PermissionDenied ,
48- format ! (
49- "path {} escapes workspace boundary {}" ,
50- resolved. display( ) ,
51- workspace_root. display( )
52- ) ,
53- ) ) ;
44+ let comparable = normalize_for_comparison ( resolved) ;
45+ if comparable. starts_with ( & workspace_root) || resolves_within ( resolved, & workspace_root) {
46+ return Ok ( ( ) ) ;
5447 }
55- Ok ( ( ) )
48+ Err ( io:: Error :: new (
49+ io:: ErrorKind :: PermissionDenied ,
50+ format ! (
51+ "path {} escapes workspace boundary {}" ,
52+ comparable. display( ) ,
53+ workspace_root. display( )
54+ ) ,
55+ ) )
56+ }
57+
58+ /// Whether `resolved` names a location inside `workspace_root` once the
59+ /// filesystem resolves it. Windows accepts several spellings of one location
60+ /// that [`Path::starts_with`] treats as different: letter case (a working
61+ /// directory typed as `c:\users\...` for `C:\Users\...`), the `\\.\` device
62+ /// namespace, and 8.3 short names. Only consulted after the lexical comparison
63+ /// fails, so it can accept such a spelling but never widens what already
64+ /// compared inside, and a path whose canonical form is outside stays rejected.
65+ fn resolves_within ( resolved : & Path , workspace_root : & Path ) -> bool {
66+ resolved
67+ . canonicalize ( )
68+ . is_ok_and ( |canonical| normalize_for_comparison ( & canonical) . starts_with ( workspace_root) )
5669}
5770
5871/// Text payload returned by file-reading operations.
@@ -958,6 +971,57 @@ mod tests {
958971 . expect ( "equivalent Windows path representations should be accepted" ) ;
959972 }
960973
974+ #[ test]
975+ #[ cfg( unix) ]
976+ fn accepts_a_path_that_resolves_inside_the_workspace ( ) {
977+ let workspace = temp_path ( "boundary-alias-workspace" ) ;
978+ let aliases = temp_path ( "boundary-alias-links" ) ;
979+ std:: fs:: create_dir_all ( workspace. join ( "src" ) ) . expect ( "workspace dir should be created" ) ;
980+ std:: fs:: create_dir_all ( & aliases) . expect ( "alias dir should be created" ) ;
981+ let alias = aliases. join ( "src" ) ;
982+ std:: os:: unix:: fs:: symlink ( workspace. join ( "src" ) , & alias) . expect ( "symlink should create" ) ;
983+ let root = workspace
984+ . canonicalize ( )
985+ . expect ( "workspace should canonicalize" ) ;
986+
987+ super :: validate_workspace_boundary ( & alias, & root)
988+ . expect ( "a spelling that resolves inside the workspace should be accepted" ) ;
989+ super :: validate_workspace_boundary ( & aliases, & root)
990+ . expect_err ( "a path that resolves outside the workspace must stay rejected" ) ;
991+
992+ let _ = std:: fs:: remove_dir_all ( & workspace) ;
993+ let _ = std:: fs:: remove_dir_all ( & aliases) ;
994+ }
995+
996+ // Windows resolves one location from spellings that compare unequal as
997+ // paths, letter case and the `\\.\` device namespace among them.
998+ #[ test]
999+ #[ cfg( windows) ]
1000+ fn accepts_other_windows_spellings_of_a_workspace_path ( ) {
1001+ let workspace = temp_path ( "Boundary-Case-Workspace" ) ;
1002+ std:: fs:: create_dir_all ( workspace. join ( "src" ) ) . expect ( "workspace dir should be created" ) ;
1003+ let root = workspace
1004+ . canonicalize ( )
1005+ . expect ( "workspace should canonicalize" ) ;
1006+
1007+ let lower = PathBuf :: from ( workspace. join ( "src" ) . to_string_lossy ( ) . to_lowercase ( ) ) ;
1008+ super :: validate_workspace_boundary ( & lower, & root)
1009+ . expect ( "a lower-cased spelling of a workspace path should be accepted" ) ;
1010+
1011+ let device = PathBuf :: from ( format ! ( r"\\.\{}" , workspace. join( "src" ) . display( ) ) ) ;
1012+ super :: validate_workspace_boundary ( & device, & root)
1013+ . expect ( "a device-namespace spelling of a workspace path should be accepted" ) ;
1014+
1015+ let parent = workspace
1016+ . parent ( )
1017+ . expect ( "workspace has a parent" )
1018+ . to_path_buf ( ) ;
1019+ super :: validate_workspace_boundary ( & parent, & root)
1020+ . expect_err ( "the workspace's parent must stay rejected" ) ;
1021+
1022+ let _ = std:: fs:: remove_dir_all ( & workspace) ;
1023+ }
1024+
9611025 #[ test]
9621026 #[ cfg( unix) ]
9631027 fn workspace_write_rejects_parent_symlink_escape_regression_3007_class ( ) {
0 commit comments