Compare commits

...
11 changed files with 281 additions and 13 deletions

No files matched your search

@@ -696,6 +696,67 @@ test("single-file restart resume continues from checkpoint without page callback
}
});
test("stat-less SCP download resume keeps saved progress instead of restarting", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let startOptions: Record<string, unknown> | undefined;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
// Stat-less SCP host: size cannot be measured, so the bridge reports the
// placeholder 0 with sizeKnown false.
statSftp: async () => ({ type: "file", size: 0, lastModified: 5, sizeKnown: false }),
startStreamTransfer: async (options: Record<string, unknown>) => {
startOptions = options;
return { transferId: "scp-download-resume" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "scp-download-resume",
fileName: "file.bin",
sourcePath: "/remote/file.bin",
targetPath: "/local/file.bin",
sourceConnectionId: "old-sftp",
sourceHostId: "h1",
sourceHostLabel: "box",
targetConnectionId: "local",
direction: "download",
status: "interrupted",
totalBytes: 100,
transferredBytes: 20,
checkpointBytes: 20,
sourceLastModified: 5,
speed: 0,
startTime: 1,
isDirectory: false,
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")],
keys: [],
identities: [],
});
assert.equal(result.success, true, result.error);
// Unknown size must not misread the saved checkpoint as a shrunk source;
// the download resumes from byte 20 instead of silently restarting.
assert.equal(startOptions?.checkpointBytes, 20);
assert.equal(startOptions?.totalBytes, 100);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder restart resume reports file-count progress without child page callbacks", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
@@ -671,6 +671,7 @@ async function resumeSingleFileWithDedicatedSession(
const validationError = validateTransferResumeSource(task, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
sizeKnown: sourceStat.sizeKnown,
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "modified") {
@@ -1206,6 +1207,7 @@ async function resumeDirectoryWithDedicatedSession(
const validationError = validateTransferResumeSource(childBase, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
sizeKnown: sourceStat.sizeKnown,
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "restart") {
@@ -1236,16 +1238,23 @@ async function resumeDirectoryWithDedicatedSession(
// zero-byte plans (`||` would incorrectly promote to grown size).
const plannedBytes = Number(childBase.totalBytes);
const hasPlannedBytes = Number.isFinite(plannedBytes) && plannedBytes >= 0;
// Stat-less SCP sources report size as a placeholder 0; keep the
// planned size rather than re-planning to a fake zero.
const sourceSizeKnown = sourceStat.sizeKnown !== false && Number.isFinite(sourceStat.size);
childBase = {
...childBase,
totalBytes: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
totalBytes: !sourceSizeKnown
? childBase.totalBytes
: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: !sourceSizeKnown
? (sourceStat.lastModified ?? childBase.sourceLastModified)
: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
};
}
@@ -118,6 +118,10 @@ async function tryStatTransferPath(
if (!sftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(sftpId, filePath, encoding);
if (!stat || stat.type === "directory") return null;
// Stat-less SCP endpoints report size as a placeholder 0 (sizeKnown false).
// Treat that as missing metadata so skip-unchanged is disabled rather than
// comparing a fake zero against the other endpoint (Codex P1).
if (stat.sizeKnown === false) return null;
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
} catch {
return null;
+36
View File
@@ -228,6 +228,42 @@ test("resume rejects changed or shortened source files", () => {
);
});
test("resume skips size-based validation when the source size is unknown", () => {
// Stat-less SCP endpoints report size as a placeholder 0 (sizeKnown false).
// Saved progress must not be misread as a shrunk/changed source.
const resumable = {
...task("scp", "interrupted", 1),
totalBytes: 100,
sourceLastModified: 50,
checkpointBytes: 60,
};
assert.equal(
validateTransferResumeSource(
resumable,
{ size: 0, lastModified: 50, sizeKnown: false },
{ allowSourceGrowth: true },
),
null,
);
// mtime drift is still detectable without a size.
assert.match(
validateTransferResumeSource(
resumable,
{ size: 0, lastModified: 51, sizeKnown: false },
) ?? "",
/modified/,
);
// The mtime guard is skipped while growth is allowed (append-only updates).
assert.equal(
validateTransferResumeSource(
resumable,
{ size: 0, lastModified: 51, sizeKnown: false },
{ allowSourceGrowth: true },
),
null,
);
});
test("prune upgrades legacy completed children into the parent checkpoint", () => {
const now = Date.now();
const parent: TransferTask = {
+17 -1
View File
@@ -252,9 +252,25 @@ export function pruneSftpTransferHistory(
export function validateTransferResumeSource(
task: Pick<TransferTask, "totalBytes" | "sourceLastModified" | "checkpointBytes">,
source: { size: number; lastModified?: number },
source: { size: number; lastModified?: number; sizeKnown?: boolean },
options?: { allowSourceGrowth?: boolean },
): string | null {
// Stat-less SCP endpoints report size as a placeholder 0 (sizeKnown false).
// Size-based checks would misread any saved progress against that fake zero
// as a shrunk/changed source and silently restart the transfer, so only the
// mtime guard applies while the size is unknown.
const sizeKnown = source.sizeKnown !== false && Number.isFinite(source.size);
if (!sizeKnown) {
if (
!options?.allowSourceGrowth
&& task.sourceLastModified
&& source.lastModified
&& source.lastModified !== task.sourceLastModified
) {
return "Source was modified while the transfer was paused";
}
return null;
}
const checkpoint = Math.max(0, task.checkpointBytes ?? 0);
if (checkpoint > source.size) return "Saved checkpoint is beyond the current source size";
const plannedSize = Number(task.totalBytes);
@@ -1132,6 +1132,45 @@ test("SCP upload aborts while staged size verification is pending", async () =>
assert.equal(removedStage, true);
});
test("SCP upload succeeds when the remote cannot report file sizes", async () => {
// #3399: devices without SFTP (and without a usable stat binary) report the
// "?" unknown-size marker after a successful scp -t upload. The staged size
// verification must skip instead of failing with "Upload size mismatch ...
// got 0" while download on the same device works.
let renameCalls = 0;
let uploadCalls = 0;
const backend = {
async stat(remotePath) {
if (String(remotePath).includes(".netcatty-backup-")) {
const error = new Error("No such file");
error.code = "ENOENT";
throw error;
}
// Mimic parseStatRecord on a stat-less device: type/mode known, size unknown.
return { type: "file", isDirectory: false, isSymbolicLink: false, size: undefined };
},
async rename() {
renameCalls += 1;
},
async remove() {},
};
const client = {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
};
const result = await sftpBridge.runRemoteUploadTransaction(client, "/tmp/local.bin", "/tmp/remote.bin", {
expectedSize: 7,
async uploadFile() {
uploadCalls += 1;
},
});
assert.equal(result?.staged, true);
assert.equal(uploadCalls, 1);
// Existing target moves to backup, then the stage promotes to the final path.
assert.equal(renameCalls, 2);
});
test("staged basenames stay within the remote NAME_MAX budget", async (t) => {
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-long-name-"));
t.after(async () => {
+11 -1
View File
@@ -886,10 +886,20 @@ function createFileOpsApi(ctx) {
}
function formatSftpStatResult(payloadPath, stat, permissions) {
// The renderer contract (SftpStatResult.size) requires a number. A
// stat-less SCP host reports an unknown size internally (undefined) so
// size-sensitive backend checks can skip it, but that value must not
// cross the IPC boundary: conflict dialogs and TransferTask.totalBytes
// would render "NaN undefined". Map unknown to 0 ("--" when formatted).
const sizeKnown = Number.isFinite(stat.size);
const size = sizeKnown ? stat.size : 0;
return {
name: path.basename(payloadPath),
type: stat.isDirectory ? "directory" : stat.isSymbolicLink ? "symlink" : "file",
size: stat.size,
size,
// Skip-unchanged and resume checks must not mistake the placeholder 0
// for a real zero-byte file; they gate on this marker instead.
sizeKnown,
lastModified: stat.modifyTime,
permissions,
};
@@ -512,3 +512,62 @@ test("listSftp includes owner from longname and falls back to uid", async () =>
assert.equal(entries[1].name, "uid-only.bin");
assert.equal(entries[1].owner, "1000");
});
test("statSftp maps an unknown SCP stat size to 0 at the renderer boundary", async () => {
// A stat-less SCP host reports size: undefined internally so backend
// size-sensitive checks can skip it, but SftpStatResult.size must stay a
// number for the renderer (conflict dialog / TransferTask.totalBytes).
const client = { __netcattyFileProtocol: "scp" };
client.__netcattyScpBackend = {
stat: async () => ({
type: "file",
isDirectory: false,
isSymbolicLink: false,
size: undefined,
modifyTime: 1000,
mode: 0o100644,
permissions: "rw-r--r--",
path: "/tmp/data.bin",
}),
};
const api = createFileOpsApi({
sftpClients: new Map([["scp-1", client]]),
path: require("node:path"),
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
requireSftpChannel: async () => { throw new Error("SFTP channel must not be used in SCP mode"); },
});
const result = await api.statSftp(null, { sftpId: "scp-1", path: "/tmp/data.bin" });
assert.equal(result.size, 0);
assert.equal(typeof result.size, "number");
assert.equal(result.sizeKnown, false);
assert.equal(result.type, "file");
});
test("statSftp marks a real SCP stat size as known", async () => {
const client = { __netcattyFileProtocol: "scp" };
client.__netcattyScpBackend = {
stat: async () => ({
type: "file",
isDirectory: false,
isSymbolicLink: false,
size: 4096,
modifyTime: 1000,
mode: 0o100644,
permissions: "rw-r--r--",
path: "/tmp/data.bin",
}),
};
const api = createFileOpsApi({
sftpClients: new Map([["scp-1", client]]),
path: require("node:path"),
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
requireSftpChannel: async () => { throw new Error("SFTP channel must not be used in SCP mode"); },
});
const result = await api.statSftp(null, { sftpId: "scp-1", path: "/tmp/data.bin" });
assert.equal(result.size, 4096);
assert.equal(result.sizeKnown, true);
});
@@ -173,6 +173,29 @@ describe("scpBackend browse/manage with fake exec", () => {
assert.equal(st.isDirectory, false);
const statCommand = commands.find((entry) => entry.command.includes("if [ ! -e"))?.command || "";
assert.match(statCommand, /\[ ! -L "\$p" \]/, "broken symlinks must not be reported as missing");
assert.match(
statCommand,
/stat -f %z -- "\$p" 2>\/dev\/null \|\| echo "\?"/,
"a missing stat binary must yield the unknown-size marker, not a fake 0",
);
});
it("reports an unknown size when the remote has no stat binary", async () => {
// Devices without SFTP often also lack a usable stat binary; the stat
// command then falls back to the "?" marker. The upload size verification
// must skip instead of failing with "expected N bytes, got 0" (#3399).
const noStatBackend = createScpBackend({
exec: async () => ({
stdout: "f|-rw-r--r--|?|1700000000|/home/test/readme.txt\n",
stderr: "",
code: 0,
}),
execStream: async () => createMockStream(),
});
const st = await noStatBackend.stat("/home/test/readme.txt");
assert.equal(st.size, undefined);
assert.equal(st.isDirectory, false);
assert.equal(st.type, "file");
});
it("reports ENOENT when the stat command exits 2 with an ENOENT marker", async () => {
+12 -3
View File
@@ -126,11 +126,15 @@ function buildStatCommand(remotePath, encoding = "utf-8") {
'elif [ -d "$p" ]; then t=d',
'else t=f; fi',
'mode=$(ls -ld -- "$p" 2>/dev/null | awk \'{print $1}\')',
'size=$(stat -c %s -- "$p" 2>/dev/null || stat -f %z -- "$p" 2>/dev/null || echo 0)',
// A missing/unsupported stat binary must not masquerade as a real 0-byte
// file: post-upload size verification would then reject a successful
// upload with "expected N bytes, got 0" (#3399). Emit the same "?" unknown
// marker used for mode so the parser can report an unknown size instead.
'size=$(stat -c %s -- "$p" 2>/dev/null || stat -f %z -- "$p" 2>/dev/null || echo "?")',
'mtime=$(date -r "$p" +%s 2>/dev/null || stat -c %Y -- "$p" 2>/dev/null || stat -f %m -- "$p" 2>/dev/null || echo 0)',
'abs=$(cd "$(dirname -- "$p")" 2>/dev/null && printf "%s/%s\\n" "$(pwd -P 2>/dev/null || pwd)" "$(basename -- "$p")" || printf "%s\\n" "$p")',
'ino=$(stat -c %i -- "$p" 2>/dev/null || stat -f %i -- "$p" 2>/dev/null || echo)',
'printf "%s|%s|%s|%s|%s|%s\\n" "$t" "${mode:-?}" "${size:-0}" "${mtime:-0}" "$abs" "${ino}"',
'printf "%s|%s|%s|%s|%s|%s\\n" "$t" "${mode:-?}" "${size:-?}" "${mtime:-0}" "$abs" "${ino}"',
].join("; ");
}
@@ -383,11 +387,16 @@ function parseStatRecord(stdout, { stderr = "", exitCode = null } = {}) {
const ino = inoStr && /^\d+$/.test(String(inoStr).trim())
? String(inoStr).trim()
: undefined;
// "?" (or an empty field) means the remote could not measure the size —
// e.g. a device without a stat binary. Report undefined rather than a fake
// 0 so callers can skip size-sensitive checks instead of failing them.
const sizeStrTrim = String(sizeStr ?? "").trim();
const size = /^\d+$/.test(sizeStrTrim) ? Number(sizeStrTrim) : undefined;
return {
type: t === "d" ? "directory" : t === "l" ? "symlink" : "file",
isDirectory: t === "d",
isSymbolicLink: t === "l",
size: Number(sizeStr) || 0,
size,
modifyTime: (Number(mtimeStr) || 0) * 1000,
mode: lsModeToNumber(modeStr),
permissions: parseLsModeToPermissions(modeStr),
Vendored
+2
View File
@@ -183,6 +183,8 @@ declare global {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
/** False when the endpoint cannot report a size (stat-less SCP); size is a placeholder 0. */
sizeKnown?: boolean;
lastModified: number; // timestamp
permissions?: string; // e.g., "rwxr-xr-x"
owner?: string;