Compare commits

...
8 changed files with 365 additions and 12 deletions

No files matched your search

@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveDownloadSourceSnapshot } from "./downloadSourceSnapshot.ts";
const stat = (overrides: Partial<SftpStatResult> = {}): SftpStatResult => ({
name: "f",
size: 50_000,
type: "file",
lastModified: 0,
sizeKnown: true,
...overrides,
});
for (const listedSize of [25_000, 100_000]) {
test(`recreated file uses the current 50k size rather than listed ${listedSize}`, async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async (_id, path, encoding) => {
assert.equal(path, "/tmp/f");
assert.equal(encoding, "auto");
return stat();
},
"sftp-1", "/tmp/f", "auto",
);
assert.deepEqual(snapshot, { size: 50_000, isDirectory: false });
assert.notEqual(snapshot.size, listedSize);
});
}
test("a stale file entry replaced by a directory uses directory routing", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 0, type: "directory" }),
"sftp-1", "/tmp/x", "auto",
);
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
});
test("a stale directory entry replaced by a file uses file routing", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 12, type: "file" }),
"sftp-1", "/tmp/x", "auto",
);
assert.deepEqual(snapshot, { size: 12, isDirectory: false });
});
test("missing, failed and unsupported stats reject instead of using a stale listing", async () => {
const rejected = [
undefined,
async () => null as unknown as SftpStatResult,
async () => { throw new Error("File not found"); },
async () => stat({ type: "symlink" }),
async () => stat({ size: Number.NaN }),
async () => stat({ size: -1 }),
];
for (const candidate of rejected) {
await assert.rejects(
resolveDownloadSourceSnapshot(candidate, "sftp-1", "/tmp/f", "auto"),
/Cannot verify the current remote source before download/,
);
}
});
test("stat-less SCP file leaves sizing to the transfer instead of using listed bytes", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 0, sizeKnown: false }),
"scp-1", "/tmp/f", "auto",
);
assert.deepEqual(snapshot, { size: undefined, isDirectory: false });
});
test("SCP links retain their resolved file or directory routing without using link size", async () => {
for (const target of ["file", "directory"] as const) {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ type: "symlink", size: 8 }),
"scp-1", "/tmp/link", "auto",
async (_id, parent, encoding) => {
assert.equal(parent, "/tmp");
assert.equal(encoding, "auto");
return [{ name: "link", type: "symlink", linkTarget: target, size: "8 bytes", lastModified: "" }];
},
);
assert.deepEqual(snapshot, { size: undefined, isDirectory: target === "directory" });
}
});
test("SCP link whose current target cannot be confirmed stops safely", async () => {
for (const entries of [
[],
[{ name: "link", type: "symlink" as const, linkTarget: null, size: "8 bytes", lastModified: "" }],
[{ name: "link", type: "file" as const, size: "8 bytes", lastModified: "" }],
]) {
await assert.rejects(
resolveDownloadSourceSnapshot(
async () => stat({ type: "symlink" }),
"scp-1", "/tmp/link", "auto", async () => entries,
),
/Cannot verify the current remote source before download/,
);
}
});
test("directory routing does not require a file byte count", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: Number.NaN, sizeKnown: false, type: "directory" }),
"sftp-1", "/tmp/d", "auto",
);
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
});
@@ -0,0 +1,67 @@
import type { SftpFilenameEncoding } from "../../../domain/models/sftp";
import type { RemoteFile } from "../../../domain/models/workspace";
import { getFileName, getParentPath } from "./utils";
export type DownloadSourceSnapshot = {
/** Unknown for stat-less SCP files and links; the transfer reads its wire size. */
size: number | undefined;
isDirectory: boolean;
};
type StatSftp = (
sftpId: string,
path: string,
encoding?: SftpFilenameEncoding,
) => Promise<SftpStatResult>;
type ListSftp = (
sftpId: string,
path: string,
encoding?: SftpFilenameEncoding,
) => Promise<RemoteFile[]>;
const sourceUnavailable = () => new Error("Cannot verify the current remote source before download");
/** A stale listing must never supply the size or type of a new download. */
export const resolveDownloadSourceSnapshot = async (
statSftp: StatSftp | undefined,
sftpId: string,
sourcePath: string,
encoding: SftpFilenameEncoding | undefined,
listSftp?: ListSftp,
): Promise<DownloadSourceSnapshot> => {
if (!statSftp) throw sourceUnavailable();
let stat: SftpStatResult | null;
try {
stat = await statSftp(sftpId, sourcePath, encoding);
} catch (cause) {
throw new Error("Cannot verify the current remote source before download", { cause });
}
if (!stat) {
throw sourceUnavailable();
}
if (stat.type === "directory") return { size: 0, isDirectory: true };
if (stat.type === "symlink") {
// SFTP STAT follows links; legacy SCP describes the link node. Re-list its
// parent to resolve the current target kind rather than trusting the pane.
if (!listSftp) throw sourceUnavailable();
let entries: RemoteFile[];
try {
entries = await listSftp(sftpId, getParentPath(sourcePath), encoding);
} catch (cause) {
throw new Error("Cannot verify the current remote source before download", { cause });
}
const current = entries.find((entry) => entry.name === getFileName(sourcePath));
if (current?.type !== "symlink"
|| (current.linkTarget !== "file" && current.linkTarget !== "directory")) {
throw sourceUnavailable();
}
// The link's own size is never a valid file plan; SCP's wire header is.
return { size: undefined, isDirectory: current.linkTarget === "directory" };
}
if (stat.type !== "file") throw sourceUnavailable();
if (stat.sizeKnown === false) return { size: undefined, isDirectory: false };
if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw sourceUnavailable();
return { size: stat.size, isDirectory: false };
};
+5
View File
@@ -347,6 +347,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
listLocalDir,
listDrives,
openPath,
statSftp,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -1337,6 +1338,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
selectDirectory={selectDirectory}
listLocalDir={listLocalDir}
listDrives={listDrives}
statSftp={statSftp}
openPath={openPath}
t={t}
/>
@@ -1389,6 +1391,7 @@ type SftpSidePanelInteractiveBodyProps = {
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
statSftp: ReturnType<typeof useSftpBackend>["statSftp"];
openPath: ReturnType<typeof useSftpBackend>["openPath"];
t: ReturnType<typeof useI18n>["t"];
};
@@ -1431,6 +1434,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
selectDirectory,
listLocalDir,
listDrives,
statSftp,
openPath,
t,
}) => {
@@ -1540,6 +1544,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
showSaveDialog,
selectDirectory,
getSftpIdForConnection: sftp.getSftpIdForConnection,
statSftp,
listLocalFiles: listLocalDir,
listDrives,
});
+2
View File
@@ -193,6 +193,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
listLocalDir,
listDrives,
openPath,
statSftp,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -424,6 +425,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
showSaveDialog,
selectDirectory,
getSftpIdForConnection: sftp.getSftpIdForConnection,
statSftp,
listLocalFiles: listLocalDir,
listDrives,
});
+50 -11
View File
@@ -11,6 +11,7 @@ import { toast } from "../../ui/toast";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../utils";
import { resolveDownloadSourceSnapshot } from "../../../application/state/sftp/downloadSourceSnapshot";
import { reportSftpUploadResults } from "../reportSftpUploadResults";
import { editorTabStore } from "../../../application/state/editorTabStore";
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
@@ -36,6 +37,8 @@ export const useSftpViewFileOps = ({
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
statSftp,
listSftp,
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
@@ -404,7 +407,7 @@ export const useSftpViewFileOps = ({
if (!pane.connection) return;
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const isDirectory = isNavigableDirectory(file);
let isDirectory = isNavigableDirectory(file);
try {
// For local files, use blob download.
@@ -442,6 +445,18 @@ export const useSftpViewFileOps = ({
throw new Error("SFTP session not found");
}
// The listed entry can be stale when the file was deleted / recreated
// in the terminal without refreshing the side panel. Re-stat so the
// planned snapshot size and entry type match the real source.
const sourceSnapshot = await resolveDownloadSourceSnapshot(
statSftp,
sftpId,
resolvedFullPath,
pane.filenameEncoding,
listSftp,
);
isDirectory = sourceSnapshot.isDirectory;
if (isDirectory) {
if (!selectDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
@@ -451,6 +466,15 @@ export const useSftpViewFileOps = ({
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
// The picker may stay open while the remote entry changes again.
const selectedSnapshot = await resolveDownloadSourceSnapshot(
statSftp,
sftpId, resolvedFullPath, pane.filenameEncoding,
listSftp,
);
if (!selectedSnapshot.isDirectory) {
throw new Error("Remote source changed while choosing the download target");
}
const targetPath = joinFsPath(selectedDirectory, file.name);
try {
@@ -481,14 +505,17 @@ export const useSftpViewFileOps = ({
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
// User cancelled
return;
}
if (!targetPath) return;
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
const selectedSnapshot = await resolveDownloadSourceSnapshot(
statSftp,
sftpId, resolvedFullPath, pane.filenameEncoding,
listSftp,
);
if (selectedSnapshot.isDirectory) {
throw new Error("Remote source changed while choosing the download target");
}
// Route through downloadToLocal so FileZilla-style transfer pool
// sessions are used (browse session stays free for listing).
const status = await sftpRef.current.downloadToLocal({
@@ -501,7 +528,7 @@ export const useSftpViewFileOps = ({
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory: false,
totalBytes: fileSize,
totalBytes: selectedSnapshot.size,
});
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
@@ -523,6 +550,8 @@ export const useSftpViewFileOps = ({
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
statSftp,
listSftp,
],
);
@@ -633,8 +662,16 @@ export const useSftpViewFileOps = ({
try {
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const targetPath = joinFsPath(selectedDirectory, file.name);
const isDirectory = isNavigableDirectory(file);
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
// Re-stat each root: the listed entries can be stale after
// terminal-side delete + recreate without a refresh.
const sourceSnapshot = await resolveDownloadSourceSnapshot(
statSftp,
sftpId,
sourcePath,
pane.filenameEncoding,
listSftp,
);
const isDirectory = sourceSnapshot.isDirectory;
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
@@ -646,7 +683,7 @@ export const useSftpViewFileOps = ({
sourceHostLabel: pane.connection.hostLabel,
sourceEncoding: pane.filenameEncoding,
isDirectory,
totalBytes: isDirectory ? undefined : fileSize,
totalBytes: isDirectory ? undefined : sourceSnapshot.size,
});
results[index] = { status: "fulfilled", value: { file, status } };
} catch (reason) {
@@ -679,6 +716,8 @@ export const useSftpViewFileOps = ({
t,
selectDirectory,
getSftpIdForConnection,
statSftp,
listSftp,
handleDownloadFileForSide,
],
);
@@ -1,6 +1,6 @@
import type React from "react";
import type { MutableRefObject } from "react";
import type { SftpFileEntry } from "../../../types";
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
@@ -21,6 +21,9 @@ export interface UseSftpViewFileOpsParams {
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
/** Application-layer fresh stat for download source snapshots (kept off the raw bridge). */
statSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpStatResult>;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
}
export interface UseSftpViewFileOpsResult {
@@ -28,6 +28,7 @@ interface UseSftpViewPaneCallbacksParams {
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
statSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpStatResult>;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<void>;
deleteLocalFile?: (path: string) => Promise<void>;
@@ -45,6 +46,7 @@ export const useSftpViewPaneCallbacks = ({
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
statSftp,
listLocalFiles,
listDrives,
}: UseSftpViewPaneCallbacksParams) => {
@@ -59,6 +61,8 @@ export const useSftpViewPaneCallbacks = ({
showSaveDialog,
selectDirectory,
getSftpIdForConnection,
statSftp,
listSftp,
});
const listLocalFilesRef = useRef(listLocalFiles);
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { SftpFileEntry } from "../../types";
import type { SftpStateApi } from "../../application/state/useSftpState";
import { useSftpViewFileOps } from "./hooks/useSftpViewFileOps";
const file = (
name: string, size: number, type: SftpFileEntry["type"] = "file",
linkTarget?: SftpFileEntry["linkTarget"],
): SftpFileEntry => ({
name, size, type, linkTarget, lastModified: 0, sizeFormatted: "", lastModifiedFormatted: "",
});
test("single and batch downloads route by current remote type and size", async () => {
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
const current = new Map<string, SftpStatResult>([
["/remote/smaller", { name: "smaller", size: 50_000, sizeKnown: true, type: "file", lastModified: 0 }],
["/remote/larger", { name: "larger", size: 50_000, sizeKnown: true, type: "file", lastModified: 0 }],
["/remote/became-dir", { name: "became-dir", size: 0, sizeKnown: true, type: "directory", lastModified: 0 }],
["/remote/became-file", { name: "became-file", size: 12, sizeKnown: true, type: "file", lastModified: 0 }],
["/remote/changes-in-picker", { name: "changes-in-picker", size: 15, sizeKnown: true, type: "file", lastModified: 0 }],
["/remote/type-changes-in-picker", { name: "type-changes-in-picker", size: 15, sizeKnown: true, type: "file", lastModified: 0 }],
["/remote/statless", { name: "statless", size: 0, sizeKnown: false, type: "file", lastModified: 0 }],
["/remote/file-link", { name: "file-link", size: 9, sizeKnown: true, type: "symlink", lastModified: 0 }],
["/remote/dir-link", { name: "dir-link", size: 9, sizeKnown: true, type: "symlink", lastModified: 0 }],
]);
const downloads: Array<{ sourcePath: string; totalBytes?: number; isDirectory: boolean }> = [];
let saveCalls = 0;
let directoryCalls = 0;
let onSave: (name: string) => void = () => undefined;
const connection = { id: "conn-1", hostId: "host-1", hostLabel: "Host", currentPath: "/remote", isLocal: false };
const pane = { connection, filenameEncoding: "auto" };
const sftpRef = { current: {
leftPane: pane, rightPane: pane,
joinPath: (parent: string, name: string) => `${parent}/${name}`,
downloadToLocal: async (params: { sourcePath: string; totalBytes?: number; isDirectory: boolean }) => {
downloads.push(params);
return "completed";
},
} as unknown as SftpStateApi };
let ops: ReturnType<typeof useSftpViewFileOps> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpViewFileOps({
sftpRef,
behaviorRef: { current: "" },
autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null },
setOpenerForExtension: () => undefined,
t: (key) => key,
showSaveDialog: async (name) => { saveCalls++; onSave(name); return `/downloads/${name}`; },
selectDirectory: async () => { directoryCalls++; return "/downloads"; },
getSftpIdForConnection: () => "sftp-1",
statSftp: async (_id, path) => current.get(path) ?? null as unknown as SftpStatResult,
listSftp: async (_id, parent) => {
assert.equal(parent, "/remote");
return [
{ name: "file-link", type: "symlink", linkTarget: "file", size: "9 bytes", lastModified: "" },
{ name: "dir-link", type: "symlink", linkTarget: "directory", size: "9 bytes", lastModified: "" },
];
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const single = ops!.onDownloadFileLeft as unknown as (entry: SftpFileEntry) => Promise<void>;
const batch = ops!.onDownloadFilesLeft as unknown as (entries: SftpFileEntry[]) => Promise<void>;
await act(async () => { await single(file("smaller", 100_000)); });
await act(async () => { await single(file("larger", 25_000)); });
assert.deepEqual(downloads.map(({ totalBytes }) => totalBytes), [50_000, 50_000]);
assert.equal(saveCalls, 2, "save dialog still opens for every single-file download");
onSave = (name) => {
if (name === "changes-in-picker") current.set("/remote/changes-in-picker", {
name, size: 30, sizeKnown: true, type: "file", lastModified: 0,
});
if (name === "type-changes-in-picker") current.set("/remote/type-changes-in-picker", {
name, size: 0, sizeKnown: true, type: "directory", lastModified: 0,
});
};
await act(async () => { await single(file("changes-in-picker", 100)); });
assert.equal(downloads.at(-1)?.totalBytes, 30, "size is checked again after Save As");
const countBeforeTypeChange = downloads.length;
await act(async () => { await single(file("type-changes-in-picker", 100)); });
assert.equal(downloads.length, countBeforeTypeChange, "type changes during Save As abort the transfer");
const countBeforeMissing = saveCalls;
await act(async () => { await single(file("missing", 100_000)); });
assert.equal(saveCalls, countBeforeMissing, "missing source never opens Save As");
await act(async () => { await single(file("statless", 100_000)); });
assert.equal(downloads.at(-1)?.totalBytes, undefined, "stat-less SCP does not use listed size");
await act(async () => { await single(file("file-link", 9, "symlink", "directory")); });
assert.equal(downloads.at(-1)?.isDirectory, false);
assert.equal(downloads.at(-1)?.totalBytes, undefined, "link node size is not file size");
await act(async () => { await single(file("became-dir", 100_000)); });
assert.equal(downloads.at(-1)?.isDirectory, true);
assert.equal(directoryCalls, 1);
await act(async () => {
await batch([
file("became-file", 0, "directory"),
file("missing", 100_000),
file("dir-link", 9, "symlink", "file"),
]);
});
assert.equal(directoryCalls, 2, "batch chooses one directory");
const becameFile = downloads.find(({ sourcePath }) => sourcePath === "/remote/became-file");
assert.equal(becameFile?.isDirectory, false);
assert.equal(becameFile?.totalBytes, 12);
assert.equal(downloads.find(({ sourcePath }) => sourcePath === "/remote/dir-link")?.isDirectory, true);
assert.equal(downloads.some(({ sourcePath }) => sourcePath === "/remote/missing"), false);
} finally {
await act(async () => { renderer?.unmount(); });
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});