@@ -137,6 +137,11 @@ type cpError struct {
137137// DstPath: "/app/file.txt",
138138// })
139139func CpToInstance (ctx context.Context , cfg CpConfig , opts CpToInstanceOptions ) error {
140+ return cpToInstanceInternal (ctx , cfg , opts , nil )
141+ }
142+
143+ // cpToInstanceInternal is the internal implementation that accepts visitedDirs for cycle detection
144+ func cpToInstanceInternal (ctx context.Context , cfg CpConfig , opts CpToInstanceOptions , visitedDirs map [string ]bool ) error {
140145 // Build WebSocket URL
141146 wsURL , err := buildWsURL (cfg .BaseURL , opts .InstanceID )
142147 if err != nil {
@@ -200,7 +205,16 @@ func CpToInstance(ctx context.Context, cfg CpConfig, opts CpToInstanceOptions) e
200205 }
201206
202207 if srcInfo .IsDir () {
203- return copyDirToWs (ctx , cfg , ws , opts .SrcPath , opts .DstPath , opts .InstanceID , opts .Archive , opts .FollowLinks , opts .Dialer , opts .Callbacks )
208+ // Track visited directories to detect symlink cycles
209+ if visitedDirs == nil {
210+ visitedDirs = make (map [string ]bool )
211+ }
212+ absPath , _ := filepath .Abs (opts .SrcPath )
213+ absPath , _ = filepath .EvalSymlinks (absPath )
214+ if ! visitedDirs [absPath ] {
215+ visitedDirs [absPath ] = true
216+ }
217+ return copyDirToWs (ctx , cfg , ws , opts .SrcPath , opts .DstPath , opts .InstanceID , opts .Archive , opts .FollowLinks , opts .Dialer , opts .Callbacks , visitedDirs )
204218 }
205219 return copyFileToWs (ws , opts .SrcPath , srcInfo .Size (), opts .Callbacks )
206220}
@@ -286,7 +300,7 @@ func copyFileToWs(ws WsConn, srcPath string, size int64, callbacks *CpCallbacks)
286300}
287301
288302// copyDirToWs copies a directory to the WebSocket
289- func copyDirToWs (ctx context.Context , cfg CpConfig , ws WsConn , srcPath , dstPath , instanceID string , archive , followLinks bool , dialer WsDialer , callbacks * CpCallbacks ) error {
303+ func copyDirToWs (ctx context.Context , cfg CpConfig , ws WsConn , srcPath , dstPath , instanceID string , archive , followLinks bool , dialer WsDialer , callbacks * CpCallbacks , visitedDirs map [ string ] bool ) error {
290304 // For directory copy, we just send the end marker
291305 // The server will create the directory
292306 endMsg , _ := json .Marshal (map [string ]string {"type" : "end" })
@@ -348,6 +362,28 @@ func copyDirToWs(ctx context.Context, cfg CpConfig, ws WsConn, srcPath, dstPath,
348362 return fmt .Errorf ("info: %w" , err )
349363 }
350364
365+ // Check for symlink cycles when following links
366+ if followLinks && info .Mode ()& fs .ModeSymlink != 0 {
367+ // Resolve the symlink to its real path
368+ realPath , err := filepath .EvalSymlinks (walkPath )
369+ if err != nil {
370+ // If we can't resolve the symlink, skip it (might be broken)
371+ return nil
372+ }
373+ realInfo , err := os .Stat (realPath )
374+ if err != nil {
375+ return nil // Skip broken symlinks
376+ }
377+ // If it's a directory symlink, check for cycles
378+ if realInfo .IsDir () {
379+ if visitedDirs [realPath ] {
380+ // Cycle detected, skip this symlink to prevent infinite recursion
381+ return nil
382+ }
383+ visitedDirs [realPath ] = true
384+ }
385+ }
386+
351387 // Determine the mode to use
352388 // For symlinks: if following links, let CpToInstance auto-detect from target
353389 // (symlinks show 0777 but that's not the target's actual mode)
@@ -360,7 +396,7 @@ func copyDirToWs(ctx context.Context, cfg CpConfig, ws WsConn, srcPath, dstPath,
360396
361397 // For each file/dir, we need a new WebSocket connection
362398 // This is because the protocol is one-file-per-connection
363- return CpToInstance (ctx , cfg , CpToInstanceOptions {
399+ return cpToInstanceInternal (ctx , cfg , CpToInstanceOptions {
364400 InstanceID : instanceID ,
365401 SrcPath : walkPath ,
366402 DstPath : targetPath ,
@@ -369,7 +405,7 @@ func copyDirToWs(ctx context.Context, cfg CpConfig, ws WsConn, srcPath, dstPath,
369405 FollowLinks : followLinks ,
370406 Dialer : dialer ,
371407 Callbacks : callbacks ,
372- })
408+ }, visitedDirs )
373409 })
374410}
375411
@@ -477,7 +513,7 @@ func CpFromInstance(ctx context.Context, cfg CpConfig, opts CpFromInstanceOption
477513 return fmt .Errorf ("create directory %s: %w" , targetPath , err )
478514 }
479515 // Apply ownership if archive mode
480- if opts .Archive && ( header . Uid != 0 || header . Gid != 0 ) {
516+ if opts .Archive {
481517 os .Chown (targetPath , int (header .Uid ), int (header .Gid ))
482518 }
483519 } else if header .IsSymlink {
@@ -494,7 +530,7 @@ func CpFromInstance(ctx context.Context, cfg CpConfig, opts CpFromInstanceOption
494530 return fmt .Errorf ("create symlink %s: %w" , targetPath , err )
495531 }
496532 // Apply ownership if archive mode (use Lchown for symlinks)
497- if opts .Archive && ( header . Uid != 0 || header . Gid != 0 ) {
533+ if opts .Archive {
498534 os .Lchown (targetPath , int (header .Uid ), int (header .Gid ))
499535 }
500536 } else {
@@ -528,7 +564,7 @@ func CpFromInstance(ctx context.Context, cfg CpConfig, opts CpFromInstanceOption
528564 os .Chtimes (targetPath , mtime , mtime )
529565 }
530566 // Apply ownership if archive mode
531- if opts .Archive && ( currentHeader . Uid != 0 || currentHeader . Gid != 0 ) {
567+ if opts .Archive {
532568 os .Chown (targetPath , int (currentHeader .Uid ), int (currentHeader .Gid ))
533569 }
534570 // Notify file end
0 commit comments