mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2218c252c2 | ||
|
|
718ece9a45 | ||
|
|
847a377f2a | ||
|
|
86393cd4e8 |
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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user