mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 10:14:20 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3096f86889 | ||
|
|
94f8bd9bc7 | ||
|
|
5cd42cb518 |
No files matched your search
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
import { buildCacheKey } from "./sharedRemoteHostCache";
|
||||
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
|
||||
@@ -999,3 +1000,86 @@ test("no-conflict local copy awaits discovery so a late stat cannot overwrite th
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of ["folder-cp", "folder-fallback", "file-cp", "different-key", "missing-key", "non-utf8"] as const) {
|
||||
test(`fresh remote copy selects a consistent strategy: ${scenario} (#4207)`, async () => {
|
||||
const isDirectory = scenario.startsWith("folder");
|
||||
const source = makePane("left");
|
||||
const target = makePane("left");
|
||||
target.connection = { ...target.connection!, id: "target-conn", currentPath: "/usr/tmp" };
|
||||
if (scenario === "non-utf8") target.filenameEncoding = "gb18030";
|
||||
const key = buildCacheKey("host-1", "debian", 22, "ssh", false, "user", "sftp");
|
||||
const keys = new Map([["remote-conn", key], ["target-conn", key]]);
|
||||
if (scenario === "different-key") keys.set("target-conn", buildCacheKey("host-2", "debian", 22, "ssh", false, "user", "sftp"));
|
||||
if (scenario === "missing-key") keys.delete("target-conn");
|
||||
const ownerId = `same-host-${scenario}-4207`;
|
||||
const streams: Array<{ resumable: boolean; sameHost?: boolean; parentTaskId?: string }> = [];
|
||||
const copies: unknown[][] = [];
|
||||
let listings = 0;
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const restore = installGlobals({
|
||||
statSftp: async (id: string) => id === "sftp-source"
|
||||
? { type: "file", size: 3626, lastModified: 1758000001000 }
|
||||
: null,
|
||||
mkdirSftp: async () => undefined,
|
||||
sameHostCopyDirectory: async (...args: unknown[]) => {
|
||||
copies.push(args);
|
||||
assert.equal(sftpTransferCenterStore.getTask(args[4] as string)?.resumable, false);
|
||||
return { success: scenario !== "folder-fallback" };
|
||||
},
|
||||
startStreamTransfer: async (options: { transferId: string; resumable: boolean; sameHost?: boolean; parentTaskId?: string }) => {
|
||||
streams.push(options);
|
||||
assert.equal(sftpTransferCenterStore.getTask(options.transferId)?.resumable, options.resumable);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: options.transferId,
|
||||
transferred: 3626, totalBytes: 3626, lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => side === "left" ? source : target,
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-source"], ["target-conn", "sftp-target"]]) },
|
||||
connectionCacheKeyMapRef: { current: keys },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => { listings++; return source.files; },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const results = await ops!.startTransfer(
|
||||
[{ name: isDirectory ? "bt_mine" : "aops_dns_view_demo.sh", isDirectory }], "left", "right",
|
||||
);
|
||||
assert.equal(results[0]?.status, "completed");
|
||||
});
|
||||
const eligible = ["folder-cp", "folder-fallback", "file-cp"].includes(scenario);
|
||||
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
assert.ok(rows.length > 0);
|
||||
assert.ok(rows.every(row => row.resumable === !eligible));
|
||||
assert.equal(copies.length, isDirectory ? 1 : 0);
|
||||
if (isDirectory) assert.deepEqual(copies[0]?.slice(0, 4), ["sftp-source", "/opt/bt_mine", "/usr/tmp/bt_mine", "auto"]);
|
||||
assert.equal(listings, scenario === "folder-fallback" ? 1 : 0);
|
||||
assert.equal(streams.length, scenario === "folder-cp" ? 0 : 1);
|
||||
if (streams.length) {
|
||||
assert.equal(streams[0].resumable, !eligible);
|
||||
assert.equal(streams[0].sameHost, !["different-key", "missing-key"].includes(scenario) || undefined);
|
||||
if (isDirectory) assert.ok(streams[0].parentTaskId);
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1348,6 +1348,17 @@ export const useSftpTransfers = ({
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
const batchId = crypto.randomUUID();
|
||||
const usesLegacyScp = sourcePane.connection.fileProtocol === "scp" || targetPane.connection.fileProtocol === "scp";
|
||||
const sourceCacheKey = connectionCacheKeyMapRef.current.get(sourcePane.connection.id);
|
||||
const targetCacheKey = connectionCacheKeyMapRef.current.get(targetPane.connection.id);
|
||||
const encodingSafeForExec = [sourcePane.filenameEncoding, targetPane.filenameEncoding]
|
||||
.every((encoding) => !encoding || encoding === "utf-8" || encoding === "auto");
|
||||
// Select the existing non-resumable cp route only for newly created copies.
|
||||
// Resume/recovery keeps its saved strategy and checkpoints. If cp fails,
|
||||
// recursive/download+upload fallback also remains non-resumable.
|
||||
const useSameHostCopy = !usesLegacyScp
|
||||
&& !sourcePane.connection.isLocal && !targetPane.connection.isLocal
|
||||
&& !!sourceCacheKey && sourceCacheKey === targetCacheKey
|
||||
&& encodingSafeForExec;
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
const skippedResults: TransferResult[] = [];
|
||||
@@ -1582,8 +1593,10 @@ export const useSftpTransfers = ({
|
||||
sourceLastModified,
|
||||
preflightStatSkipped: preflightSkippedNames.has(file.name),
|
||||
origin: "manual",
|
||||
resumable: !usesLegacyScp,
|
||||
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
|
||||
resumable: !usesLegacyScp && !useSameHostCopy,
|
||||
pauseUnavailableReason: usesLegacyScp
|
||||
? "This server uses legacy SCP; cancel and retry from the beginning instead"
|
||||
: useSameHostCopy ? "Same-host copies cannot be paused; cancel and retry from the beginning instead" : undefined,
|
||||
phase: file.isDirectory ? "scanning" : "transferring",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
|
||||
assert.match(source, /Toggle,\s*\n\s*Select,/);
|
||||
});
|
||||
|
||||
test("folder compression row exposes its settings search anchor", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/<SettingRow\s+anchorId="sftp-compressed-upload"\s+label=\{t\('settings\.sftp\.compressedUpload\.enable'\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
function functionBody(functionName: string): string {
|
||||
const start = source.indexOf(`const ${functionName} = useCallback`);
|
||||
assert.notEqual(start, -1, `${functionName} should exist`);
|
||||
|
||||
@@ -180,6 +180,7 @@ export default function SettingsFileAssociationsTab() {
|
||||
<SectionHeader title={t('settings.sftp.compressedUpload')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-compressed-upload"
|
||||
label={t('settings.sftp.compressedUpload.enable')}
|
||||
description={t('settings.sftp.compressedUpload.enableDesc')}
|
||||
>
|
||||
@@ -231,12 +232,14 @@ export default function SettingsFileAssociationsTab() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="sftp-skip-unchanged"
|
||||
label={t('settings.sftp.skipUnchanged.enable')}
|
||||
description={t('settings.sftp.skipUnchanged.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="sftp-transport-idle-ttl"
|
||||
label={t('settings.ssh.transportIdleTtl')}
|
||||
description={t('settings.ssh.transportIdleTtl.desc')}
|
||||
>
|
||||
|
||||
@@ -75,6 +75,9 @@ test('list hook preserves local copy/move routing, remote HTML drag, selection a
|
||||
await act(async () => root.render(React.createElement(Harness, { local: false })));
|
||||
await act(async () => right.handlePaneDrop(event()));
|
||||
assert.equal(actions.at(-1)?.kind, 'copy');
|
||||
actions.length = 0;
|
||||
await act(async () => left.handleEntryDrop(entries[2], event()));
|
||||
assert.deepEqual(actions, [{ kind: 'move', args: [['/files/b.txt'], '/files/folder'] }], 'same-pane remote drag stays a move');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
clearLocalFileDrag();
|
||||
|
||||
@@ -125,6 +125,48 @@ test("settings search finds App Lock from the zh-CN locale", () => {
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
|
||||
});
|
||||
|
||||
test("settings search finds folder compression from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["压缩", "普通传输"]],
|
||||
[tEnReal, ["compress", "tar"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-compressed-upload"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds skip unchanged files from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["跳过未改文件", "修改时间"]],
|
||||
[tEnReal, ["skip unchanged", "rsync"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-skip-unchanged"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds SSH connection keep-alive from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["保活时间", "重新认证"]],
|
||||
[tEnReal, ["keep-alive", "re-authentication"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-transport-idle-ttl"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds temporary network device mode", () => {
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
const tZhTW = (key: string) => zhTW[key] ?? key;
|
||||
|
||||
@@ -671,6 +671,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.sftp.autoSync.enableDesc",
|
||||
sectionKey: "settings.sftp.autoSync",
|
||||
},
|
||||
{
|
||||
id: "sftp-compressed-upload",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.sftp.compressedUpload.enable",
|
||||
descriptionKey: "settings.sftp.compressedUpload.enableDesc",
|
||||
sectionKey: "settings.sftp.compressedUpload",
|
||||
},
|
||||
{
|
||||
id: "sftp-follow-terminal-cwd",
|
||||
tab: "file-associations",
|
||||
@@ -691,6 +698,20 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
labelKey: "settings.sftp.transferConcurrency",
|
||||
descriptionKey: "settings.sftp.transferConcurrency.desc",
|
||||
},
|
||||
{
|
||||
id: "sftp-skip-unchanged",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.sftp.skipUnchanged.enable",
|
||||
descriptionKey: "settings.sftp.skipUnchanged.enableDesc",
|
||||
sectionKey: "settings.sftp.transferConcurrency",
|
||||
},
|
||||
{
|
||||
id: "sftp-transport-idle-ttl",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.ssh.transportIdleTtl",
|
||||
descriptionKey: "settings.ssh.transportIdleTtl.desc",
|
||||
sectionKey: "settings.sftp.transferConcurrency",
|
||||
},
|
||||
{
|
||||
id: "sftp-default-opener",
|
||||
tab: "file-associations",
|
||||
|
||||
@@ -281,6 +281,26 @@ test('wall deadline during authorization reports authorization failure rather th
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('idle Enter-PSSession remoting prompt allows the command to be sent', async () => {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
const prompt = '[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ';
|
||||
trackSessionIdlePrompt(session, `PowerShell 7\r\n${prompt}`);
|
||||
assert.equal(readyPrompt(session), prompt);
|
||||
|
||||
const result = await startPtyJob(pty, 'Write-Output ok', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
typedInput: true,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.stdout, 'OK');
|
||||
assert.equal(pty.writes.length, 1);
|
||||
assert.match(pty.writes[0], /\$__NCMCP_/);
|
||||
});
|
||||
|
||||
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
|
||||
@@ -50,11 +50,12 @@ function formatSyntheticEcho(command) {
|
||||
}
|
||||
|
||||
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
|
||||
// `PS /home/alice>`). Anchored so command output that merely starts with
|
||||
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
|
||||
// `PS /home/alice>`), with optional Enter-PSSession / remoting `[host]: `
|
||||
// and debug `[DBG]: ` prefixes. Anchored so command output that merely
|
||||
// starts with `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
|
||||
// `"PS >"` (which the default prompt never emits) so a script that prints
|
||||
// such a line can't trick prompt-driven shell-kind selection.
|
||||
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
|
||||
const POWERSHELL_PROMPT_PATTERN = /^(?:\[[^\]\r\n]+\]: )*PS(?:\s+\S.*)?>$/;
|
||||
|
||||
// Default cmd.exe prompt (e.g. `C:\>`, `C:\Users\alice>`, `D:\data>`).
|
||||
// Drive letter + optional path + `>`. Rejects `C: >` (space before `>`) and
|
||||
|
||||
@@ -112,6 +112,46 @@ test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alik
|
||||
assert.equal(isDefaultPowerShellPromptLine(null), false);
|
||||
});
|
||||
|
||||
test("PowerShell prompt helpers recognize remoting and debug prefixes", () => {
|
||||
for (const prompt of [
|
||||
"[192.168.1.10]: PS C:\\Users\\Administrator\\Documents>",
|
||||
"[SERVER01]: PS C:\\>",
|
||||
"[host.example.com]: PS C:\\Windows\\System32>",
|
||||
"[fe80::1]: PS C:\\>",
|
||||
"[DBG]: PS C:\\Users\\alice>",
|
||||
"[SERVER01]: [DBG]: PS C:\\>",
|
||||
]) {
|
||||
assert.equal(isDefaultPowerShellPromptLine(prompt), true, prompt);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("PowerShell prompt helpers reject malformed remoting prefixes and look-alikes", () => {
|
||||
for (const line of [
|
||||
"[host] PS C:\\>",
|
||||
"prefix [host]: PS C:\\>",
|
||||
"[]: PS C:\\>",
|
||||
"[ho]st]: PS C:\\>",
|
||||
"[ho\rst]: PS C:\\>",
|
||||
"[ho\nst]: PS C:\\>",
|
||||
"[host]:PS C:\\>",
|
||||
"[host]: PS >",
|
||||
"PS >",
|
||||
"PSO>",
|
||||
"ZIPS>",
|
||||
"",
|
||||
null,
|
||||
]) {
|
||||
assert.equal(isDefaultPowerShellPromptLine(line), false, String(line));
|
||||
assert.equal(extractTrailingIdlePrompt(line), "", String(line));
|
||||
}
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt preserves trailing space on a remoting prompt", () => {
|
||||
const prompt = "[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ";
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt);
|
||||
});
|
||||
|
||||
test("extracts a trailing cmd.exe idle prompt", () => {
|
||||
// Windows OpenSSH default shell is cmd.exe; without capturing `C:\...>`
|
||||
// AI exec cannot select the cmd wrapper when shellKind is still unset.
|
||||
|
||||
@@ -1913,6 +1913,16 @@ function createSessionBackedSftpClient(sessionId, sshClient, options = {}) {
|
||||
const attrs = await statAsync(sftp, remotePath);
|
||||
return statResultFromAttrs(attrs);
|
||||
},
|
||||
async lstat(remotePath) {
|
||||
const sftp = await requireSftpChannel(client);
|
||||
// Copy preflights must inspect link nodes, never their followed targets.
|
||||
// lstatAsync falls back to STAT when LSTAT is absent, so reject first.
|
||||
if (typeof sftp.lstat !== "function") {
|
||||
throw Object.assign(new Error("Remote server does not support LSTAT"), { code: "ENOTSUP" });
|
||||
}
|
||||
const attrs = await lstatAsync(sftp, remotePath);
|
||||
return statResultFromAttrs(attrs);
|
||||
},
|
||||
async realPath(remotePath) {
|
||||
const sftp = await requireSftpChannel(client);
|
||||
return await realpathAsync(sftp, remotePath);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { Readable, PassThrough } = require("node:stream");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
|
||||
const missing = () => Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
|
||||
async function openSessionClient(t, { sourceIsLink = false, targetExists = false } = {}) {
|
||||
const commands = [];
|
||||
const channel = new EventEmitter();
|
||||
channel.readdir = (_path, callback) => callback(null, []);
|
||||
channel.mkdir = (_path, callback) => callback(null);
|
||||
channel.unlink = (_path, callback) => callback(null);
|
||||
channel.stat = (remotePath, callback) => {
|
||||
if (String(remotePath) === "/target/file" && !targetExists) return callback(missing());
|
||||
callback(null, { size: 66, mode: 0o100640, mtime: 1 });
|
||||
};
|
||||
channel.lstat = (remotePath, callback) => {
|
||||
if (String(remotePath) === "/target" && !targetExists) return callback(missing());
|
||||
callback(null, { size: 66, mode: sourceIsLink ? 0o120777 : 0o100640, mtime: 1 });
|
||||
};
|
||||
channel.createReadStream = () => Readable.from([Buffer.alloc(66)]);
|
||||
const connection = {
|
||||
sftp(callback) { callback(null, channel); },
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
};
|
||||
const clients = new Map();
|
||||
sftpBridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["copy-session", { conn: connection }]]),
|
||||
sftpClients: clients,
|
||||
});
|
||||
transferBridge.init({ sftpClients: clients });
|
||||
const { sftpId } = await sftpBridge.openSftpForSession(null, {
|
||||
sessionId: "copy-session", fileProtocol: "sftp",
|
||||
});
|
||||
t.after(() => sftpBridge.closeSftp(null, { sftpId }));
|
||||
return { client: clients.get(sftpId), channel, commands, sftpId };
|
||||
}
|
||||
|
||||
test("same-host file copy executes cp using the actual session-backed client (#4207)", async (t) => {
|
||||
const { commands, sftpId } = await openSessionClient(t);
|
||||
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
|
||||
transferId: "session-backed-file-copy",
|
||||
sourcePath: "/source/file", targetPath: "/target/file",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: sftpId, targetSftpId: sftpId,
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: 66, sameHost: true, resumable: false,
|
||||
});
|
||||
assert.deepEqual(commands, ["cp -a '/source/file' '/target/file'"]);
|
||||
assert.equal(result.error, undefined);
|
||||
});
|
||||
|
||||
for (const sourceIsLink of [false, true]) {
|
||||
test(`session-backed LSTAT preserves link classification (${sourceIsLink})`, async (t) => {
|
||||
const { client } = await openSessionClient(t, { sourceIsLink });
|
||||
assert.equal((await client.lstat("/source/file")).isSymbolicLink, sourceIsLink);
|
||||
assert.equal((await client.stat("/source/file")).isSymbolicLink, false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const unsupported of ["missing-method", "server-error"]) {
|
||||
test(`session-backed LSTAT never follows STAT on ${unsupported}`, async (t) => {
|
||||
const { client, channel } = await openSessionClient(t);
|
||||
let statCalls = 0;
|
||||
channel.stat = () => { statCalls++; throw new Error("must not follow symlinks"); };
|
||||
if (unsupported === "missing-method") delete channel.lstat;
|
||||
else channel.lstat = (_path, callback) => callback(Object.assign(new Error("unsupported"), { code: 8 }));
|
||||
await assert.rejects(() => client.lstat("/source/file"), error =>
|
||||
error.code === (unsupported === "missing-method" ? "ENOTSUP" : 8));
|
||||
assert.equal(statCalls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const targetExists of [false, true]) {
|
||||
test(`session-backed directory cp preserves the destination merge guard (${targetExists})`, async (t) => {
|
||||
const { commands, sftpId } = await openSessionClient(t, { targetExists });
|
||||
const result = await transferBridge.sameHostCopyDirectory({ sender: { send() {} } }, {
|
||||
transferId: `session-backed-directory-copy-${targetExists}`,
|
||||
sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
|
||||
});
|
||||
assert.equal(result.success, !targetExists);
|
||||
assert.deepEqual(commands, targetExists ? [] : ["cp -ra '/source/.' '/target/'"]);
|
||||
});
|
||||
}
|
||||
@@ -7174,6 +7174,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
const singleChannelCopy = srcClient.__netcattySingleChannelSsh || sshClient?.__netcattySingleChannelSsh;
|
||||
if (sshClient && typeof sshClient.exec === 'function' && !singleChannelCopy) {
|
||||
try {
|
||||
// cp -a preserves link nodes; the SFTP fallback follows target bytes.
|
||||
// A failed lstat also falls through to that existing route.
|
||||
const sourceAttrs = await runCancelablePreflight(() => srcClient.lstat(
|
||||
encodePathForSession(sourceSftpId, sourcePath, resolvedSourceEnc),
|
||||
));
|
||||
if (sourceAttrs.isSymbolicLink) throw new Error("Source is a symbolic link");
|
||||
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try {
|
||||
await runTransferAbortableOperation(transfer, (signal) => ensureRemoteDirForSession(
|
||||
@@ -7186,6 +7193,18 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const targetClient = sftpClients.get(targetSftpId);
|
||||
let existingRemoteMode = null;
|
||||
try {
|
||||
const attrs = await runCancelablePreflight(() => targetClient.stat(
|
||||
encodePathForSession(targetSftpId, targetPath, resolvedTargetEnc),
|
||||
));
|
||||
if (Number.isFinite(attrs?.mode)) existingRemoteMode = attrs.mode & 0o7777;
|
||||
} catch {
|
||||
// Missing destinations or failed stats need no permission restore.
|
||||
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
|
||||
@@ -7194,6 +7213,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
if (result.code === 0) {
|
||||
sendProgress(fileSize, fileSize);
|
||||
sameHostDone = true;
|
||||
await restoreRemoteUploadModeBestEffort(
|
||||
targetClient,
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
resolvedTargetEnc,
|
||||
existingRemoteMode,
|
||||
);
|
||||
} else if (result.code === 127) {
|
||||
// Exit 127 = command not found — cache to skip future attempts
|
||||
cpUnavailableSet.add(srcClient);
|
||||
@@ -8204,11 +8230,28 @@ async function sameHostCopyDirectory(event, payload) {
|
||||
}
|
||||
if (cpUnavailableSet.has(client)) return { success: false };
|
||||
|
||||
const resolvedEncoding = resolveEncodingForRequest(sftpId, encoding);
|
||||
if (resolvedEncoding && resolvedEncoding !== 'utf-8') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const sshClient = client.client;
|
||||
if (!sshClient || typeof sshClient.exec !== 'function') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// Merges must use recursive SFTP transfers to preserve destination child modes.
|
||||
// Only a clearly missing target is safe for the remote cp optimization.
|
||||
try {
|
||||
await runTransferCancelablePreflight(transfer, () => client.lstat(
|
||||
encodePathForSession(sftpId, targetPath, resolvedEncoding),
|
||||
));
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
if (!isMissingRemoteStageError(error)) return { success: false };
|
||||
}
|
||||
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const lifecyclePayload = {
|
||||
...payload,
|
||||
@@ -8233,8 +8276,7 @@ async function sameHostCopyDirectory(event, payload) {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
// Use "source/." to copy directory *contents* into target, preserving merge
|
||||
// semantics consistent with the recursive per-file transfer path.
|
||||
// Use "source/." to copy directory *contents* into the newly created target.
|
||||
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
|
||||
@@ -106,6 +106,7 @@ test("same-host cp capability cache follows the live SFTP client, not a recycled
|
||||
bridge.init({ sftpClients });
|
||||
|
||||
const makeClient = (exitCode, calls) => ({
|
||||
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
calls.count += 1;
|
||||
|
||||
@@ -1294,7 +1294,9 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
t.after(async () => fs.promises.rm(tempPath, { force: true }));
|
||||
let markPrefixStarted;
|
||||
const prefixStarted = new Promise((resolve) => { markPrefixStarted = resolve; });
|
||||
let execCalls = 0;
|
||||
const sourceClient = {
|
||||
client: { exec() { execCalls += 1; throw new Error("checkpointed copies must not exec cp"); } },
|
||||
sftp: createFastSftp({
|
||||
createReadStream() {
|
||||
markPrefixStarted();
|
||||
@@ -1317,7 +1319,7 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
targetSftpId: "target",
|
||||
totalBytes: 8,
|
||||
resumable: true,
|
||||
sameHost: false,
|
||||
sameHost: true,
|
||||
resumeStage: "download",
|
||||
checkpointBytes: 4,
|
||||
downloadCheckpointBytes: 4,
|
||||
@@ -1332,6 +1334,7 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
]);
|
||||
|
||||
assert.match(result.error || "", /cancel/i);
|
||||
assert.equal(execCalls, 0, "same-host resume must verify its saved prefix instead of running cp");
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
||||
});
|
||||
@@ -13243,6 +13246,119 @@ test("same-host directory copy rejected by a committed close leaves no lease or
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
|
||||
test("same-host directory copy skips cp when auto encoding resolves to non-UTF-8", async (t) => {
|
||||
const {
|
||||
sftpTransferSessionLeaseStore,
|
||||
} = require("./sftpTransferSessionLease.cjs");
|
||||
sftpTransferSessionLeaseStore.resetForTests();
|
||||
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
||||
|
||||
const sftpId = "same-host-directory-auto-gb18030";
|
||||
// Seed the session's resolved encoding before the copy requests auto.
|
||||
sftpBridge.resolveEncodingForRequest(sftpId, "gb18030");
|
||||
let execCalls = 0;
|
||||
transferBridge.init({
|
||||
sftpClients: new Map([[sftpId, {
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
execCalls += 1;
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
}]]),
|
||||
});
|
||||
const sender = createSender();
|
||||
|
||||
const result = await transferBridge.sameHostCopyDirectory(
|
||||
{ sender },
|
||||
{
|
||||
transferId: "same-host-directory-auto-gb18030-copy",
|
||||
sftpId,
|
||||
sourcePath: "/source",
|
||||
targetPath: "/\u76ee\u6807",
|
||||
encoding: "auto",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(execCalls, 0);
|
||||
assert.equal(result.success, false);
|
||||
assert.deepEqual(sender.sent, []);
|
||||
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
|
||||
for (const targetState of ["existing", "missing", "stat-error"]) {
|
||||
test(`same-host directory copy ${targetState} target uses cp only for a fresh destination`, async (t) => {
|
||||
const { sftpTransferSessionLeaseStore } = require("./sftpTransferSessionLease.cjs");
|
||||
sftpTransferSessionLeaseStore.resetForTests();
|
||||
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
||||
const sftpId = `same-host-directory-${targetState}`;
|
||||
const commands = [];
|
||||
const mkdirs = [];
|
||||
let targetExists = targetState === "existing";
|
||||
const client = {
|
||||
async lstat(remotePath) {
|
||||
assert.equal(String(remotePath), "/target");
|
||||
assert.deepEqual(mkdirs, [], "check the destination before ensuring it");
|
||||
if (targetState === "stat-error") {
|
||||
throw Object.assign(new Error("Permission denied"), { code: 3 });
|
||||
}
|
||||
if (!targetExists) throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
return { isDirectory: true };
|
||||
},
|
||||
sftp: createFastSftp({
|
||||
stat(_remotePath, callback) {
|
||||
if (!targetExists) {
|
||||
callback(Object.assign(new Error("ENOENT"), { code: 2 }));
|
||||
return;
|
||||
}
|
||||
callback(null, { isDirectory: () => true });
|
||||
},
|
||||
mkdir(remotePath, callback) {
|
||||
mkdirs.push(String(remotePath));
|
||||
targetExists = true;
|
||||
callback(null);
|
||||
},
|
||||
}),
|
||||
client: {
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
assert.equal(targetExists, true, "create the fresh destination before cp");
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
};
|
||||
const sftpClients = new Map([[sftpId, client]]);
|
||||
transferBridge.init({ sftpClients });
|
||||
sftpBridge.init({ sftpClients });
|
||||
const sender = createSender();
|
||||
const result = await transferBridge.sameHostCopyDirectory({ sender }, {
|
||||
transferId: `${sftpId}-copy`, sftpId,
|
||||
sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (targetState === "missing") {
|
||||
assert.deepEqual(result, { success: true });
|
||||
assert.deepEqual(commands, ["cp -ra '/source/.' '/target/'"]);
|
||||
assert.deepEqual(mkdirs, ["/target"]);
|
||||
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
|
||||
} else {
|
||||
assert.deepEqual(result, { success: false });
|
||||
assert.deepEqual(commands, []);
|
||||
assert.deepEqual(mkdirs, []);
|
||||
assert.deepEqual(sender.sent, []);
|
||||
}
|
||||
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("same-host directory copy cancellation aborts cp and releases its lease", async (t) => {
|
||||
const {
|
||||
sftpTransferSessionLeaseStore,
|
||||
@@ -13259,6 +13375,7 @@ test("same-host directory copy cancellation aborts cp and releases its lease", a
|
||||
stream.end = () => {};
|
||||
stream.destroy = () => {};
|
||||
const client = {
|
||||
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
@@ -13342,6 +13459,7 @@ test("same-host file cp cancellation releases the shared session lease", async (
|
||||
stream.end = () => {};
|
||||
stream.destroy = () => {};
|
||||
const client = {
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
@@ -15139,3 +15257,209 @@ test("pending channel-open skips the fastPut reopen and uses the shared channel"
|
||||
assert.ok(sharedWrites >= 1);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
});
|
||||
|
||||
test("same-host file cp restores existing destination mode", async () => {
|
||||
const commands = [];
|
||||
const chmods = [];
|
||||
let targetMode = 0o100600;
|
||||
let downloads = 0;
|
||||
let uploads = 0;
|
||||
const sourceSftp = createFastSftp({
|
||||
open() { downloads++; throw new Error("unexpected download"); },
|
||||
});
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
stat: async () => ({ size: 64, mode: 0o100644 }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
targetMode = 0o100644;
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetClient = {
|
||||
sftp: createFastSftp({
|
||||
open() { uploads++; throw new Error("unexpected upload"); },
|
||||
}),
|
||||
stat: async () => ({ size: 64, mode: targetMode }),
|
||||
chmod: async (remotePath, mode) => {
|
||||
chmods.push([String(remotePath), mode]);
|
||||
targetMode = mode;
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: `same-host-cp-mode-${crypto.randomUUID()}`,
|
||||
sourcePath: "/source/payload.bin", targetPath: "/target/payload.bin",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: 64,
|
||||
sameHost: true, resumable: false,
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
|
||||
assert.deepEqual(chmods, [["/target/payload.bin", 0o600]]);
|
||||
assert.equal(targetMode, 0o600);
|
||||
assert.equal(downloads, 0);
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
|
||||
for (const sourceState of ["symlink", "lstat-error"]) {
|
||||
test(`same-host file cp skips a source ${sourceState} and downloads/uploads target bytes`, async (t) => {
|
||||
const transferId = `same-host-cp-fallback-${crypto.randomUUID()}`;
|
||||
const sourcePath = "/opt/app/config";
|
||||
const targetPath = "/tmp/app/config";
|
||||
const payload = Buffer.from("contents of ../shared/config");
|
||||
const commands = [];
|
||||
let reads = 0;
|
||||
let remote = Buffer.alloc(0);
|
||||
let promoted = false;
|
||||
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
|
||||
const originalRead = sourceSftp.read;
|
||||
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
async lstat(remotePath) {
|
||||
assert.equal(String(remotePath), sourcePath);
|
||||
if (sourceState === "lstat-error") throw new Error("lstat unsupported");
|
||||
return { isSymbolicLink: true, size: "../shared/config".length };
|
||||
},
|
||||
stat: async () => ({ size: payload.length }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
callback(new Error("unexpected exec"));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetSftp = createFastSftp({
|
||||
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
|
||||
write(_handle, buffer, offset, length, position, callback) {
|
||||
if (remote.length < position + length) {
|
||||
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
|
||||
}
|
||||
buffer.copy(remote, position, offset, offset + length);
|
||||
callback(null);
|
||||
},
|
||||
close(_handle, callback) { callback(null); },
|
||||
});
|
||||
const targetClient = {
|
||||
sftp: targetSftp,
|
||||
client: { sftp: (callback) => callback(null, targetSftp) },
|
||||
async stat(remotePath) {
|
||||
if (String(remotePath) === targetPath && remote.length === 0) {
|
||||
throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
}
|
||||
return { size: remote.length };
|
||||
},
|
||||
rename: async () => { promoted = true; },
|
||||
delete: async () => {},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "config");
|
||||
t.after(async () => fs.promises.rm(localStage, { force: true }));
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId, sourcePath, targetPath,
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: payload.length, sameHost: true, resumable: false,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, [], "source symlinks and lstat failures must bypass cp -a");
|
||||
assert.ok(reads > 0, "fallback must download the followed source contents");
|
||||
assert.deepEqual(remote, payload, "fallback must upload target bytes rather than the link node");
|
||||
assert.equal(promoted, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const cpResult of [0, 127, 1, "exec-error"]) {
|
||||
test(`fresh same-host file cp result ${cpResult} preserves copy/fallback behavior (#4207)`, async (t) => {
|
||||
const transferId = `same-host-cp-${crypto.randomUUID()}`;
|
||||
const payload = Buffer.from("same-host ordinary SFTP copy");
|
||||
const commands = [];
|
||||
const chmods = [];
|
||||
let reads = 0;
|
||||
let remote = Buffer.alloc(0);
|
||||
let promoted = false;
|
||||
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
|
||||
const originalRead = sourceSftp.read;
|
||||
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
stat: async () => ({ size: payload.length }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
if (cpResult === "exec-error") { callback(new Error("exec denied")); return; }
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", cpResult));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetSftp = createFastSftp({
|
||||
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
|
||||
write(_handle, buffer, offset, length, position, callback) {
|
||||
if (remote.length < position + length) {
|
||||
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
|
||||
}
|
||||
buffer.copy(remote, position, offset, offset + length);
|
||||
callback(null);
|
||||
},
|
||||
close(_handle, callback) { callback(null); },
|
||||
});
|
||||
const targetClient = {
|
||||
sftp: targetSftp,
|
||||
client: { sftp: (callback) => callback(null, targetSftp) },
|
||||
stat: async (remotePath) => {
|
||||
if (String(remotePath) === "/target/payload.bin" && remote.length === 0) {
|
||||
throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
}
|
||||
return { size: remote.length };
|
||||
},
|
||||
chmod: async (...args) => { chmods.push(args); },
|
||||
rename: async () => { promoted = true; },
|
||||
delete: async () => {},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
|
||||
t.after(async () => fs.promises.rm(localStage, { force: true }));
|
||||
const sender = createSender();
|
||||
const result = await transferBridge.startTransfer({ sender }, {
|
||||
transferId,
|
||||
sourcePath: "/source/payload.bin",
|
||||
targetPath: "/target/payload.bin",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: payload.length,
|
||||
sameHost: true, resumable: false,
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
|
||||
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
|
||||
if (cpResult === 0) {
|
||||
assert.deepEqual(chmods, [], "fresh cp must not restore destination permissions");
|
||||
assert.equal(reads, 0, "successful cp must not download the source");
|
||||
assert.equal(remote.length, 0, "successful cp must not upload through SFTP");
|
||||
assert.equal(fs.existsSync(localStage), false);
|
||||
} else {
|
||||
assert.ok(reads > 0, "cp failure must fall back to SFTP download");
|
||||
assert.deepEqual(remote, payload, "fallback must upload the downloaded contents");
|
||||
assert.equal(promoted, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user