[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
220
electron/bridges/sftpBridge.directoryDelete.test.cjs
Normal file
220
electron/bridges/sftpBridge.directoryDelete.test.cjs
Normal file
@@ -0,0 +1,220 @@
|
||||
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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user