@@ -56,11 +56,11 @@ func ResolveSymlinks(srcDir, dstDir string) error {
5656 return fmt .Errorf ("srcDir %s is not a directory" , absSrc )
5757 }
5858
59- if err : = checkDstDir (dstDir ); err != nil {
59+ if err = checkDstDir (dstDir ); err != nil {
6060 return err
6161 }
6262
63- return copyResolvedDir ( realSrc , dstDir , make (map [string ]bool ))
63+ return copyDir ( "" , realSrc , dstDir , make (map [string ]bool ))
6464}
6565
6666// ResolveSymlinksRoot is the confined variant of ResolveSymlinks: every
@@ -109,11 +109,11 @@ func ResolveSymlinksRoot(rootDir, srcDir, dstDir string) error {
109109 return fmt .Errorf ("srcDir %s is not a directory" , absSrc )
110110 }
111111
112- if err : = checkDstDir (dstDir ); err != nil {
112+ if err = checkDstDir (dstDir ); err != nil {
113113 return err
114114 }
115115
116- return copyConfinedDir (realRoot , realSrc , dstDir , make (map [string ]bool ))
116+ return copyDir (realRoot , realSrc , dstDir , make (map [string ]bool ))
117117}
118118
119119// checkDstDir verifies that dstDir exists and is a directory.
@@ -128,13 +128,16 @@ func checkDstDir(dstDir string) error {
128128 return nil
129129}
130130
131- // copyResolvedDir recursively copies srcDir (already resolved via
132- // EvalSymlinks) into dstDir. visited tracks resolved directory paths
133- // currently on the call stack so that a re-entry via a symlink does
134- // not loop. Entries are removed when the call returns, so the same
135- // directory may be copied again through a different symlink — this is
136- // intentional (both link sites need the content).
137- func copyResolvedDir (srcDir , dstDir string , visited map [string ]bool ) error {
131+ // copyDir recursively copies srcDir into dstDir, resolving all
132+ // symlinks. srcDir must already be fully resolved (no symlink
133+ // components). If confineRoot is non-empty, every symlink target must
134+ // resolve within confineRoot; a violation fails the call. visited is a
135+ // stack-based cycle breaker: directories currently on the call stack
136+ // are skipped to prevent infinite loops from symlink cycles. Entries
137+ // are removed when the call returns, so the same directory may be
138+ // copied again through a different symlink — this is intentional
139+ // (both link sites need the content).
140+ func copyDir (confineRoot , srcDir , dstDir string , visited map [string ]bool ) error {
138141 if visited [srcDir ] {
139142 return nil
140143 }
@@ -150,74 +153,36 @@ func copyResolvedDir(srcDir, dstDir string, visited map[string]bool) error {
150153 srcPath := filepath .Join (srcDir , entry .Name ())
151154 dstPath := filepath .Join (dstDir , entry .Name ())
152155
153- realPath , err := filepath .EvalSymlinks (srcPath )
154- if err != nil {
155- return fmt .Errorf ("resolving symlink %s: %w" , srcPath , err )
156- }
157- realInfo , err := os .Stat (realPath )
158- if err != nil {
159- return fmt .Errorf ("stat resolved path %s: %w" , realPath , err )
160- }
156+ isLink := entry .Type ()& os .ModeSymlink != 0
161157
162- if realInfo .IsDir () {
163- if err := os .MkdirAll (dstPath , realInfo .Mode ()); err != nil {
164- return err
158+ realPath := srcPath
159+ if isLink {
160+ realPath , err = filepath .EvalSymlinks (srcPath )
161+ if err != nil {
162+ return fmt .Errorf ("resolving symlink %s: %w" , srcPath , err )
165163 }
166- if err := copyResolvedDir (realPath , dstPath , visited ); err != nil {
167- return err
164+ // Report the logical path, not the resolved target,
165+ // to avoid leaking filesystem layout.
166+ if confineRoot != "" && ! isWithin (confineRoot , realPath ) {
167+ return fmt .Errorf ("symlink %s resolves outside rootDir" , srcPath )
168168 }
169- continue
170- }
171-
172- if ! realInfo .Mode ().IsRegular () {
173- continue
174169 }
175170
176- if err := copyResolvedFile (realPath , dstPath , realInfo .Mode ()); err != nil {
177- return err
178- }
179- }
180- return nil
181- }
182-
183- // copyConfinedDir is the root-confined equivalent of copyResolvedDir.
184- // srcDir is assumed already resolved and already verified as within
185- // realRoot. visited is a stack-based cycle breaker (see copyResolvedDir).
186- func copyConfinedDir (realRoot , srcDir , dstDir string , visited map [string ]bool ) error {
187- if visited [srcDir ] {
188- return nil
189- }
190- visited [srcDir ] = true
191- defer delete (visited , srcDir )
192-
193- entries , err := os .ReadDir (srcDir )
194- if err != nil {
195- return err
196- }
197-
198- for _ , entry := range entries {
199- srcPath := filepath .Join (srcDir , entry .Name ())
200- dstPath := filepath .Join (dstDir , entry .Name ())
201-
202- realPath , err := filepath .EvalSymlinks (srcPath )
203- if err != nil {
204- return fmt .Errorf ("resolving %s: %w" , srcPath , err )
205- }
206- // Report the logical path of the offending symlink, not the
207- // resolved target, to avoid leaking filesystem layout.
208- if ! isWithin (realRoot , realPath ) {
209- return fmt .Errorf ("symlink %s resolves outside rootDir" , srcPath )
171+ var realInfo os.FileInfo
172+ if isLink {
173+ realInfo , err = os .Stat (realPath )
174+ } else {
175+ realInfo , err = entry .Info ()
210176 }
211- realInfo , err := os .Stat (realPath )
212177 if err != nil {
213178 return fmt .Errorf ("stat %s: %w" , realPath , err )
214179 }
215180
216181 if realInfo .IsDir () {
217- if err : = os .MkdirAll (dstPath , realInfo .Mode ()); err != nil {
182+ if err = os .MkdirAll (dstPath , realInfo .Mode ()); err != nil {
218183 return err
219184 }
220- if err := copyConfinedDir ( realRoot , realPath , dstPath , visited ); err != nil {
185+ if err = copyDir ( confineRoot , realPath , dstPath , visited ); err != nil {
221186 return err
222187 }
223188 continue
@@ -227,7 +192,7 @@ func copyConfinedDir(realRoot, srcDir, dstDir string, visited map[string]bool) e
227192 continue
228193 }
229194
230- if err : = copyResolvedFile (realPath , dstPath , realInfo .Mode ()); err != nil {
195+ if err = copyResolvedFile (realPath , dstPath , realInfo .Mode ()); err != nil {
231196 return err
232197 }
233198 }
0 commit comments