@@ -481,11 +481,8 @@ func canonicalizeRoots(roots []string) []string {
481481}
482482
483483func ensureResolvedPathWithinRoots (path string , roots []string ) error {
484- resolvedPath , err := filepath . EvalSymlinks (path )
484+ resolvedPath , err := resolvePathForContainment (path )
485485 if err != nil {
486- if errors .Is (err , os .ErrNotExist ) {
487- return nil
488- }
489486 return err
490487 }
491488 if ! isPathWithinRoots (resolvedPath , roots ) {
@@ -494,6 +491,44 @@ func ensureResolvedPathWithinRoots(path string, roots []string) error {
494491 return nil
495492}
496493
494+ // resolvePathForContainment resolves every existing path component, preserving any
495+ // missing tail for the final containment check. filepath.EvalSymlinks returns
496+ // os.ErrNotExist for the whole path when its final component is missing, which would
497+ // otherwise hide a symlinked parent that escapes the configured roots.
498+ func resolvePathForContainment (path string ) (string , error ) {
499+ candidate := filepath .Clean (path )
500+ var missing []string
501+
502+ for {
503+ resolved , err := filepath .EvalSymlinks (candidate )
504+ if err == nil {
505+ if len (missing ) > 0 {
506+ info , statErr := os .Stat (resolved )
507+ if statErr != nil {
508+ return "" , statErr
509+ }
510+ if ! info .IsDir () {
511+ return "" , fmt .Errorf ("path component %q is not a directory" , candidate )
512+ }
513+ }
514+ for i := len (missing ) - 1 ; i >= 0 ; i -- {
515+ resolved = filepath .Join (resolved , missing [i ])
516+ }
517+ return resolved , nil
518+ }
519+ if ! errors .Is (err , os .ErrNotExist ) {
520+ return "" , err
521+ }
522+
523+ parent := filepath .Dir (candidate )
524+ if parent == candidate {
525+ return "" , err
526+ }
527+ missing = append (missing , filepath .Base (candidate ))
528+ candidate = parent
529+ }
530+ }
531+
497532func containsFold (values []string , value string ) bool {
498533 for _ , v := range values {
499534 if strings .EqualFold (v , value ) {
0 commit comments