@@ -201,7 +201,7 @@ func CpToInstance(ctx context.Context, cfg CpConfig, opts CpToInstanceOptions) e
201201 }
202202
203203 if srcInfo .IsDir () {
204- return copyDirToWs (ctx , cfg , ws , opts .SrcPath , opts .DstPath , opts .InstanceID , opts .Archive , opts .FollowLinks , opts .Callbacks )
204+ return copyDirToWs (ctx , cfg , ws , opts .SrcPath , opts .DstPath , opts .InstanceID , opts .Archive , opts .FollowLinks , opts .Dialer , opts . Callbacks )
205205 }
206206 return copyFileToWs (ws , opts .SrcPath , srcInfo .Size (), opts .Callbacks )
207207}
@@ -271,7 +271,7 @@ func copyFileToWs(ws WsConn, srcPath string, size int64, callbacks *CpCallbacks)
271271}
272272
273273// copyDirToWs copies a directory to the WebSocket
274- func copyDirToWs (ctx context.Context , cfg CpConfig , ws WsConn , srcPath , dstPath , instanceID string , archive , followLinks bool , callbacks * CpCallbacks ) error {
274+ func copyDirToWs (ctx context.Context , cfg CpConfig , ws WsConn , srcPath , dstPath , instanceID string , archive , followLinks bool , dialer WsDialer , callbacks * CpCallbacks ) error {
275275 // For directory copy, we just send the end marker
276276 // The server will create the directory
277277 endMsg , _ := json .Marshal (map [string ]string {"type" : "end" })
@@ -326,6 +326,7 @@ func copyDirToWs(ctx context.Context, cfg CpConfig, ws WsConn, srcPath, dstPath,
326326 Mode : info .Mode ().Perm (),
327327 Archive : archive ,
328328 FollowLinks : followLinks ,
329+ Dialer : dialer ,
329330 Callbacks : callbacks ,
330331 })
331332 })
@@ -383,6 +384,7 @@ func CpFromInstance(ctx context.Context, cfg CpConfig, opts CpFromInstanceOption
383384 var currentFile * os.File
384385 var currentHeader * cpFileHeader
385386 var bytesReceived int64
387+ var receivedFinal bool
386388
387389 // Ensure any open file is closed on function exit (fixes file handle leak)
388390 defer func () {
@@ -493,6 +495,7 @@ func CpFromInstance(ctx context.Context, cfg CpConfig, opts CpFromInstanceOption
493495 }
494496
495497 if endMarker .Final {
498+ receivedFinal = true
496499 return nil
497500 }
498501
@@ -524,6 +527,10 @@ func CpFromInstance(ctx context.Context, cfg CpConfig, opts CpFromInstanceOption
524527 }
525528 }
526529
530+ // If connection closed without receiving final marker, the transfer was incomplete
531+ if ! receivedFinal {
532+ return fmt .Errorf ("copy stream ended without completion marker" )
533+ }
527534 return nil
528535}
529536
@@ -555,7 +562,9 @@ func sanitizePath(base, path string) (string, error) {
555562 }
556563
557564 // Ensure the result is under the base directory
558- if ! strings .HasPrefix (absResult , absBase + string (filepath .Separator )) && absResult != absBase {
565+ // Special case: if base is root ("/"), everything under it is valid
566+ isRoot := absBase == "/" || absBase == string (filepath .Separator )
567+ if ! isRoot && ! strings .HasPrefix (absResult , absBase + string (filepath .Separator )) && absResult != absBase {
559568 return "" , fmt .Errorf ("invalid path: path escapes destination: %s" , path )
560569 }
561570
@@ -569,7 +578,9 @@ func buildWsURL(baseURL, instanceID string) (string, error) {
569578 return "" , fmt .Errorf ("invalid base URL: %w" , err )
570579 }
571580
572- u .Path = fmt .Sprintf ("/instances/%s/cp" , instanceID )
581+ // Append to existing path (preserves any path prefix like /api)
582+ // Use path.Join to handle trailing slashes and ensure clean paths
583+ u .Path = path .Join (u .Path , "instances" , instanceID , "cp" )
573584
574585 switch u .Scheme {
575586 case "https" :
0 commit comments