mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
932b93eeeb | ||
|
|
9dc84e8d89 | ||
|
|
7e8335b64f | ||
|
|
4b20f1b2a4 | ||
|
|
1463a46ad2 | ||
|
|
08c7fe2270 | ||
|
|
f8dce631d3 | ||
|
|
d1d5771f6a | ||
|
|
2917d03e25 | ||
|
|
5073791f5f | ||
|
|
184ba582b7 | ||
|
|
97d2b61192 | ||
|
|
928371049b | ||
|
|
ab6b272b67 |
No files matched your search
@@ -0,0 +1,82 @@
|
||||
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, TransferTask } from "../../../domain/models";
|
||||
import { createSftpDirectoryTraversalBudget } from "../../../domain/sftpDirectoryCheckpoint";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
|
||||
const modes = [
|
||||
{ name: "ordinary local upload", sourceLocal: true, targetLocal: false, sameConnection: false, bounded: false },
|
||||
{ name: "cross-pane local copy", sourceLocal: true, targetLocal: true, sameConnection: false, bounded: false },
|
||||
{ name: "same-pane local copy", sourceLocal: true, targetLocal: true, sameConnection: true, bounded: true },
|
||||
{ name: "remote download", sourceLocal: false, targetLocal: true, sameConnection: false, bounded: true },
|
||||
] as const;
|
||||
|
||||
for (const mode of modes) {
|
||||
for (const limit of ["entries", "directories"] as const) {
|
||||
test(`${mode.name} ${mode.bounded ? "enforces" : "does not inherit"} the ${limit} budget`, async () => {
|
||||
const previousGet = netcattyBridge.get;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpDirectoryTransferOps>;
|
||||
const listed: string[] = [];
|
||||
const root: TransferTask = {
|
||||
id: crypto.randomUUID(), fileName: "source", sourcePath: "/source", targetPath: "/target",
|
||||
sourceConnectionId: "source", targetConnectionId: mode.sameConnection ? "source" : "target",
|
||||
direction: mode.sourceLocal ? "upload" : "download", status: "transferring",
|
||||
totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: true,
|
||||
};
|
||||
const transfersRef = { current: [root] };
|
||||
const list = async (value: string): Promise<SftpFileEntry[]> => {
|
||||
listed.push(value);
|
||||
return value === "/source" ? ["one", "two"].map(name => ({
|
||||
name, type: "directory", size: 0, sizeFormatted: "0 B", lastModified: 0, lastModifiedFormatted: "",
|
||||
})) : [];
|
||||
};
|
||||
netcattyBridge.get = () => ({
|
||||
mkdirLocal: async () => undefined,
|
||||
mkdirSftp: async () => undefined,
|
||||
realpathSftp: async (_id: string, value: string) => value,
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() {
|
||||
ops = useSftpDirectoryTransferOps({
|
||||
ownerId: root.id, cancelledTasksRef: { current: new Set() }, pausedTasksRef: { current: new Set() },
|
||||
waitUntilTransferResumed: async () => undefined, activeChildIdsRef: { current: new Map() },
|
||||
transfersRef,
|
||||
setTransfers: update => { transfersRef.current = typeof update === "function" ? update(transfersRef.current) : update; },
|
||||
listLocalFiles: list, listRemoteFiles: (_id, value) => list(value),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
// Three empty directories exercise real recursion without large fixtures or file streams.
|
||||
const budget = createSftpDirectoryTraversalBudget({
|
||||
maxEntries: limit === "entries" ? 1 : 10,
|
||||
maxDirectories: limit === "directories" ? 1 : 10,
|
||||
});
|
||||
const run = ops.transferDirectory(root, mode.sourceLocal ? null : "remote-source",
|
||||
mode.targetLocal ? null : "remote-target", mode.sourceLocal, mode.targetLocal,
|
||||
"auto", "auto", root.id, false, 0, false, undefined, budget);
|
||||
if (mode.bounded) {
|
||||
await assert.rejects(run, limit === "entries" ? /entry limit exceeded/ : /directory limit exceeded/);
|
||||
assert.deepEqual(listed, ["/source"]);
|
||||
} else {
|
||||
assert.equal(await run, 0);
|
||||
assert.deepEqual(listed, ["/source", "/source/one", "/source/two"]);
|
||||
assert.equal(budget.visitedDirectories, 0);
|
||||
assert.equal(budget.visitedEntries, 0);
|
||||
}
|
||||
assert.equal(budget.activeCanonicalDirectories.size, 0);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
netcattyBridge.get = previousGet;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { createEmptyPane } from "./types";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
for (const action of ["merge", "replace", "duplicate"] as const) {
|
||||
test(`same-directory ${action} preserves source links through the real transfer hook`, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".same-directory-copy-"));
|
||||
const ownerId = `self-copy-${action}-${crypto.randomUUID()}`;
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let writes = 0;
|
||||
let listings = 0;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpTransfers>;
|
||||
const names = ["one", "two"];
|
||||
for (const name of names) {
|
||||
await fs.mkdir(path.join(dir, name));
|
||||
await fs.writeFile(path.join(dir, name, "file"), "original data");
|
||||
await fs.symlink("file", path.join(dir, name, "link"));
|
||||
}
|
||||
const pane = createEmptyPane("self-copy-pane");
|
||||
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
|
||||
const statPath = async (value: string) => {
|
||||
const stat = await fs.lstat(value);
|
||||
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
|
||||
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
|
||||
mkdirLocal: async (value: string) => { writes++; await fs.mkdir(value, { recursive: true }); },
|
||||
deleteLocalFile: async (value: string) => { writes++; await fs.rm(value, { recursive: true, force: true }); },
|
||||
renameLocalFile: async (source: string, target: string) => { writes++; await fs.rename(source, target); },
|
||||
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
|
||||
writes++;
|
||||
await fs.copyFile(options.sourcePath, options.targetPath);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
||||
listRemoteFiles: async () => [],
|
||||
listLocalFiles: async (value: string) => {
|
||||
listings++;
|
||||
return Promise.all((await fs.readdir(value)).map(async name => {
|
||||
const stat = await fs.lstat(path.join(value, name));
|
||||
return { name, type: stat.isSymbolicLink() ? "symlink" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
|
||||
}));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => { await ops.startTransfer(names.map(name => ({ name, isDirectory: true })), "left", "left"); });
|
||||
assert.equal(ops.conflicts.length, 2, JSON.stringify(sftpTransferCenterStore.getOwnerTasks(ownerId)));
|
||||
const firstId = ops.conflicts[0].transferId;
|
||||
// Apply to all exercises shared batch resolution rather than just one row.
|
||||
await act(async () => { await ops.resolveConflict(firstId, action, true); });
|
||||
await act(async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline) {
|
||||
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
});
|
||||
for (const name of names) {
|
||||
assert.equal(await fs.readlink(path.join(dir, name, "link")), "file");
|
||||
assert.equal(await fs.readFile(path.join(dir, name, "file"), "utf8"), "original data");
|
||||
}
|
||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
assert.equal(roots.length, 2);
|
||||
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
|
||||
if (action === "duplicate") {
|
||||
assert.ok(writes > 0);
|
||||
assert.ok(listings > 0);
|
||||
for (const name of names) assert.equal(await fs.readFile(path.join(dir, `${name} (copy)`, "file"), "utf8"), "original data");
|
||||
} else {
|
||||
assert.equal(writes, 0, "self merge/replace must never write or unlink anything");
|
||||
assert.equal(listings, 0, "self merge/replace must not walk the source tree");
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
getWindowsUncRoot,
|
||||
isSameSftpPath,
|
||||
isSftpDescendantPath,
|
||||
isWindowsPath,
|
||||
joinPath,
|
||||
} from "./utils";
|
||||
|
||||
export type SamePanePasteAction = "allow" | "block-same-folder" | "block-into-source";
|
||||
|
||||
export interface SamePanePasteFile {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/** Filesystem identity of a directory (stat dev/ino). */
|
||||
export interface SamePanePasteIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexically resolve "." / ".." segments and repeated separators so equivalent
|
||||
* spellings of the same directory (e.g. /home/user/., /home//user) compare
|
||||
* equal in the same-pane paste guards. Purely string-level: it never touches
|
||||
* the server, and it is only used to make the guards stricter, never to build
|
||||
* transfer paths.
|
||||
*/
|
||||
const canonicalizeSftpPath = (path: string): string => {
|
||||
const isWindows = isWindowsPath(path);
|
||||
const separator = isWindows ? "\\" : "/";
|
||||
const unified = isWindows ? path.replace(/\//g, "\\") : path;
|
||||
|
||||
let root = "";
|
||||
let rest = unified;
|
||||
if (isWindows) {
|
||||
const uncRoot = getWindowsUncRoot(unified, { acceptForwardSlashUnc: true });
|
||||
const driveRoot = unified.match(/^[A-Za-z]:\\/)?.[0];
|
||||
if (uncRoot) {
|
||||
root = uncRoot;
|
||||
rest = unified.slice(uncRoot.length);
|
||||
} else if (driveRoot) {
|
||||
root = driveRoot;
|
||||
rest = unified.slice(driveRoot.length);
|
||||
}
|
||||
} else if (unified.startsWith("/")) {
|
||||
// Collapse a leading "//" to "/" for comparisons: on ordinary POSIX
|
||||
// filesystems "//home/user" names the same directory as "/home/user", and
|
||||
// treating it as a distinct root would let a same-pane cut reach the
|
||||
// replace/delete flow. This helper never builds transfer paths, so the
|
||||
// guards stay strict either way.
|
||||
root = "/";
|
||||
rest = unified.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const segment of rest.split(separator)) {
|
||||
if (!segment || segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(segment);
|
||||
}
|
||||
|
||||
// UNC roots come back without a trailing separator, so re-insert one before
|
||||
// joining the segments; without it \\server\share\docs would collapse to
|
||||
// \\server\sharedocs and collide with an unrelated share's path.
|
||||
if (root) {
|
||||
return root.endsWith(separator)
|
||||
? root + parts.join(separator)
|
||||
: root + separator + parts.join(separator);
|
||||
}
|
||||
return parts.join(separator) || ".";
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether an internal SFTP paste that targets the same connection as the
|
||||
* clipboard source can proceed. Copying files into their own source folder is
|
||||
* allowed (the conflict dialog handles same-name collisions), but pasting a
|
||||
* clipboard directory into itself or one of its descendants is blocked for both
|
||||
* copy and cut: the transfer creates the destination before listing the source,
|
||||
* so the fresh copy would be rediscovered and nested until the traversal limit.
|
||||
* For cuts, pasting into the clipboard's source folder is also blocked because
|
||||
* the post-move source delete would remove the freshly moved items. Note that
|
||||
* `sourcePath` is the folder containing every clipboard item, so the per-item
|
||||
* checks must join it with each selected entry's name.
|
||||
*
|
||||
* Lexical canonicalization alone cannot see through symlinks: when the
|
||||
* destination reaches the copied directory through an alias (e.g.
|
||||
* `/a/link -> /a/docs/sub`), a purely string-level comparison would allow the
|
||||
* paste and the recursive transfer would rediscover its own output. When
|
||||
* `resolvePath` is provided (SFTP realpath for remote connections, the local
|
||||
* realpath bridge for local panes), both sides are resolved through the
|
||||
* filesystem before comparing. If that resolution fails, the guard fails
|
||||
* closed and blocks the paste rather than risking a runaway transfer.
|
||||
*
|
||||
* realpath also cannot see through bind mounts: two mount paths for the same
|
||||
* directory resolve to different strings. When `statIdentity` is provided
|
||||
* (local panes only — remote SFTP stats carry no dev/ino), both sides' stat
|
||||
* identities are compared as a final check; equal identities block the paste.
|
||||
* The check only ever blocks on a positive identity match — an unavailable
|
||||
* stat falls through to the symlink guards above rather than blocking
|
||||
* unrelated pastes on transient stat failures.
|
||||
*/
|
||||
export const resolveSamePanePasteAction = async (params: {
|
||||
operation: "copy" | "cut";
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
files: readonly SamePanePasteFile[];
|
||||
/** Optional filesystem resolver (e.g. realpath). Throws → unresolvable. */
|
||||
resolvePath?: (path: string) => Promise<string>;
|
||||
/** Optional stat identity provider (dev/ino). Returns null → unknown. */
|
||||
statIdentity?: (path: string) => Promise<SamePanePasteIdentity | null>;
|
||||
}): Promise<SamePanePasteAction> => {
|
||||
const { resolvePath, statIdentity } = params;
|
||||
|
||||
// Canonicalize first so equivalent spellings of the same directory (dot
|
||||
// segments, repeated separators) are still caught by the guards below, then
|
||||
// resolve through the filesystem when a resolver is available so symlink
|
||||
// aliases compare equal to their real targets. Returns null when resolution
|
||||
// fails, which callers treat as fail-closed.
|
||||
const canonicalizeForComparison = async (path: string): Promise<string | null> => {
|
||||
const lexical = canonicalizeSftpPath(path);
|
||||
if (!resolvePath) return lexical;
|
||||
try {
|
||||
return canonicalizeSftpPath(await resolvePath(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [targetPath, sourcePath] = await Promise.all([
|
||||
canonicalizeForComparison(params.targetPath),
|
||||
canonicalizeForComparison(params.sourcePath),
|
||||
]);
|
||||
// A files-only copy has no nesting hazard, so an unresolvable path only
|
||||
// blocks pastes that could actually recurse (directory items, or a cut).
|
||||
const failClosed = !targetPath || !sourcePath;
|
||||
if (failClosed && (params.operation === "cut" || params.files.some((file) => file.isDirectory))) {
|
||||
return "block-into-source";
|
||||
}
|
||||
if (failClosed) return "allow";
|
||||
|
||||
if (params.operation === "cut" && isSameSftpPath(targetPath, sourcePath)) {
|
||||
return "block-same-folder";
|
||||
}
|
||||
|
||||
// Bind-mount alias detection: realpath above maps each mount path to itself,
|
||||
// so a cut (whose post-transfer source delete would destroy the only copy)
|
||||
// must additionally compare filesystem identities. A positive match means
|
||||
// both paths name the same directory. dev/ino are only trusted when at least
|
||||
// one side is non-zero — Windows stats report meaningless 0 identities, and
|
||||
// the local bridge omits them there.
|
||||
const identityOf = async (path: string): Promise<SamePanePasteIdentity | null> => {
|
||||
if (!statIdentity) return null;
|
||||
try {
|
||||
const identity = await statIdentity(path);
|
||||
if (!identity || identity.dev === undefined || identity.ino === undefined) return null;
|
||||
if (identity.dev === 0 && identity.ino === 0) return null;
|
||||
return identity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const sameIdentity = (a: SamePanePasteIdentity, b: SamePanePasteIdentity): boolean =>
|
||||
a.dev === b.dev && a.ino === b.ino;
|
||||
|
||||
if (params.operation === "cut") {
|
||||
const [targetIdentity, sourceIdentity] = await Promise.all([
|
||||
identityOf(params.targetPath),
|
||||
identityOf(params.sourcePath),
|
||||
]);
|
||||
if (targetIdentity && sourceIdentity && sameIdentity(targetIdentity, sourceIdentity)) {
|
||||
return "block-same-folder";
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of params.files) {
|
||||
if (!file.isDirectory) continue;
|
||||
const itemPath = await canonicalizeForComparison(joinPath(params.sourcePath, file.name));
|
||||
if (!itemPath) return "block-into-source";
|
||||
if (
|
||||
isSameSftpPath(targetPath, itemPath)
|
||||
|| isSftpDescendantPath(targetPath, itemPath)
|
||||
) {
|
||||
return "block-into-source";
|
||||
}
|
||||
// A bind-mount alias of the directory item itself also names the paste
|
||||
// destination, for both copy (recursive rediscovery) and cut (source
|
||||
// delete would remove the freshly pasted entry).
|
||||
if (statIdentity) {
|
||||
const [targetIdentity, itemIdentity] = await Promise.all([
|
||||
identityOf(params.targetPath),
|
||||
identityOf(joinPath(params.sourcePath, file.name)),
|
||||
]);
|
||||
if (targetIdentity && itemIdentity && sameIdentity(targetIdentity, itemIdentity)) {
|
||||
return "block-into-source";
|
||||
}
|
||||
}
|
||||
}
|
||||
return "allow";
|
||||
};
|
||||
|
||||
/** Application boundary for filesystem checks used by every internal paste entry. */
|
||||
export const resolveSameConnectionPasteAction = async (params: {
|
||||
operation: "copy" | "cut";
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
files: readonly SamePanePasteFile[];
|
||||
isLocal: boolean;
|
||||
sftpId: string | null;
|
||||
}): Promise<SamePanePasteAction> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let resolvePath: ((path: string) => Promise<string>) | undefined;
|
||||
let statIdentity: ((path: string) => Promise<SamePanePasteIdentity | null>) | undefined;
|
||||
if (params.isLocal) {
|
||||
if (bridge?.realpathLocal) resolvePath = (path) => bridge.realpathLocal!(path);
|
||||
if (bridge?.statLocal) {
|
||||
statIdentity = async (path) => {
|
||||
const stat = await bridge.statLocal!(path);
|
||||
if (stat.dev === undefined || stat.ino === undefined) return null;
|
||||
return { dev: stat.dev, ino: stat.ino };
|
||||
};
|
||||
}
|
||||
} else if (params.sftpId && bridge?.realpathSftp) {
|
||||
const sftpId = params.sftpId;
|
||||
resolvePath = (path) => bridge.realpathSftp!(sftpId, path);
|
||||
}
|
||||
return resolveSamePanePasteAction({ ...params, resolvePath, statIdentity });
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { SftpPane } from "./types";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
|
||||
test("bind-mounted parents cannot cause replacement to delete the source link", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpTransferConflictOps>;
|
||||
let deletes = 0;
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
netcattyBridge.get = originalGet;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
});
|
||||
netcattyBridge.get = () => ({
|
||||
// Bind mounts retain different realpaths but expose the same parent identity.
|
||||
realpathLocal: async (value: string) => value,
|
||||
statLocal: async (value: string) => ({
|
||||
type: "directory", size: 0, lastModified: 0, dev: 42,
|
||||
ino: value === "/a" || value === "/mnt/alias" ? 7 : 8,
|
||||
}),
|
||||
deleteLocalFile: async () => { deletes++; },
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() { ops = useSftpTransferConflictOps(); return null; }
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const pane = { connection: { id: "local-pane", isLocal: true } } as SftpPane;
|
||||
const task = {
|
||||
sourceConnectionId: "local-pane", targetConnectionId: "local-pane",
|
||||
sourcePath: "/a/link", targetPath: "/mnt/alias/link", fileName: "link", isDirectory: false,
|
||||
} as TransferTask;
|
||||
await assert.rejects(ops.deleteTargetPath(task, pane, null, "auto", "symlink"), /Choose Duplicate or Skip/);
|
||||
assert.equal(deletes, 0);
|
||||
assert.equal(await ops.isSameSourceEntry(task, pane, null, "auto"), true);
|
||||
// Different entries and genuinely different parents must remain replaceable.
|
||||
for (const targetPath of ["/mnt/alias/another-link", "/other/link"]) {
|
||||
const other = { ...task, targetPath };
|
||||
assert.equal(await ops.isSameSourceEntry(other, pane, null, "auto"), false);
|
||||
await ops.deleteTargetPath(other, pane, null, "auto", "symlink");
|
||||
}
|
||||
assert.equal(deletes, 2);
|
||||
});
|
||||
|
||||
test("same-pane link replacement preserves the source, including aliased parent paths", async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".pr3318-links-"));
|
||||
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalGet = netcattyBridge.get;
|
||||
let renderer: ReactTestRenderer;
|
||||
let ops!: ReturnType<typeof useSftpTransferConflictOps>;
|
||||
let deletes = 0;
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
netcattyBridge.get = originalGet;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
await fs.mkdir(path.join(dir, "source"));
|
||||
await fs.mkdir(path.join(dir, "other"));
|
||||
await fs.writeFile(path.join(dir, "source", "file"), "keep original contents");
|
||||
await fs.symlink("file", path.join(dir, "source", "link"));
|
||||
await fs.symlink("source", path.join(dir, "alias"));
|
||||
netcattyBridge.get = () => ({
|
||||
realpathLocal: fs.realpath,
|
||||
lstatLocal: async (value: string) => {
|
||||
const stat = await fs.lstat(value);
|
||||
return { type: stat.isSymbolicLink() ? "symlink" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
},
|
||||
deleteLocalFile: async (value: string) => { deletes++; await fs.unlink(value); },
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() { ops = useSftpTransferConflictOps(); return null; }
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const pane = { connection: { id: "local-pane", isLocal: true } } as SftpPane;
|
||||
const task = {
|
||||
sourceConnectionId: "local-pane", targetConnectionId: "local-pane",
|
||||
sourcePath: path.join(dir, "source", "link"),
|
||||
targetPath: path.join(dir, "source", "link"),
|
||||
fileName: "link", isDirectory: false,
|
||||
} as TransferTask;
|
||||
for (const parent of ["source", "alias", "source/."]) {
|
||||
await assert.rejects(ops.deleteTargetPath(
|
||||
{ ...task, targetPath: `${dir}/${parent}/link` }, pane, null, "auto", "symlink",
|
||||
), /Choose Duplicate or Skip/);
|
||||
assert.equal(await fs.readlink(task.sourcePath), "file");
|
||||
assert.equal(await fs.readFile(task.sourcePath, "utf8"), "keep original contents");
|
||||
}
|
||||
assert.equal(deletes, 0);
|
||||
const duplicate = await ops.getDuplicateTarget(task, pane, null, "auto");
|
||||
assert.equal(duplicate.fileName, "link (copy)");
|
||||
await fs.symlink("../source/file", path.join(dir, "other", "link"));
|
||||
await ops.deleteTargetPath({ ...task, targetPath: path.join(dir, "other", "link") }, pane, null, "auto", "symlink");
|
||||
assert.equal(deletes, 1);
|
||||
assert.equal(await fs.readlink(task.sourcePath), "file");
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models"
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
import { getFileName, getParentPath, isSameSftpPath, joinPath } from "./utils";
|
||||
|
||||
export function useSftpTransferConflictOps() {
|
||||
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
|
||||
@@ -84,6 +84,42 @@ export function useSftpTransferConflictOps() {
|
||||
[splitNameForDuplicate, statTargetPath],
|
||||
);
|
||||
|
||||
const isSameSourceEntry = useCallback(async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
): Promise<boolean> => {
|
||||
if (!targetPane.connection || task.sourceConnectionId !== task.targetConnectionId) return false;
|
||||
if (isSameSftpPath(task.sourcePath, task.targetPath)) return true;
|
||||
const bridge = netcattyBridge.get();
|
||||
const resolve = targetPane.connection.isLocal
|
||||
? bridge?.realpathLocal
|
||||
: targetSftpId && bridge?.realpathSftp
|
||||
? (value: string) => bridge.realpathSftp!(targetSftpId, value, targetEncoding)
|
||||
: undefined;
|
||||
const [sourceParent, targetParent] = await Promise.all(
|
||||
[getParentPath(task.sourcePath), getParentPath(task.targetPath)].map(
|
||||
(value) => resolve ? resolve(value) : Promise.resolve(value),
|
||||
),
|
||||
);
|
||||
if (isSameSftpPath(
|
||||
joinPath(sourceParent, getFileName(task.sourcePath)),
|
||||
joinPath(targetParent, getFileName(task.targetPath)),
|
||||
)) return true;
|
||||
// Bind mounts keep distinct realpaths while naming the same directory.
|
||||
// Compare parents so a symlink is identified as an entry, not its referent.
|
||||
if (targetPane.connection.isLocal && bridge?.statLocal
|
||||
&& getFileName(task.sourcePath) === getFileName(task.targetPath)) {
|
||||
const [sourceStat, targetStat] = await Promise.all([
|
||||
bridge.statLocal(sourceParent), bridge.statLocal(targetParent),
|
||||
]);
|
||||
return sourceStat.dev !== undefined && sourceStat.ino !== undefined
|
||||
&& sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const deleteTargetPath = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
@@ -93,6 +129,11 @@ export function useSftpTransferConflictOps() {
|
||||
expectedType?: "file" | "directory" | "symlink",
|
||||
) => {
|
||||
if (!targetPane.connection) return;
|
||||
// Replace unlinks symlinks before copying. Never unlink the source entry
|
||||
// when a clipboard copy is pasted back into its own directory.
|
||||
if (expectedType === "symlink" && await isSameSourceEntry(task, targetPane, targetSftpId, targetEncoding)) {
|
||||
throw new Error("Cannot replace the source link with itself. Choose Duplicate or Skip.");
|
||||
}
|
||||
if (targetPane.connection.isLocal) {
|
||||
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
|
||||
if (!deleteLocalFile) throw new Error("Local delete unavailable");
|
||||
@@ -104,9 +145,9 @@ export function useSftpTransferConflictOps() {
|
||||
if (!deleteSftp) throw new Error("SFTP delete unavailable");
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
|
||||
},
|
||||
[],
|
||||
[isSameSourceEntry],
|
||||
);
|
||||
|
||||
|
||||
return { statTargetPath, getDuplicateTarget, deleteTargetPath };
|
||||
return { statTargetPath, getDuplicateTarget, deleteTargetPath, isSameSourceEntry };
|
||||
}
|
||||
@@ -558,11 +558,20 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
const listingGate = progress.listingGate;
|
||||
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
|
||||
// Only the newly supported same-pane local copy needs this fallback.
|
||||
// Preserve existing large local uploads and cross-pane copies.
|
||||
const boundLocalTraversal = sourceIsLocal && targetIsLocal
|
||||
&& task.sourceConnectionId === task.targetConnectionId;
|
||||
let claimedCanonicalPath: string | null = null;
|
||||
let regularFiles: SftpFileEntry[] = [];
|
||||
// Keep the current remote ancestor active through child discovery.
|
||||
try {
|
||||
if (!sourceIsLocal && sourceSftpId) {
|
||||
// Bound same-pane local copies that rediscover their own output through
|
||||
// filesystem aliases not detected by the paste guard.
|
||||
if (boundLocalTraversal) {
|
||||
claimedCanonicalPath = claimSftpDirectoryVisit(traversal, task.sourcePath);
|
||||
if (!claimedCanonicalPath) return totalErrors;
|
||||
} else if (!sourceIsLocal && sourceSftpId) {
|
||||
const bridge = netcattyBridge.get();
|
||||
const canonicalPath = await bridge?.realpathSftp?.(sourceSftpId, task.sourcePath, sourceEncoding)
|
||||
.catch(() => task.sourcePath) ?? task.sourcePath;
|
||||
@@ -611,7 +620,7 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Filter both "." and ".." — some SFTP servers include "." in readdir
|
||||
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
|
||||
if (!sourceIsLocal) accountSftpDirectoryEntries(traversal, filtered.length);
|
||||
if (!sourceIsLocal || boundLocalTraversal) accountSftpDirectoryEntries(traversal, filtered.length);
|
||||
// Separate directories from files.
|
||||
// Symlink directories are only followed when followSymlinks is true
|
||||
// (downloadToLocal). Uploads/copies treat symlinks as regular entries
|
||||
|
||||
@@ -442,7 +442,7 @@ export const useSftpTransfers = ({
|
||||
cleanupTaskArtifacts,
|
||||
});
|
||||
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath, isSameSourceEntry } = useSftpTransferConflictOps();
|
||||
|
||||
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
|
||||
ownerId,
|
||||
@@ -840,9 +840,14 @@ export const useSftpTransfers = ({
|
||||
|
||||
// Try same-host directory optimization first; falls back to recursive transfer
|
||||
// if remote cp is unavailable (e.g. Windows SSH servers).
|
||||
let dirHandledBySameHost = false;
|
||||
// Merge/Replace onto the source directory is already satisfied. Walking
|
||||
// it would rewrite its children (including replacing links with files).
|
||||
// Duplicate has a different target and continues through normal copying.
|
||||
let directoryHandled = task.isDirectory
|
||||
&& await isSameSourceEntry(task, targetPane, targetSftpId, targetEncoding);
|
||||
if (
|
||||
task.isDirectory
|
||||
&& !directoryHandled
|
||||
&& !task.replaceExistingTarget
|
||||
&& task.resumable === false
|
||||
&& sameHost
|
||||
@@ -862,10 +867,10 @@ export const useSftpTransfers = ({
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
dirHandledBySameHost = result.success;
|
||||
directoryHandled = result.success;
|
||||
}
|
||||
|
||||
if (task.isDirectory && !dirHandledBySameHost) {
|
||||
if (task.isDirectory && !directoryHandled) {
|
||||
// For directory transfers, parent task uses:
|
||||
// totalBytes = total file count (discovered async)
|
||||
// transferredBytes = completed file count (incremented by child completions)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
requireCopyToOtherPaneTarget,
|
||||
type SftpPaneSide,
|
||||
} from "./copyToOtherPane";
|
||||
import { resolveSamePanePasteAction } from "../../application/state/sftp/samePanePaste";
|
||||
|
||||
test("copy to other pane is unavailable when the destination pane is missing", () => {
|
||||
assert.equal(canCopyToOtherPane({ getActivePane: () => null }, "right"), false);
|
||||
@@ -62,3 +63,414 @@ test("copy to other pane reports why it cannot start instead of silently returni
|
||||
);
|
||||
assert.equal(unavailableCount, 1);
|
||||
});
|
||||
|
||||
test("same-pane copy of files into their own source folder is allowed", async () => {
|
||||
const files = [
|
||||
{ name: "report.txt", isDirectory: false },
|
||||
{ name: "notes.txt", isDirectory: false },
|
||||
];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/home/user", targetPath: "/home/user", files }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane copy of a directory into itself or a descendant is blocked", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs/sub", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs/sub/deep", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane copy of a directory into a sibling is allowed", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a/docs", targetPath: "/a/sub", files }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a/docs", targetPath: "/a/docsx", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut into the source folder is blocked", async () => {
|
||||
const files = [{ name: "report.txt", isDirectory: false }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut of files into a child of the source folder is allowed", async () => {
|
||||
const files = [
|
||||
{ name: "report.txt", isDirectory: false },
|
||||
{ name: "photos", isDirectory: false },
|
||||
];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut of a directory into itself or a descendant is blocked", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub/deep", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut into a sibling folder is allowed", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/other", files }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user2", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane paste guard understands Windows paths", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:/Users/me",
|
||||
files,
|
||||
}),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\me\\docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\me\\docs",
|
||||
files: [{ name: "report.txt", isDirectory: false }],
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\other",
|
||||
files,
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards canonicalize equivalent path spellings", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/.", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home//user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/../user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/home/user",
|
||||
targetPath: "/home/./user/docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/userx", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards collapse a leading double slash for POSIX comparisons", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "//home/user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/home/user",
|
||||
targetPath: "//home/user/docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "//home/user", targetPath: "/home/user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/other", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards canonicalize equivalent Windows path spellings", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\me\\.",
|
||||
files,
|
||||
}),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\\\me",
|
||||
files,
|
||||
}),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\me\\docs\\sub\\..\\docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards keep the separator after UNC share roots", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
// Copying docs from \\server\share into \\server\sharedocs (a different
|
||||
// share) must not collide with the source item \\server\share\docs.
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "\\\\server\\share",
|
||||
targetPath: "\\\\server\\sharedocs",
|
||||
files,
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "\\\\server\\share",
|
||||
targetPath: "\\\\server\\share\\docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards resolve filesystem aliases before comparing", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
// /a/link -> /a/docs/sub: pasting /a/docs from /a/link must be blocked even
|
||||
// though the lexical paths look unrelated.
|
||||
const aliasedResolver = (path: string) => {
|
||||
const aliases: Record<string, string> = {
|
||||
"/a/link": "/a/docs/sub",
|
||||
"/a/docs": "/a/docs",
|
||||
"/a": "/a",
|
||||
};
|
||||
return Promise.resolve(aliases[path] ?? path);
|
||||
};
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/a/link",
|
||||
files,
|
||||
resolvePath: aliasedResolver,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/a/link",
|
||||
files,
|
||||
resolvePath: aliasedResolver,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards still allow unrelated real paths when resolving", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
const resolver = (path: string) => {
|
||||
const resolved: Record<string, string> = {
|
||||
"/a/docs": "/data/docs",
|
||||
"/a/other": "/data/other",
|
||||
"/a/docs/docs": "/data/docs/docs",
|
||||
};
|
||||
return Promise.resolve(resolved[path] ?? path);
|
||||
};
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a/docs",
|
||||
targetPath: "/a/other",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards fail closed when filesystem resolution fails", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a/docs",
|
||||
targetPath: "/a/other",
|
||||
files,
|
||||
resolvePath: () => Promise.reject(new Error("realpath unavailable")),
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("files-only copy still passes when path resolution is unavailable", async () => {
|
||||
const files = [{ name: "report.txt", isDirectory: false }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/b",
|
||||
files,
|
||||
resolvePath: () => Promise.reject(new Error("realpath unavailable")),
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut into a bind-mount alias of the source folder is blocked", async () => {
|
||||
const files = [{ name: "report.txt", isDirectory: false }];
|
||||
// realpath cannot see through bind mounts: /mnt/alias is bind-mounted to /a,
|
||||
// so both sides resolve to themselves and only stat identities match.
|
||||
const resolver = (path: string) => Promise.resolve(path);
|
||||
const statIdentity = (path: string) => Promise.resolve(
|
||||
path === "/a" || path === "/mnt/alias" ? { dev: 42, ino: 7 } : { dev: 42, ino: 9 },
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/alias",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity,
|
||||
}),
|
||||
"block-same-folder",
|
||||
);
|
||||
// Distinct filesystem identities must not block the cut.
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/other",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity: (path) => Promise.resolve(
|
||||
path === "/a" ? { dev: 42, ino: 7 } : { dev: 42, ino: 8 },
|
||||
),
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
// A provider that cannot stat falls through instead of blocking.
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/alias",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity: () => Promise.resolve(null),
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane paste into a bind-mount alias of a clipboard directory is blocked", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
// /mnt/docs-alias is bind-mounted to /a/docs: pasting /a/docs there names
|
||||
// the same directory, for copy (recursive rediscovery) and cut (the source
|
||||
// delete would remove the freshly pasted entry).
|
||||
const resolver = (path: string) => Promise.resolve(path);
|
||||
const statIdentity = (path: string) => Promise.resolve(
|
||||
path === "/a/docs" || path === "/mnt/docs-alias" ? { dev: 42, ino: 11 } : { dev: 42, ino: 3 },
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/docs-alias",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/docs-alias",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { keepOnlyPaneSelections } from "./selectionScope";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { UploadEndpointPin } from "../../../application/state/sftp/uploadTargetPin";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
|
||||
import { resolveSameConnectionPasteAction } from "../../../application/state/sftp/samePanePaste";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import { toast } from "../../ui/toast";
|
||||
@@ -351,14 +352,51 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const clipboard = sftpClipboardStore.get();
|
||||
if (!clipboard || clipboard.files.length === 0) return;
|
||||
|
||||
// Pin the destination this paste was validated against. Native pastes may
|
||||
// await clipboard reads before reaching here, and the user can navigate or
|
||||
// switch tabs meanwhile — startTransfer would otherwise resolve the
|
||||
// then-active pane and could land inside a directory this guard approved
|
||||
// as safe.
|
||||
const targetConnectionId = pane.connection!.id;
|
||||
const targetPath = pane.connection!.currentPath;
|
||||
|
||||
const isSameConnection = clipboard.sourceSide === focusedSide
|
||||
&& clipboard.sourceConnectionId === pane.connection!.id;
|
||||
if (isSameConnection) {
|
||||
toast.info("Paste within the same pane is not supported. Use copy to other pane instead.", "SFTP");
|
||||
return;
|
||||
const connection = pane.connection!;
|
||||
const pasteAction = await resolveSameConnectionPasteAction({
|
||||
operation: clipboard.operation,
|
||||
sourcePath: clipboard.sourcePath,
|
||||
targetPath,
|
||||
files: clipboard.files,
|
||||
isLocal: connection.isLocal,
|
||||
sftpId: connection.isLocal ? null : (sftp.getSftpIdForConnection(connection.id) ?? null),
|
||||
});
|
||||
if (pasteAction === "block-same-folder") {
|
||||
toast.info("The cut items are already in this folder.", "SFTP");
|
||||
return;
|
||||
}
|
||||
if (pasteAction === "block-into-source") {
|
||||
toast.info(
|
||||
clipboard.operation === "cut"
|
||||
? "A folder can't be moved into itself. Choose a different folder."
|
||||
: "A folder can't be copied into itself or one of its subfolders. Choose a different folder.",
|
||||
"SFTP",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Same-pane copy (and cut into a different folder) falls through to the
|
||||
// shared transfer path below.
|
||||
}
|
||||
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
|
||||
// Re-read live state, not the pre-await snapshot: the same-connection
|
||||
// guard above can await realpath while the user switches tabs, and
|
||||
// startTransfer resolves the currently active pane. Comparing against the
|
||||
// stale snapshot would let the pinned targetPath land on a different
|
||||
// connection (and a cut would then delete the original after the wrong
|
||||
// host received the data).
|
||||
const liveSftp = sftpRef.current;
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? liveSftp.leftTabs.tabs : liveSftp.rightTabs.tabs;
|
||||
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
|
||||
|
||||
if (!sourcePane?.connection) {
|
||||
@@ -426,10 +464,24 @@ export const useSftpKeyboardShortcuts = ({
|
||||
updateClipboardAfterCompletion(pendingNames.size === 0);
|
||||
};
|
||||
|
||||
// Abandon the paste when the destination pane changed while the paste
|
||||
// was pending: startTransfer resolves the currently active pane, and a
|
||||
// different connection would receive a path pinned from another host.
|
||||
// Read the live tabs (see the liveSftp note above), not the snapshot
|
||||
// captured before the await.
|
||||
const activeTargetPane = focusedSide === "left"
|
||||
? liveSftp.leftTabs.tabs.find((tab) => tab.id === liveSftp.leftTabs.activeTabId)
|
||||
: liveSftp.rightTabs.tabs.find((tab) => tab.id === liveSftp.rightTabs.activeTabId);
|
||||
if (!activeTargetPane?.connection || activeTargetPane.connection.id !== targetConnectionId) {
|
||||
toast.info("Paste cancelled: the destination connection changed.", "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
await sftp.startTransfer(clipboard.files, clipboard.sourceSide, focusedSide, {
|
||||
sourcePane,
|
||||
sourcePath: clipboard.sourcePath,
|
||||
sourceConnectionId: clipboard.sourceConnectionId,
|
||||
targetPath,
|
||||
onTransferComplete: handleTransferComplete,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -334,6 +334,10 @@ async function statLocal(event, payload) {
|
||||
type: stat.isDirectory() ? "directory" : "file",
|
||||
size: stat.size,
|
||||
lastModified: stat.mtime.getTime(),
|
||||
// Filesystem identity for same-pane paste guards: realpath cannot see
|
||||
// through bind mounts, but dev/ino name the same directory regardless of
|
||||
// the mount path used. Windows dev/ino are unreliable, so omit them there.
|
||||
...(process.platform === "win32" ? {} : { dev: stat.dev, ino: stat.ino }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -349,9 +353,21 @@ async function lstatLocal(event, payload) {
|
||||
type: stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "file",
|
||||
size: stat.size,
|
||||
lastModified: stat.mtime.getTime(),
|
||||
// Mirror statLocal so guards comparing identities work with either stat.
|
||||
...(process.platform === "win32" ? {} : { dev: stat.dev, ino: stat.ino }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local path to its absolute canonical form, following every
|
||||
* symlink component. Same-pane paste guards use this so destinations that
|
||||
* reach the clipboard source through a symlink alias compare equal to the
|
||||
* real source path.
|
||||
*/
|
||||
async function realpathLocal(event, payload) {
|
||||
return fs.promises.realpath(payload.path);
|
||||
}
|
||||
|
||||
function throwIfLocalTreeCancelled(isCancelled) {
|
||||
if (typeof isCancelled === "function" && isCancelled()) {
|
||||
const error = new Error("Local directory traversal cancelled");
|
||||
@@ -668,6 +684,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:local:mkdir", mkdirLocal);
|
||||
ipcMain.handle("netcatty:local:stat", statLocal);
|
||||
ipcMain.handle("netcatty:local:lstat", lstatLocal);
|
||||
ipcMain.handle("netcatty:local:realpath", realpathLocal);
|
||||
ipcMain.handle("netcatty:local:tree", listLocalTree);
|
||||
ipcMain.handle("netcatty:local:homedir", getHomeDir);
|
||||
ipcMain.handle("netcatty:local:drives", listDrives);
|
||||
@@ -687,6 +704,7 @@ module.exports = {
|
||||
mkdirLocal,
|
||||
statLocal,
|
||||
lstatLocal,
|
||||
realpathLocal,
|
||||
collectLocalTreeEntries,
|
||||
createLocalTreeTraversalBudget,
|
||||
MAX_LOCAL_TREE_DIRECTORIES,
|
||||
|
||||
@@ -916,6 +916,9 @@ function createPreloadApi(ctx) {
|
||||
lstatLocal: async (path) => {
|
||||
return ipcRenderer.invoke("netcatty:local:lstat", { path });
|
||||
},
|
||||
realpathLocal: async (path) => {
|
||||
return ipcRenderer.invoke("netcatty:local:realpath", { path });
|
||||
},
|
||||
listLocalTree: async (path, options = {}) => {
|
||||
const onProgress = typeof options?.onProgress === "function" ? options.onProgress : null;
|
||||
const onEntries = typeof options?.onEntries === "function" ? options.onEntries : null;
|
||||
|
||||
Vendored
+3
@@ -185,6 +185,9 @@ declare global {
|
||||
permissions?: string; // e.g., "rwxr-xr-x"
|
||||
owner?: string;
|
||||
group?: string;
|
||||
/** Local-only filesystem identity; undefined for remote SFTP stats. */
|
||||
dev?: number;
|
||||
ino?: number;
|
||||
}
|
||||
|
||||
interface SftpTransferProgress {
|
||||
|
||||
+2
@@ -157,6 +157,8 @@ declare global {
|
||||
statLocal?(path: string): Promise<SftpStatResult>;
|
||||
/** No-follow local metadata for conflict detection (symlink vs target). */
|
||||
lstatLocal?(path: string): Promise<SftpStatResult>;
|
||||
/** Canonical absolute local path with all symlink components resolved. */
|
||||
realpathLocal?(path: string): Promise<string>;
|
||||
listLocalTree?(
|
||||
path: string,
|
||||
options?: {
|
||||
|
||||
Reference in new issue
Block a user