Files
NetMesh/electron/bridges/sftpBridge.directoryDelete.test.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

221 lines
7.2 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const sftpBridge = require("./sftpBridge.cjs");
function createNestedDirectoryChannel() {
const entries = new Map([
["/home/user/parent", "directory"],
["/home/user/parent/child", "directory"],
["/home/user/parent/child/file.txt", "file"],
]);
const calls = { exec: [], rmdir: [], unlink: [] };
const toPath = (value) => Buffer.isBuffer(value) ? value.toString("utf8") : value;
const channel = {
mkdir(_targetPath, callback) {
callback(null);
},
stat(targetPath, callback) {
const value = toPath(targetPath);
const type = entries.get(value);
if (!type) {
const error = new Error(`No such file: ${value}`);
error.code = 2;
callback(error);
return;
}
callback(null, {
isDirectory: () => type === "directory" || type === "directory-symlink",
isSymbolicLink: () => false,
});
},
lstat(targetPath, callback) {
const value = toPath(targetPath);
const type = entries.get(value);
if (!type) {
const error = new Error(`No such file: ${value}`);
error.code = 2;
callback(error);
return;
}
callback(null, {
isDirectory: () => type === "directory",
isSymbolicLink: () => type === "directory-symlink",
});
},
readdir(targetPath, callback) {
const value = toPath(targetPath);
const prefix = `${value.replace(/\/$/, "")}/`;
const names = new Set();
for (const entryPath of entries.keys()) {
if (!entryPath.startsWith(prefix)) continue;
const rest = entryPath.slice(prefix.length);
if (rest && !rest.includes("/")) names.add(rest);
}
callback(null, [...names].map((filename) => ({ filename })));
},
unlink(targetPath, callback) {
const value = toPath(targetPath);
calls.unlink.push(value);
entries.delete(value);
callback(null);
},
rmdir(targetPath, callback) {
const value = toPath(targetPath);
calls.rmdir.push(value);
entries.delete(value);
callback(null);
},
realpath(targetPath, callback) {
callback(null, toPath(targetPath));
},
};
return { calls, channel, entries };
}
test("directory delete does not trust an unrelated shell success", async () => {
const tree = createNestedDirectoryChannel();
const sshClient = {
exec(command, callback) {
tree.calls.exec.push(command);
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
callback(null, stream);
// `rm -rf` can return zero when the shell and SFTP channel resolve the
// same-looking path in different roots. It did not touch our SFTP tree.
queueMicrotask(() => stream.emit("close", 0));
},
};
// Prefer a live SFTP channel for requireSftpChannel / lstat verify.
const client = {
client: sshClient,
sftp: tree.channel,
async sftp(cb) {
if (typeof cb === "function") cb(null, tree.channel);
return tree.channel;
},
};
// ssh2-sftp-client style: some paths use client.sftp as channel object.
Object.assign(client, { sftp: tree.channel });
sftpBridge.init({
electronModule: {},
sessions: new Map(),
sftpClients: new Map([["delete-test", client]]),
});
await sftpBridge.deleteSftp(null, {
sftpId: "delete-test",
path: "/home/user/parent",
encoding: "utf-8",
});
assert.equal(tree.entries.has("/home/user/parent"), false);
// Fast path may attempt shell rm -rf, but must fall back to protocol walk
// when SFTP still sees the path after a fake shell success.
assert.ok(
tree.calls.exec.length === 0
|| tree.calls.exec.some((command) => String(command).includes("rm -rf")),
);
assert.deepEqual(tree.calls.unlink, ["/home/user/parent/child/file.txt"]);
assert.deepEqual(tree.calls.rmdir, ["/home/user/parent/child", "/home/user/parent"]);
});
test("directory delete uses verified shell rm -rf when SFTP path is gone", async () => {
const tree = createNestedDirectoryChannel();
const sshClient = {
exec(command, callback) {
tree.calls.exec.push(command);
// Actually remove the SFTP-visible tree so verify succeeds.
if (String(command).includes("rm -rf")) {
for (const key of [...tree.entries.keys()]) {
if (key === "/home/user/parent" || key.startsWith("/home/user/parent/")) {
tree.entries.delete(key);
}
}
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
callback(null, stream);
queueMicrotask(() => stream.emit("close", 0));
},
};
const client = { client: sshClient, sftp: tree.channel };
sftpBridge.init({
electronModule: {},
sessions: new Map(),
sftpClients: new Map([["delete-fast-test", client]]),
});
await sftpBridge.deleteSftp(null, {
sftpId: "delete-fast-test",
path: "/home/user/parent",
encoding: "utf-8",
});
assert.equal(tree.entries.has("/home/user/parent"), false);
assert.ok(tree.calls.exec.some((command) => String(command).includes("rm -rf")));
// Protocol walk should not be needed when shell verify succeeds.
assert.deepEqual(tree.calls.unlink, []);
assert.deepEqual(tree.calls.rmdir, []);
});
test("directory delete preserves leading-dot directory names", async () => {
const tree = createNestedDirectoryChannel();
tree.entries.clear();
tree.entries.set(".cache", "directory");
tree.entries.set(".cache/item.txt", "file");
tree.entries.set("ache", "directory");
tree.entries.set("..staging", "directory");
tree.entries.set("..staging/item.txt", "file");
tree.entries.set("../taging", "directory");
sftpBridge.init({
electronModule: {},
sessions: new Map(),
sftpClients: new Map([["dot-delete-test", { sftp: tree.channel }]]),
});
await sftpBridge.deleteSftp(null, {
sftpId: "dot-delete-test",
path: ".cache",
encoding: "utf-8",
});
await sftpBridge.deleteSftp(null, {
sftpId: "dot-delete-test",
path: "..staging",
encoding: "utf-8",
});
assert.equal(tree.entries.has(".cache"), false);
assert.equal(tree.entries.has("..staging"), false);
assert.equal(tree.entries.has("ache"), true);
assert.equal(tree.entries.has("../taging"), true);
});
test("directory delete unlinks a directory symlink without touching its target", async () => {
const tree = createNestedDirectoryChannel();
tree.entries.clear();
tree.entries.set("/home/user/link", "directory-symlink");
tree.entries.set("/home/user/target", "directory");
tree.entries.set("/home/user/target/item.txt", "file");
sftpBridge.init({
electronModule: {},
sessions: new Map(),
sftpClients: new Map([["symlink-delete-test", { sftp: tree.channel }]]),
});
await sftpBridge.deleteSftp(null, {
sftpId: "symlink-delete-test",
path: "/home/user/link",
encoding: "utf-8",
});
assert.equal(tree.entries.has("/home/user/link"), false);
assert.equal(tree.entries.has("/home/user/target"), true);
assert.equal(tree.entries.has("/home/user/target/item.txt"), true);
assert.deepEqual(tree.calls.unlink, ["/home/user/link"]);
assert.deepEqual(tree.calls.rmdir, []);
});