Skip to content

Commit e893d90

Browse files
committed
fix: circular symlink detection and root ownership preservation
- Add cycle detection for directory copies when following symlinks - Track visited directories by resolved absolute path to prevent infinite recursion - Remove (uid != 0 || gid != 0) check from archive mode chown calls - Now properly preserves root:root ownership in archive mode
1 parent e172b69 commit e893d90

File tree

1 file changed

+43
-7
lines changed

1 file changed

+43
-7
lines changed

lib/cp.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ type cpError struct {
137137
// DstPath: "/app/file.txt",
138138
// })
139139
func 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

Comments
 (0)