diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 9ca02a5dda..fba651911f 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -293,7 +293,7 @@ class RpcApiType { } // command "remotefilecopy" [call] - RemoteFileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { + RemoteFileCopyCommand(client: WshClient, data: CommandFileCopyData, opts?: RpcOpts): Promise { return client.wshRpcCall("remotefilecopy", data, opts); } diff --git a/frontend/app/view/preview/directorypreview.tsx b/frontend/app/view/preview/directorypreview.tsx index 2a29c1e5e7..7ce987d06a 100644 --- a/frontend/app/view/preview/directorypreview.tsx +++ b/frontend/app/view/preview/directorypreview.tsx @@ -2,9 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Button } from "@/app/element/button"; -import { CopyButton } from "@/app/element/copybutton"; import { Input } from "@/app/element/input"; -import { useDimensionsWithCallbackRef } from "@/app/hook/useDimensions"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { atoms, getApi, globalStore } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; @@ -39,16 +37,13 @@ import "./directorypreview.scss"; const PageJumpSize = 20; -type FileCopyStatus = { - copyData: CommandFileCopyData; - copyError: string; - allowRetry: boolean; - isDir: boolean; -}; +const recursiveError = "recursive flag must be set for directory operations"; +const overwriteError = "set overwrite flag to delete the existing file"; +const mergeError = "set overwrite flag to delete the existing contents or set merge flag to merge the contents"; declare module "@tanstack/react-table" { interface TableMeta { - updateName: (path: string) => void; + updateName: (path: string, isDir: boolean) => void; newFile: () => void; newDirectory: () => void; } @@ -216,6 +211,7 @@ function DirectoryTable({ newDirectory, }: DirectoryTableProps) { const fullConfig = useAtomValue(atoms.fullConfigAtom); + const setErrorMsg = useSetAtom(model.errorMsgAtom); const getIconFromMimeType = useCallback( (mimeType: string): string => { while (mimeType.length > 0) { @@ -291,7 +287,7 @@ function DirectoryTable({ const setEntryManagerProps = useSetAtom(entryManagerOverlayPropsAtom); - const updateName = useCallback((path: string) => { + const updateName = useCallback((path: string, isDir: boolean) => { const fileName = path.split("/").at(-1); setEntryManagerProps({ entryManagerType: EntryManagerType.EditName, @@ -302,24 +298,47 @@ function DirectoryTable({ const lastInstance = path.lastIndexOf(fileName); newPath = path.substring(0, lastInstance) + newName; console.log(`replacing ${fileName} with ${newName}: ${path}`); - fireAndForget(async () => { - try { - await RpcApi.FileMoveCommand(TabRpcClient, { - srcuri: await model.formatRemoteUri(path, globalStore.get), - desturi: await model.formatRemoteUri(newPath, globalStore.get), - opts: { - recursive: true, - }, - }); - } catch (e) { - const errorStatus: ErrorMsg = { - status: "Rename Failed", - text: `${e}`, - }; - globalStore.set(model.errorMsgAtom, errorStatus); - } - model.refreshCallback(); - }); + const handleRename = (recursive: boolean) => + fireAndForget(async () => { + try { + let srcuri = await model.formatRemoteUri(path, globalStore.get); + if (isDir) { + srcuri += "/"; + } + await RpcApi.FileMoveCommand(TabRpcClient, { + srcuri, + desturi: await model.formatRemoteUri(newPath, globalStore.get), + opts: { + recursive, + }, + }); + } catch (e) { + const errorText = `${e}`; + console.warn(`Rename failed: ${errorText}`); + let errorMsg: ErrorMsg; + if (errorText.includes(recursiveError)) { + errorMsg = { + status: "Confirm Rename Directory", + text: "Renaming a directory requires the recursive flag. Proceed?", + level: "warning", + buttons: [ + { + text: "Rename Recursively", + onClick: () => handleRename(true), + }, + ], + }; + } else { + errorMsg = { + status: "Rename Failed", + text: `${e}`, + }; + } + setErrorMsg(errorMsg); + } + model.refreshCallback(); + }); + handleRename(false); } setEntryManagerProps(undefined); }, @@ -495,6 +514,7 @@ function TableBody({ const warningBoxRef = useRef(); const rowRefs = useRef([]); const conn = useAtomValue(model.connection); + const setErrorMsg = useSetAtom(model.errorMsgAtom); useEffect(() => { if (focusIndex !== null && rowRefs.current[focusIndex] && bodyRef.current && osRef) { @@ -529,8 +549,41 @@ function TableBody({ if (finfo == null) { return; } - const normPath = finfo.path; const fileName = finfo.path.split("/").pop(); + const handleFileDelete = (recursive: boolean) => + fireAndForget(async () => { + const path = await model.formatRemoteUri(finfo.path, globalStore.get); + try { + await RpcApi.FileDeleteCommand(TabRpcClient, { + path, + recursive, + }); + } catch (e) { + const errorText = `${e}`; + console.warn(`Delete failed: ${errorText}`); + let errorMsg: ErrorMsg; + if (errorText.includes(recursiveError)) { + errorMsg = { + status: "Confirm Delete Directory", + text: "Deleting a directory requires the recursive flag. Proceed?", + level: "warning", + buttons: [ + { + text: "Delete Recursively", + onClick: () => handleFileDelete(true), + }, + ], + }; + } else { + errorMsg = { + status: "Delete Failed", + text: `${e}`, + }; + } + setErrorMsg(errorMsg); + } + setRefreshVersion((current) => current + 1); + }); const menu: ContextMenuItem[] = [ { label: "New File", @@ -547,7 +600,7 @@ function TableBody({ { label: "Rename", click: () => { - table.options.meta.updateName(finfo.path); + table.options.meta.updateName(finfo.path, finfo.isdir); }, }, { @@ -577,23 +630,7 @@ function TableBody({ }, { label: "Delete", - click: () => { - fireAndForget(async () => { - try { - await RpcApi.FileDeleteCommand(TabRpcClient, { - path: await model.formatRemoteUri(finfo.path, globalStore.get), - recursive: false, - }); - } catch (e) { - const errorStatus: ErrorMsg = { - status: "Delete Failed", - text: `${e}`, - }; - globalStore.set(model.errorMsgAtom, errorStatus); - } - setRefreshVersion((current) => current + 1); - }); - }, + click: () => handleFileDelete(false), } ); ContextMenuModel.showContextMenu(menu, e); @@ -728,7 +765,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { const blockData = useAtomValue(model.blockAtom); const finfo = useAtomValue(model.statFile); const dirPath = finfo?.path; - const [copyStatus, setCopyStatus] = useState(null); + const setErrorMsg = useSetAtom(model.errorMsgAtom); useEffect(() => { model.refreshCallback = () => { @@ -754,11 +791,10 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { ); entries = file.entries ?? []; } catch (e) { - const errorStatus: ErrorMsg = { + setErrorMsg({ status: "Cannot Read Directory", text: `${e}`, - }; - globalStore.set(model.errorMsgAtom, errorStatus); + }); } setUnfilteredData(entries); }; @@ -854,28 +890,48 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { }); const handleDropCopy = useCallback( - async (data: CommandFileCopyData, isDir) => { + async (data: CommandFileCopyData, isDir: boolean) => { try { await RpcApi.FileCopyCommand(TabRpcClient, data, { timeout: data.opts.timeout }); - setCopyStatus(null); } catch (e) { - console.log("copy failed:", e); + console.warn("Copy failed:", e); const copyError = `${e}`; - const allowRetry = - copyError.includes("overwrite not specified") || - copyError.includes("neither overwrite nor merge specified") || - copyError.includes("neither merge nor overwrite specified"); - const copyStatus: FileCopyStatus = { - copyError, - copyData: data, - allowRetry, - isDir: isDir, - }; - setCopyStatus(copyStatus); + const allowRetry = copyError.includes(overwriteError) || copyError.includes(mergeError); + let errorMsg: ErrorMsg; + if (allowRetry) { + errorMsg = { + status: "Confirm Overwrite File(s)", + text: "This copy operation will overwrite an existing file. Would you like to continue?", + level: "warning", + buttons: [ + { + text: "Delete Then Copy", + onClick: async () => { + data.opts.overwrite = true; + await handleDropCopy(data, isDir); + }, + }, + { + text: "Sync", + onClick: async () => { + data.opts.merge = true; + await handleDropCopy(data, isDir); + }, + }, + ], + }; + } else { + errorMsg = { + status: "Copy Failed", + text: copyError, + level: "error", + }; + } + setErrorMsg(errorMsg); } model.refreshCallback(); }, - [setCopyStatus, model.refreshCallback] + [model.refreshCallback] ); const [, drop] = useDrop( @@ -908,7 +964,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { }, // TODO: mabe add a hover option? }), - [dirPath, model.formatRemoteUri, model.refreshCallback, setCopyStatus] + [dirPath, model.formatRemoteUri, model.refreshCallback] ); useEffect(() => { @@ -1000,13 +1056,6 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { onContextMenu={(e) => handleFileContextMenu(e)} onClick={() => setEntryManagerProps(undefined)} > - {copyStatus != null && ( - - )} void; - handleDropCopy: (data: CommandFileCopyData, isDir: boolean) => Promise; - }) => { - const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); - const width = domRect?.width; - - const handleRetryCopy = React.useCallback( - async (copyOpt?: string) => { - if (!copyStatus) { - return; - } - let overwrite = copyOpt == "overwrite"; - let merge = copyOpt == "merge"; - const updatedData = { - ...copyStatus.copyData, - opts: { ...copyStatus.copyData.opts, overwrite, merge }, - }; - await handleDropCopy(updatedData, copyStatus.isDir); - }, - [copyStatus.copyData] - ); - - let statusText = "Copy Error"; - let errorMsg = `error: ${copyStatus?.copyError}`; - if (copyStatus?.allowRetry) { - statusText = "Confirm Overwrite File(s)"; - errorMsg = "This copy operation will overwrite an existing file. Would you like to continue?"; - } - - const buttonClassName = "outlined grey font-size-11 vertical-padding-3 horizontal-padding-7"; - - const handleRemoveCopyError = React.useCallback(async () => { - setCopyStatus(null); - }, [setCopyStatus]); - - const handleCopyToClipboard = React.useCallback(async () => { - await navigator.clipboard.writeText(errorMsg); - }, [errorMsg]); - - return ( -
-
-
- - -
-
- {statusText} -
- - - -
{errorMsg}
-
- - {copyStatus?.allowRetry && ( -
- - {copyStatus.isDir && ( - - )} - -
- )} -
- - {!copyStatus?.allowRetry && ( -
-
- )} -
-
-
- ); - } -); - export { DirectoryPreview }; diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 27e09c3233..e4757cc9ab 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -1216,18 +1216,22 @@ const ErrorOverlay = memo(({ errorMsg, resetOverlay }: { errorMsg: ErrorMsg; res />
{errorMsg.text}
- {errorMsg.buttons?.map((buttonDef) => ( - - ))} + {!!errorMsg.buttons && ( +
+ {errorMsg.buttons?.map((buttonDef) => ( + + ))} +
+ )} {showDismiss && ( diff --git a/go.mod b/go.mod index b79c7fbc27..17d50db73d 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/wavetermdev/htmltoken v0.2.0 golang.org/x/crypto v0.33.0 golang.org/x/mod v0.23.0 + golang.org/x/sync v0.11.0 golang.org/x/sys v0.30.0 golang.org/x/term v0.29.0 google.golang.org/api v0.221.0 @@ -95,7 +96,6 @@ require ( go.uber.org/atomic v1.7.0 // indirect golang.org/x/net v0.35.0 // indirect golang.org/x/oauth2 v0.26.0 // indirect - golang.org/x/sync v0.11.0 // indirect golang.org/x/text v0.22.0 // indirect golang.org/x/time v0.10.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect diff --git a/pkg/remote/fileshare/fileshare.go b/pkg/remote/fileshare/fileshare.go index 9d1eed196a..a31e033b86 100644 --- a/pkg/remote/fileshare/fileshare.go +++ b/pkg/remote/fileshare/fileshare.go @@ -119,7 +119,11 @@ func Mkdir(ctx context.Context, path string) error { } func Move(ctx context.Context, data wshrpc.CommandFileCopyData) error { - log.Printf("Move: %v", data) + opts := data.Opts + if opts == nil { + opts = &wshrpc.FileCopyOpts{} + } + log.Printf("Move: srcuri: %v, desturi: %v, opts: %v", data.SrcUri, data.DestUri, opts) srcClient, srcConn := CreateFileShareClient(ctx, data.SrcUri) if srcConn == nil || srcClient == nil { return fmt.Errorf("error creating fileshare client, could not parse source connection %s", data.SrcUri) @@ -129,26 +133,23 @@ func Move(ctx context.Context, data wshrpc.CommandFileCopyData) error { return fmt.Errorf("error creating fileshare client, could not parse destination connection %s", data.DestUri) } if srcConn.Host != destConn.Host { - finfo, err := srcClient.Stat(ctx, srcConn) - if err != nil { - return fmt.Errorf("cannot stat %q: %w", data.SrcUri, err) - } - recursive := data.Opts != nil && data.Opts.Recursive - if finfo.IsDir && data.Opts != nil && !recursive { - return fmt.Errorf("cannot move directory %q to %q without recursive flag", data.SrcUri, data.DestUri) - } - err = destClient.CopyRemote(ctx, srcConn, destConn, srcClient, data.Opts) + isDir, err := destClient.CopyRemote(ctx, srcConn, destConn, srcClient, opts) if err != nil { return fmt.Errorf("cannot copy %q to %q: %w", data.SrcUri, data.DestUri, err) } - return srcClient.Delete(ctx, srcConn, recursive) + return srcClient.Delete(ctx, srcConn, opts.Recursive && isDir) } else { - return srcClient.MoveInternal(ctx, srcConn, destConn, data.Opts) + return srcClient.MoveInternal(ctx, srcConn, destConn, opts) } } func Copy(ctx context.Context, data wshrpc.CommandFileCopyData) error { - log.Printf("Copy: %v", data) + opts := data.Opts + if opts == nil { + opts = &wshrpc.FileCopyOpts{} + } + opts.Recursive = true + log.Printf("Copy: srcuri: %v, desturi: %v, opts: %v", data.SrcUri, data.DestUri, opts) srcClient, srcConn := CreateFileShareClient(ctx, data.SrcUri) if srcConn == nil || srcClient == nil { return fmt.Errorf("error creating fileshare client, could not parse source connection %s", data.SrcUri) @@ -158,9 +159,11 @@ func Copy(ctx context.Context, data wshrpc.CommandFileCopyData) error { return fmt.Errorf("error creating fileshare client, could not parse destination connection %s", data.DestUri) } if srcConn.Host != destConn.Host { - return destClient.CopyRemote(ctx, srcConn, destConn, srcClient, data.Opts) + _, err := destClient.CopyRemote(ctx, srcConn, destConn, srcClient, opts) + return err } else { - return srcClient.CopyInternal(ctx, srcConn, destConn, data.Opts) + _, err := srcClient.CopyInternal(ctx, srcConn, destConn, opts) + return err } } diff --git a/pkg/remote/fileshare/fstype/fstype.go b/pkg/remote/fileshare/fstype/fstype.go index cc67ddeab9..a99d1ccd87 100644 --- a/pkg/remote/fileshare/fstype/fstype.go +++ b/pkg/remote/fileshare/fstype/fstype.go @@ -14,9 +14,12 @@ import ( ) const ( - DefaultTimeout = 30 * time.Second - FileMode os.FileMode = 0644 - DirMode os.FileMode = 0755 | os.ModeDir + DefaultTimeout = 30 * time.Second + FileMode os.FileMode = 0644 + DirMode os.FileMode = 0755 | os.ModeDir + RecursiveRequiredError = "recursive flag must be set for directory operations" + MergeRequiredError = "directory already exists at %q, set overwrite flag to delete the existing contents or set merge flag to merge the contents" + OverwriteRequiredError = "file already exists at %q, set overwrite flag to delete the existing file" ) type FileShareClient interface { @@ -40,10 +43,10 @@ type FileShareClient interface { Mkdir(ctx context.Context, conn *connparse.Connection) error // Move moves the file within the same connection MoveInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) error - // Copy copies the file within the same connection - CopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) error - // CopyRemote copies the file between different connections - CopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient FileShareClient, opts *wshrpc.FileCopyOpts) error + // Copy copies the file within the same connection. Returns whether the copy source was a directory + CopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) (bool, error) + // CopyRemote copies the file between different connections. Returns whether the copy source was a directory + CopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient FileShareClient, opts *wshrpc.FileCopyOpts) (bool, error) // Delete deletes the entry at the given path Delete(ctx context.Context, conn *connparse.Connection, recursive bool) error // Join joins the given parts to the connection path diff --git a/pkg/remote/fileshare/fsutil/fsutil.go b/pkg/remote/fileshare/fsutil/fsutil.go index a6b6660557..617bbdc706 100644 --- a/pkg/remote/fileshare/fsutil/fsutil.go +++ b/pkg/remote/fileshare/fsutil/fsutil.go @@ -28,7 +28,7 @@ func GetParentPath(conn *connparse.Connection) string { func GetParentPathString(hostAndPath string) string { if hostAndPath == "" || hostAndPath == fspath.Separator { - return fspath.Separator + return "" } // Remove trailing slash if present @@ -38,75 +38,23 @@ func GetParentPathString(hostAndPath string) string { lastSlash := strings.LastIndex(hostAndPath, fspath.Separator) if lastSlash <= 0 { - return fspath.Separator - } - return hostAndPath[:lastSlash+1] -} - -const minURILength = 10 // Minimum length for a valid URI (e.g., "s3://bucket") - -func GetPathPrefix(conn *connparse.Connection) string { - fullUri := conn.GetFullURI() - if fullUri == "" { return "" } - pathPrefix := fullUri - lastSlash := strings.LastIndex(fullUri, fspath.Separator) - if lastSlash > minURILength && lastSlash < len(fullUri)-1 { - pathPrefix = fullUri[:lastSlash+1] - } - return pathPrefix + return hostAndPath[:lastSlash+1] } -func PrefixCopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, c fstype.FileShareClient, opts *wshrpc.FileCopyOpts, listEntriesPrefix func(ctx context.Context, host string, path string) ([]string, error), copyFunc func(ctx context.Context, host string, path string) error) error { +func PrefixCopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, c fstype.FileShareClient, opts *wshrpc.FileCopyOpts, listEntriesPrefix func(ctx context.Context, host string, path string) ([]string, error), copyFunc func(ctx context.Context, host string, path string) error) (bool, error) { log.Printf("PrefixCopyInternal: %v -> %v", srcConn.GetFullURI(), destConn.GetFullURI()) - merge := opts != nil && opts.Merge - overwrite := opts != nil && opts.Overwrite - if overwrite && merge { - return fmt.Errorf("cannot specify both overwrite and merge") - } srcHasSlash := strings.HasSuffix(srcConn.Path, fspath.Separator) - srcPath, err := CleanPathPrefix(srcConn.Path) - if err != nil { - return fmt.Errorf("error cleaning source path: %w", err) - } - destHasSlash := strings.HasSuffix(destConn.Path, fspath.Separator) - destPath, err := CleanPathPrefix(destConn.Path) - if err != nil { - return fmt.Errorf("error cleaning destination path: %w", err) - } - if !srcHasSlash { - if !destHasSlash { - destPath += fspath.Separator - } - destPath += fspath.Base(srcPath) - } - destConn.Path = destPath - destInfo, err := c.Stat(ctx, destConn) - destExists := err == nil && !destInfo.NotFound - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("error getting destination file info: %w", err) - } - - srcInfo, err := c.Stat(ctx, srcConn) + srcPath, destPath, srcInfo, err := DetermineCopyDestPath(ctx, srcConn, destConn, c, c, opts) if err != nil { - return fmt.Errorf("error getting source file info: %w", err) - } - if destExists { - if overwrite { - err = c.Delete(ctx, destConn, true) - if err != nil { - return fmt.Errorf("error deleting conflicting destination file: %w", err) - } - } else if destInfo.IsDir && srcInfo.IsDir { - if !merge { - return fmt.Errorf("destination and source are both directories, neither merge nor overwrite specified: %v", destConn.GetFullURI()) - } - } else { - return fmt.Errorf("destination already exists, overwrite not specified: %v", destConn.GetFullURI()) - } + return false, err } + recursive := opts != nil && opts.Recursive if srcInfo.IsDir { + if !recursive { + return false, fmt.Errorf(fstype.RecursiveRequiredError) + } if !srcHasSlash { srcPath += fspath.Separator } @@ -114,7 +62,7 @@ func PrefixCopyInternal(ctx context.Context, srcConn, destConn *connparse.Connec log.Printf("Copying directory: %v -> %v", srcPath, destPath) entries, err := listEntriesPrefix(ctx, srcConn.Host, srcPath) if err != nil { - return fmt.Errorf("error listing source directory: %w", err) + return false, fmt.Errorf("error listing source directory: %w", err) } tree := pathtree.NewTree(srcPath, fspath.Separator) @@ -122,14 +70,14 @@ func PrefixCopyInternal(ctx context.Context, srcConn, destConn *connparse.Connec tree.Add(entry) } - /* tree.Walk will return the full path in the source bucket for each item. + /* tree.Walk will return false, the full path in the source bucket for each item. prefixToRemove specifies how much of that path we want in the destination subtree. If the source path has a trailing slash, we don't want to include the source directory itself in the destination subtree.*/ prefixToRemove := srcPath if !srcHasSlash { prefixToRemove = fspath.Dir(srcPath) + fspath.Separator } - return tree.Walk(func(path string, numChildren int) error { + return true, tree.Walk(func(path string, numChildren int) error { // since this is a prefix filesystem, we only care about leafs if numChildren > 0 { return nil @@ -138,58 +86,23 @@ func PrefixCopyInternal(ctx context.Context, srcConn, destConn *connparse.Connec return copyFunc(ctx, path, destFilePath) }) } else { - return copyFunc(ctx, srcPath, destPath) + return false, copyFunc(ctx, srcPath, destPath) } } -func PrefixCopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient, destClient fstype.FileShareClient, destPutFile func(host string, path string, size int64, reader io.Reader) error, opts *wshrpc.FileCopyOpts) error { - merge := opts != nil && opts.Merge - overwrite := opts != nil && opts.Overwrite - if overwrite && merge { - return fmt.Errorf("cannot specify both overwrite and merge") - } - srcHasSlash := strings.HasSuffix(srcConn.Path, fspath.Separator) - destHasSlash := strings.HasSuffix(destConn.Path, fspath.Separator) - destPath, err := CleanPathPrefix(destConn.Path) +func PrefixCopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient, destClient fstype.FileShareClient, destPutFile func(host string, path string, size int64, reader io.Reader) error, opts *wshrpc.FileCopyOpts) (bool, error) { + // prefix to be used if the destination is a directory. The destPath returned in the following call only applies if the destination is not a directory. + destPathPrefix, err := CleanPathPrefix(destConn.Path) if err != nil { - return fmt.Errorf("error cleaning destination path: %w", err) - } - if !srcHasSlash { - if !destHasSlash { - destPath += fspath.Separator - } - destPath += fspath.Base(srcConn.Path) - } - destConn.Path = destPath - destInfo, err := destClient.Stat(ctx, destConn) - destExists := err == nil && !destInfo.NotFound - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("error getting destination file info: %w", err) + return false, fmt.Errorf("error cleaning destination path: %w", err) } + destPathPrefix += fspath.Separator - srcInfo, err := srcClient.Stat(ctx, srcConn) + _, destPath, srcInfo, err := DetermineCopyDestPath(ctx, srcConn, destConn, srcClient, destClient, opts) if err != nil { - return fmt.Errorf("error getting source file info: %w", err) - } - if destExists { - if overwrite { - err = destClient.Delete(ctx, destConn, true) - if err != nil { - return fmt.Errorf("error deleting conflicting destination file: %w", err) - } - } else if destInfo.IsDir && srcInfo.IsDir { - if !merge { - return fmt.Errorf("destination and source are both directories, neither merge nor overwrite specified: %v", destConn.GetFullURI()) - } - } else { - return fmt.Errorf("destination already exists, overwrite not specified: %v", destConn.GetFullURI()) - } - } - if err != nil { - if !errors.Is(err, fs.ErrNotExist) { - return err - } + return false, err } + log.Printf("Copying: %v -> %v", srcConn.GetFullURI(), destConn.GetFullURI()) readCtx, cancel := context.WithCancelCause(ctx) defer cancel(nil) @@ -201,9 +114,9 @@ func PrefixCopyRemote(ctx context.Context, srcConn, destConn *connparse.Connecti if singleFile && srcInfo.IsDir { return fmt.Errorf("protocol error: source is a directory, but only a single file is being copied") } - fileName, err := CleanPathPrefix(fspath.Join(destPath, next.Name)) - if singleFile && !destHasSlash { - fileName, err = CleanPathPrefix(destConn.Path) + fileName, err := CleanPathPrefix(fspath.Join(destPathPrefix, next.Name)) + if singleFile { + fileName = destPath } if err != nil { return fmt.Errorf("error cleaning path: %w", err) @@ -213,15 +126,72 @@ func PrefixCopyRemote(ctx context.Context, srcConn, destConn *connparse.Connecti }) if err != nil { cancel(err) - return err + return false, err + } + return srcInfo.IsDir, nil +} + +func DetermineCopyDestPath(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient, destClient fstype.FileShareClient, opts *wshrpc.FileCopyOpts) (srcPath, destPath string, srcInfo *wshrpc.FileInfo, err error) { + merge := opts != nil && opts.Merge + overwrite := opts != nil && opts.Overwrite + recursive := opts != nil && opts.Recursive + if overwrite && merge { + return "", "", nil, fmt.Errorf("cannot specify both overwrite and merge") + } + + srcHasSlash := strings.HasSuffix(srcConn.Path, fspath.Separator) + srcPath = srcConn.Path + destHasSlash := strings.HasSuffix(destConn.Path, fspath.Separator) + destPath, err = CleanPathPrefix(destConn.Path) + if err != nil { + return "", "", nil, fmt.Errorf("error cleaning destination path: %w", err) + } + + srcInfo, err = srcClient.Stat(ctx, srcConn) + if err != nil { + return "", "", nil, fmt.Errorf("error getting source file info: %w", err) + } + destInfo, err := destClient.Stat(ctx, destConn) + destExists := err == nil && !destInfo.NotFound + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return "", "", nil, fmt.Errorf("error getting destination file info: %w", err) + } + originalDestPath := destPath + if !srcHasSlash { + if destInfo.IsDir || (!destExists && !destHasSlash && srcInfo.IsDir) { + destPath = fspath.Join(destPath, fspath.Base(srcConn.Path)) + } + } + destConn.Path = destPath + if originalDestPath != destPath { + destInfo, err = destClient.Stat(ctx, destConn) + destExists = err == nil && !destInfo.NotFound + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return "", "", nil, fmt.Errorf("error getting destination file info: %w", err) + } + } + if destExists { + if overwrite { + log.Printf("Deleting existing file: %s\n", destConn.GetFullURI()) + err = destClient.Delete(ctx, destConn, destInfo.IsDir && recursive) + if err != nil { + return "", "", nil, fmt.Errorf("error deleting conflicting destination file: %w", err) + } + } else if destInfo.IsDir && srcInfo.IsDir { + if !merge { + return "", "", nil, fmt.Errorf(fstype.MergeRequiredError, destConn.GetFullURI()) + } + } else { + return "", "", nil, fmt.Errorf(fstype.OverwriteRequiredError, destConn.GetFullURI()) + } } - return nil + return srcPath, destPath, srcInfo, nil } // CleanPathPrefix corrects paths for prefix filesystems (i.e. ones that don't have directories) func CleanPathPrefix(path string) (string, error) { if path == "" { - return "", fmt.Errorf("path is empty") + return "", nil } if strings.HasPrefix(path, fspath.Separator) { path = path[1:] diff --git a/pkg/remote/fileshare/pathtree/pathtree.go b/pkg/remote/fileshare/pathtree/pathtree.go index 5d4918fbae..3fa9735edc 100644 --- a/pkg/remote/fileshare/pathtree/pathtree.go +++ b/pkg/remote/fileshare/pathtree/pathtree.go @@ -32,7 +32,7 @@ func (n *Node) Walk(curPath string, walkFunc WalkFunc, delimiter string) error { func NewTree(path string, delimiter string) *Tree { if len(delimiter) > 1 { - log.Printf("Warning: multi-character delimiter '%s' may cause unexpected behavior", delimiter) + log.Printf("pathtree.NewTree: Warning: multi-character delimiter '%s' may cause unexpected behavior", delimiter) } if path != "" && !strings.HasSuffix(path, delimiter) { path += delimiter @@ -48,7 +48,6 @@ func NewTree(path string, delimiter string) *Tree { } func (t *Tree) Add(path string) { - log.Printf("tree.Add: path: %s", path) // Validate input if path == "" { return @@ -75,6 +74,7 @@ func (t *Tree) Add(path string) { // Validate path components for _, component := range components { if component == "" || component == "." || component == ".." { + log.Printf("pathtree.Add: invalid path component: %s", component) return // Skip invalid paths } } @@ -118,7 +118,6 @@ func (t *Tree) addNewPath(components []string) { } func (t *Tree) Walk(walkFunc WalkFunc) error { - log.Printf("RootPath: %s", t.RootPath) for key, child := range t.Root.Children { if err := child.Walk(t.RootPath+key, walkFunc, t.delimiter); err != nil { return err diff --git a/pkg/remote/fileshare/s3fs/s3fs.go b/pkg/remote/fileshare/s3fs/s3fs.go index aef9404196..f6d24ef81a 100644 --- a/pkg/remote/fileshare/s3fs/s3fs.go +++ b/pkg/remote/fileshare/s3fs/s3fs.go @@ -150,6 +150,7 @@ func (c S3Client) ReadStream(ctx context.Context, conn *connparse.Connection, da } func (c S3Client) ReadTarStream(ctx context.Context, conn *connparse.Connection, opts *wshrpc.FileCopyOpts) <-chan wshrpc.RespOrErrorUnion[iochantypes.Packet] { + recursive := opts != nil && opts.Recursive bucket := conn.Host if bucket == "" || bucket == "/" { return wshutil.SendErrCh[iochantypes.Packet](fmt.Errorf("bucket must be specified")) @@ -187,6 +188,10 @@ func (c S3Client) ReadTarStream(ctx context.Context, conn *connparse.Connection, // whether the operation is on a single file singleFile := singleFileResult != nil + if !singleFile && !recursive { + return wshutil.SendErrCh[iochantypes.Packet](fmt.Errorf(fstype.RecursiveRequiredError)) + } + // whether to include the directory itself in the tar includeDir := (wholeBucket && conn.Path == "") || (singleFileResult == nil && conn.Path != "" && !strings.HasSuffix(conn.Path, fspath.Separator)) @@ -223,7 +228,6 @@ func (c S3Client) ReadTarStream(ctx context.Context, conn *connparse.Connection, // close the objects when we're done defer func() { for key, obj := range objMap { - log.Printf("closing object %v", key) utilfn.GracefulClose(obj.Body, "s3fs", key) } }() @@ -598,13 +602,11 @@ func (c S3Client) Stat(ctx context.Context, conn *connparse.Connection) (*wshrpc func (c S3Client) PutFile(ctx context.Context, conn *connparse.Connection, data wshrpc.FileData) error { if data.At != nil { - log.Printf("PutFile: offset %d and size %d", data.At.Offset, data.At.Size) return errors.Join(errors.ErrUnsupported, fmt.Errorf("file data offset and size not supported")) } bucket := conn.Host objectKey := conn.Path if bucket == "" || bucket == "/" || objectKey == "" || objectKey == "/" { - log.Printf("PutFile: bucket and object key must be specified") return errors.Join(errors.ErrUnsupported, fmt.Errorf("bucket and object key must be specified")) } contentMaxLength := base64.StdEncoding.DecodedLen(len(data.Data64)) @@ -615,7 +617,6 @@ func (c S3Client) PutFile(ctx context.Context, conn *connparse.Connection, data decodedBody = make([]byte, contentMaxLength) contentLength, err = base64.StdEncoding.Decode(decodedBody, []byte(data.Data64)) if err != nil { - log.Printf("PutFile: error decoding data: %v", err) return err } } else { @@ -644,20 +645,21 @@ func (c S3Client) Mkdir(ctx context.Context, conn *connparse.Connection) error { } func (c S3Client) MoveInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) error { - err := c.CopyInternal(ctx, srcConn, destConn, opts) + isDir, err := c.CopyInternal(ctx, srcConn, destConn, opts) if err != nil { return err } - return c.Delete(ctx, srcConn, true) + recursive := opts != nil && opts.Recursive + return c.Delete(ctx, srcConn, recursive && isDir) } -func (c S3Client) CopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient fstype.FileShareClient, opts *wshrpc.FileCopyOpts) error { +func (c S3Client) CopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient fstype.FileShareClient, opts *wshrpc.FileCopyOpts) (bool, error) { if srcConn.Scheme == connparse.ConnectionTypeS3 && destConn.Scheme == connparse.ConnectionTypeS3 { return c.CopyInternal(ctx, srcConn, destConn, opts) } destBucket := destConn.Host if destBucket == "" || destBucket == fspath.Separator { - return fmt.Errorf("destination bucket must be specified") + return false, fmt.Errorf("destination bucket must be specified") } return fsutil.PrefixCopyRemote(ctx, srcConn, destConn, srcClient, c, func(bucket, path string, size int64, reader io.Reader) error { _, err := c.client.PutObject(ctx, &s3.PutObjectInput{ @@ -670,11 +672,11 @@ func (c S3Client) CopyRemote(ctx context.Context, srcConn, destConn *connparse.C }, opts) } -func (c S3Client) CopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) error { +func (c S3Client) CopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) (bool, error) { srcBucket := srcConn.Host destBucket := destConn.Host if srcBucket == "" || srcBucket == fspath.Separator || destBucket == "" || destBucket == fspath.Separator { - return fmt.Errorf("source and destination bucket must be specified") + return false, fmt.Errorf("source and destination bucket must be specified") } return fsutil.PrefixCopyInternal(ctx, srcConn, destConn, c, opts, func(ctx context.Context, bucket, prefix string) ([]string, error) { var entries []string @@ -687,39 +689,16 @@ func (c S3Client) CopyInternal(ctx context.Context, srcConn, destConn *connparse }) return entries, err }, func(ctx context.Context, srcPath, destPath string) error { - log.Printf("Copying file %v -> %v", srcBucket+"/"+srcPath, destBucket+"/"+destPath) _, err := c.client.CopyObject(ctx, &s3.CopyObjectInput{ Bucket: aws.String(destBucket), Key: aws.String(destPath), CopySource: aws.String(fspath.Join(srcBucket, srcPath)), }) - return err - }) -} - -func (c S3Client) listFilesPrefix(ctx context.Context, input *s3.ListObjectsV2Input, fileCallback func(*types.Object) (bool, error)) error { - var err error - var output *s3.ListObjectsV2Output - objectPaginator := s3.NewListObjectsV2Paginator(c.client, input) - for objectPaginator.HasMorePages() { - output, err = objectPaginator.NextPage(ctx) if err != nil { - var noBucket *types.NoSuchBucket - if !awsconn.CheckAccessDeniedErr(&err) && errors.As(err, &noBucket) { - err = noBucket - } - return err - } else { - for _, obj := range output.Contents { - if cont, err := fileCallback(&obj); err != nil { - return err - } else if !cont { - return nil - } - } + return fmt.Errorf("error copying %v:%v to %v:%v: %w", srcBucket, srcPath, destBucket, destPath, err) } - } - return nil + return nil + }) } func (c S3Client) Delete(ctx context.Context, conn *connparse.Connection, recursive bool) error { @@ -731,37 +710,80 @@ func (c S3Client) Delete(ctx context.Context, conn *connparse.Connection, recurs if objectKey == "" || objectKey == fspath.Separator { return errors.Join(errors.ErrUnsupported, fmt.Errorf("object key must be specified")) } + var err error if recursive { + log.Printf("Deleting objects with prefix %v:%v", bucket, objectKey) if !strings.HasSuffix(objectKey, fspath.Separator) { objectKey = objectKey + fspath.Separator } - entries, err := c.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + objects := make([]types.ObjectIdentifier, 0) + err = c.listFilesPrefix(ctx, &s3.ListObjectsV2Input{ Bucket: aws.String(bucket), Prefix: aws.String(objectKey), + }, func(obj *types.Object) (bool, error) { + objects = append(objects, types.ObjectIdentifier{Key: obj.Key}) + return true, nil }) if err != nil { return err } - if len(entries.Contents) == 0 { + if len(objects) == 0 { return nil } - objects := make([]types.ObjectIdentifier, 0, len(entries.Contents)) - for _, obj := range entries.Contents { - objects = append(objects, types.ObjectIdentifier{Key: obj.Key}) - } _, err = c.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ Bucket: aws.String(bucket), Delete: &types.Delete{ Objects: objects, }, }) + } else { + log.Printf("Deleting object %v:%v", bucket, objectKey) + _, err = c.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(objectKey), + }) + } + if err != nil { return err } - _, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(objectKey), - }) - return err + + // verify the object was deleted + finfo, err := c.Stat(ctx, conn) + if err != nil { + return err + } + if !finfo.NotFound { + if finfo.IsDir { + return fmt.Errorf(fstype.RecursiveRequiredError) + } + return fmt.Errorf("object was not successfully deleted %v:%v", bucket, objectKey) + } + return nil +} + +func (c S3Client) listFilesPrefix(ctx context.Context, input *s3.ListObjectsV2Input, fileCallback func(*types.Object) (bool, error)) error { + var err error + var output *s3.ListObjectsV2Output + objectPaginator := s3.NewListObjectsV2Paginator(c.client, input) + for objectPaginator.HasMorePages() { + output, err = objectPaginator.NextPage(ctx) + if err != nil { + var noBucket *types.NoSuchBucket + if !awsconn.CheckAccessDeniedErr(&err) && errors.As(err, &noBucket) { + err = noBucket + } + return err + } else { + for _, obj := range output.Contents { + if cont, err := fileCallback(&obj); err != nil { + return err + } else if !cont { + return nil + } + } + } + } + return nil } func (c S3Client) Join(ctx context.Context, conn *connparse.Connection, parts ...string) (*wshrpc.FileInfo, error) { diff --git a/pkg/remote/fileshare/wavefs/wavefs.go b/pkg/remote/fileshare/wavefs/wavefs.go index b30c4bad39..cbd672a1d9 100644 --- a/pkg/remote/fileshare/wavefs/wavefs.go +++ b/pkg/remote/fileshare/wavefs/wavefs.go @@ -422,16 +422,18 @@ func (c WaveClient) MoveInternal(ctx context.Context, srcConn, destConn *connpar if srcConn.Host != destConn.Host { return fmt.Errorf("move internal, src and dest hosts do not match") } - if err := c.CopyInternal(ctx, srcConn, destConn, opts); err != nil { + isDir, err := c.CopyInternal(ctx, srcConn, destConn, opts) + if err != nil { return fmt.Errorf("error copying blockfile: %w", err) } - if err := c.Delete(ctx, srcConn, opts.Recursive); err != nil { + recursive := opts != nil && opts.Recursive && isDir + if err := c.Delete(ctx, srcConn, recursive); err != nil { return fmt.Errorf("error deleting blockfile: %w", err) } return nil } -func (c WaveClient) CopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) error { +func (c WaveClient) CopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) (bool, error) { return fsutil.PrefixCopyInternal(ctx, srcConn, destConn, c, opts, func(ctx context.Context, zoneId, prefix string) ([]string, error) { entryList := make([]string, 0) if err := listFilesPrefix(ctx, zoneId, prefix, func(wf *filestore.WaveFile) error { @@ -466,13 +468,13 @@ func (c WaveClient) CopyInternal(ctx context.Context, srcConn, destConn *connpar }) } -func (c WaveClient) CopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient fstype.FileShareClient, opts *wshrpc.FileCopyOpts) error { +func (c WaveClient) CopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, srcClient fstype.FileShareClient, opts *wshrpc.FileCopyOpts) (bool, error) { if srcConn.Scheme == connparse.ConnectionTypeWave && destConn.Scheme == connparse.ConnectionTypeWave { return c.CopyInternal(ctx, srcConn, destConn, opts) } zoneId := destConn.Host if zoneId == "" { - return fmt.Errorf("zoneid not found in connection") + return false, fmt.Errorf("zoneid not found in connection") } return fsutil.PrefixCopyRemote(ctx, srcConn, destConn, srcClient, c, func(zoneId, path string, size int64, reader io.Reader) error { dataBuf := make([]byte, size) diff --git a/pkg/remote/fileshare/wshfs/wshfs.go b/pkg/remote/fileshare/wshfs/wshfs.go index ae0930e864..41dfdd15f5 100644 --- a/pkg/remote/fileshare/wshfs/wshfs.go +++ b/pkg/remote/fileshare/wshfs/wshfs.go @@ -114,11 +114,11 @@ func (c WshClient) MoveInternal(ctx context.Context, srcConn, destConn *connpars return wshclient.RemoteFileMoveCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcConn.GetFullURI(), DestUri: destConn.GetFullURI(), Opts: opts}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(destConn.Host), Timeout: timeout}) } -func (c WshClient) CopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, _ fstype.FileShareClient, opts *wshrpc.FileCopyOpts) error { +func (c WshClient) CopyRemote(ctx context.Context, srcConn, destConn *connparse.Connection, _ fstype.FileShareClient, opts *wshrpc.FileCopyOpts) (bool, error) { return c.CopyInternal(ctx, srcConn, destConn, opts) } -func (c WshClient) CopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) error { +func (c WshClient) CopyInternal(ctx context.Context, srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopyOpts) (bool, error) { if opts == nil { opts = &wshrpc.FileCopyOpts{} } diff --git a/pkg/util/tarcopy/tarcopy.go b/pkg/util/tarcopy/tarcopy.go index d8888719de..c2858c7917 100644 --- a/pkg/util/tarcopy/tarcopy.go +++ b/pkg/util/tarcopy/tarcopy.go @@ -73,8 +73,6 @@ func TarCopySrc(ctx context.Context, pathPrefix string) (outputChan chan wshrpc. } header.Name = path - log.Printf("TarCopySrc: header name: %v\n", header.Name) - // write header if err := tarWriter.WriteHeader(header); err != nil { return err diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 1ded90d83c..243065b9cc 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -356,9 +356,9 @@ func RecordTEventCommand(w *wshutil.WshRpc, data telemetrydata.TEvent, opts *wsh } // command "remotefilecopy", wshserver.RemoteFileCopyCommand -func RemoteFileCopyCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) error { - _, err := sendRpcRequestCallHelper[any](w, "remotefilecopy", data, opts) - return err +func RemoteFileCopyCommand(w *wshutil.WshRpc, data wshrpc.CommandFileCopyData, opts *wshrpc.RpcOpts) (bool, error) { + resp, err := sendRpcRequestCallHelper[bool](w, "remotefilecopy", data, opts) + return resp, err } // command "remotefiledelete", wshserver.RemoteFileDeleteCommand diff --git a/pkg/wshrpc/wshremote/wshremote.go b/pkg/wshrpc/wshremote/wshremote.go index da05953fce..afa269fc97 100644 --- a/pkg/wshrpc/wshremote/wshremote.go +++ b/pkg/wshrpc/wshremote/wshremote.go @@ -306,7 +306,7 @@ func (impl *ServerImpl) RemoteTarStreamCommand(ctx context.Context, data wshrpc. return rtn } -func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.CommandFileCopyData) error { +func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.CommandFileCopyData) (bool, error) { log.Printf("RemoteFileCopyCommand: src=%s, dest=%s\n", data.SrcUri, data.DestUri) opts := data.Opts if opts == nil { @@ -316,16 +316,19 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C srcUri := data.SrcUri merge := opts.Merge overwrite := opts.Overwrite + if overwrite && merge { + return false, fmt.Errorf("cannot specify both overwrite and merge") + } destConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, destUri) if err != nil { - return fmt.Errorf("cannot parse destination URI %q: %w", srcUri, err) + return false, fmt.Errorf("cannot parse destination URI %q: %w", destUri, err) } destPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(destConn.Path)) destinfo, err := os.Stat(destPathCleaned) if err != nil { if !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("cannot stat destination %q: %w", destPathCleaned, err) + return false, fmt.Errorf("cannot stat destination %q: %w", destPathCleaned, err) } } @@ -335,17 +338,17 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C if destExists && !destIsDir { if !overwrite { - return fmt.Errorf("file already exists at destination %q, use overwrite option", destPathCleaned) + return false, fmt.Errorf(fstype.OverwriteRequiredError, destPathCleaned) } else { err := os.Remove(destPathCleaned) if err != nil { - return fmt.Errorf("cannot remove file %q: %w", destPathCleaned, err) + return false, fmt.Errorf("cannot remove file %q: %w", destPathCleaned, err) } } } srcConn, err := connparse.ParseURIAndReplaceCurrentHost(ctx, srcUri) if err != nil { - return fmt.Errorf("cannot parse source URI %q: %w", srcUri, err) + return false, fmt.Errorf("cannot parse source URI %q: %w", srcUri, err) } copyFileFunc := func(path string, finfo fs.FileInfo, srcFile io.Reader) (int64, error) { @@ -364,34 +367,29 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C return 0, fmt.Errorf("cannot stat file %q: %w", path, err) } if newdestinfo != nil && !overwrite { - return 0, fmt.Errorf("cannot create file %q, file exists at path, overwrite not specified", path) + return 0, fmt.Errorf(fstype.OverwriteRequiredError, path) } - } else if !merge && !overwrite { - return 0, fmt.Errorf("cannot create directory %q, directory exists at path, neither overwrite nor merge specified", path) } else if overwrite { err := os.RemoveAll(path) if err != nil { return 0, fmt.Errorf("cannot remove directory %q: %w", path, err) } + } else if !merge { + return 0, fmt.Errorf(fstype.MergeRequiredError, path) } } else { - if finfo.IsDir() { - if !overwrite { - return 0, fmt.Errorf("cannot create file %q, directory exists at path, overwrite not specified", path) - } else { - err := os.RemoveAll(path) - if err != nil { - return 0, fmt.Errorf("cannot remove directory %q: %w", path, err) - } + if !overwrite { + return 0, fmt.Errorf(fstype.OverwriteRequiredError, path) + } else if finfo.IsDir() { + err := os.RemoveAll(path) + if err != nil { + return 0, fmt.Errorf("cannot remove directory %q: %w", path, err) } - } else if !overwrite { - return 0, fmt.Errorf("cannot create file %q, file exists at path, overwrite not specified", path) } } } if finfo.IsDir() { - log.Printf("RemoteFileCopyCommand: making dirs %s\n", path) err := os.MkdirAll(path, finfo.Mode()) if err != nil { return 0, fmt.Errorf("cannot create directory %q: %w", path, err) @@ -417,15 +415,17 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C return finfo.Size(), nil } + srcIsDir := false if srcConn.Host == destConn.Host { srcPathCleaned := filepath.Clean(wavebase.ExpandHomeDirSafe(srcConn.Path)) srcFileStat, err := os.Stat(srcPathCleaned) if err != nil { - return fmt.Errorf("cannot stat file %q: %w", srcPathCleaned, err) + return false, fmt.Errorf("cannot stat file %q: %w", srcPathCleaned, err) } if srcFileStat.IsDir() { + srcIsDir = true var srcPathPrefix string if destIsDir { srcPathPrefix = filepath.Dir(srcPathCleaned) @@ -450,12 +450,12 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C return err }) if err != nil { - return fmt.Errorf("cannot copy %q to %q: %w", srcUri, destUri, err) + return false, fmt.Errorf("cannot copy %q to %q: %w", srcUri, destUri, err) } } else { file, err := os.Open(srcPathCleaned) if err != nil { - return fmt.Errorf("cannot open file %q: %w", srcPathCleaned, err) + return false, fmt.Errorf("cannot open file %q: %w", srcPathCleaned, err) } defer utilfn.GracefulClose(file, "RemoteFileCopyCommand", srcPathCleaned) var destFilePath string @@ -466,7 +466,7 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C } _, err = copyFileFunc(destFilePath, srcFileStat, file) if err != nil { - return fmt.Errorf("cannot copy %q to %q: %w", srcUri, destUri, err) + return false, fmt.Errorf("cannot copy %q to %q: %w", srcUri, destUri, err) } } } else { @@ -486,7 +486,7 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C err := tarcopy.TarCopyDest(readCtx, cancel, ioch, func(next *tar.Header, reader *tar.Reader, singleFile bool) error { numFiles++ nextpath := filepath.Join(destPathCleaned, next.Name) - log.Printf("RemoteFileCopyCommand: copying %q to %q\n", next.Name, nextpath) + srcIsDir = !singleFile if singleFile && !destHasSlash { // custom flag to indicate that the source is a single file, not a directory the contents of a directory nextpath = destPathCleaned @@ -500,7 +500,7 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C return nil }) if err != nil { - return fmt.Errorf("cannot copy %q to %q: %w", srcUri, destUri, err) + return false, fmt.Errorf("cannot copy %q to %q: %w", srcUri, destUri, err) } totalTime := time.Since(copyStart).Seconds() totalMegaBytes := float64(totalBytes) / 1024 / 1024 @@ -510,7 +510,7 @@ func (impl *ServerImpl) RemoteFileCopyCommand(ctx context.Context, data wshrpc.C } log.Printf("RemoteFileCopyCommand: done; %d files copied in %.3fs, total of %.4f MB, %.2f MB/s, %d files skipped\n", numFiles, totalTime, totalMegaBytes, rate, numSkipped) } - return nil + return srcIsDir, nil } func (impl *ServerImpl) RemoteListEntriesCommand(ctx context.Context, data wshrpc.CommandRemoteListEntriesData) chan wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData] { @@ -736,7 +736,7 @@ func (impl *ServerImpl) RemoteFileMoveCommand(ctx context.Context, data wshrpc.C return fmt.Errorf("cannot stat file %q: %w", srcPathCleaned, err) } if finfo.IsDir() && !recursive { - return fmt.Errorf("cannot move directory %q, recursive option not specified", srcUri) + return fmt.Errorf(fstype.RecursiveRequiredError) } err = os.Rename(srcPathCleaned, destPathCleaned) if err != nil { @@ -839,7 +839,7 @@ func (*ServerImpl) RemoteFileDeleteCommand(ctx context.Context, data wshrpc.Comm finfo, _ := os.Stat(cleanedPath) if finfo != nil && finfo.IsDir() { if !data.Recursive { - return fmt.Errorf("cannot delete directory %q, recursive option not specified", data.Path) + return fmt.Errorf(fstype.RecursiveRequiredError) } err = os.RemoveAll(cleanedPath) if err != nil { diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index cc4ef1e14d..3d629533cf 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -228,7 +228,7 @@ type WshRpcInterface interface { // remotes RemoteStreamFileCommand(ctx context.Context, data CommandRemoteStreamFileData) chan RespOrErrorUnion[FileData] RemoteTarStreamCommand(ctx context.Context, data CommandRemoteStreamTarData) <-chan RespOrErrorUnion[iochantypes.Packet] - RemoteFileCopyCommand(ctx context.Context, data CommandFileCopyData) error + RemoteFileCopyCommand(ctx context.Context, data CommandFileCopyData) (bool, error) RemoteListEntriesCommand(ctx context.Context, data CommandRemoteListEntriesData) chan RespOrErrorUnion[CommandRemoteListEntriesRtnData] RemoteFileInfoCommand(ctx context.Context, path string) (*FileInfo, error) RemoteFileTouchCommand(ctx context.Context, path string) error @@ -547,7 +547,7 @@ type CommandRemoteStreamTarData struct { type FileCopyOpts struct { Overwrite bool `json:"overwrite,omitempty"` Recursive bool `json:"recursive,omitempty"` // only used for move, always true for copy - Merge bool `json:"merge,omitempty"` // only used for copy, always false for move + Merge bool `json:"merge,omitempty"` Timeout int64 `json:"timeout,omitempty"` }