"use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); const { RemoteFileService, authorization } = require("../src/main/files/remoteFileService"); const { PluginRuntime } = require("../src/main/plugins/pluginRuntime"); function attrs(type, size = 0) { return { size, mtime: 1, mode: 0o644, isDirectory: () => type === "directory", isSymbolicLink: () => type === "symlink", isFile: () => type === "file" }; } function fixture({ sftpError = null, entries = [], stats = {} } = {}) { const helper = []; const sftpCalls = []; const sessionManager = { list: () => [{ sessionId: "s", state: "connected", host: "h", port: 22, username: "user", profileId: "p" }], remoteEditor: async (_session, request, auth) => { helper.push({ request, auth }); return request.operation === "listdir" ? { entries: [{ name: "shadow", type: "file", size: 9, mtime: 1, mode: 0o100640 }] } : request.operation === "remove" ? { type: "file", entries: 1 } : { ok: true }; }, openSftp: async () => new Proxy({}, { get: (_target, method) => method === "then" ? undefined : method === "end" ? () => {} : (...args) => { const cb = args.pop(); sftpCalls.push([method, ...args]); if (method === "readdir") return cb(null, entries); if (method === "stat" || method === "lstat") return stats[args[0]] ? cb(null, stats[args[0]]) : cb(Object.assign(new Error("No such file"), { code: 2 })); if (sftpError) return cb(sftpError); cb(null); } }) }; const privilegedExecutor = { probe: async () => ({ mode: "sudo-password", username: "user" }), hasCachedPassword: () => true }; return { service: new RemoteFileService({ sessionManager, privilegedExecutor }), helper, sftpCalls }; } test("only explicit authorization fields reach the privileged helper", () => { assert.deepEqual(authorization({ sudo: true, sudoPassword: "pw", rememberSudo: true, timeoutMs: 1, editId: "x", useCachedSudo: false }), { sudo: true, sudoPassword: "pw", rememberSudo: true }); assert.deepEqual(authorization({ sudo: "yes", sudoPassword: "" }), {}); }); test("SFTP permission failures become FILE_PERMISSION_DENIED so the UI can offer sudo", async () => { const denied = Object.assign(new Error("Permission denied"), { code: 3 }); const f = fixture({ sftpError: denied }); await assert.rejects(() => f.service.createFile("s", "/etc/new.conf"), { code: "FILE_PERMISSION_DENIED" }); await assert.rejects(() => f.service.mkdir("s", "/etc/new.d"), { code: "FILE_PERMISSION_DENIED" }); await assert.rejects(() => f.service.rename("s", "/etc/a", "/etc/b"), { code: "FILE_PERMISSION_DENIED" }); const exists = fixture({ sftpError: Object.assign(new Error("Failure"), { code: 4 }), stats: { "/srv/x": attrs("file") } }); await assert.rejects(() => exists.service.createFile("s", "/srv/x"), { code: "FILE_EXISTS" }); }); test("privileged create, mkdir, rename and remove use the shared helper with sudo", async () => { const f = fixture(); await f.service.createFile("s", "/etc/new.conf", { sudo: true, sudoPassword: "pw" }); await f.service.mkdir("s", "/etc/new.d", { sudo: true }); await f.service.rename("s", "/etc/a.conf", "/etc/b.conf", { sudo: true }); await f.service.removeMany("s", ["/etc/nginx/old.conf"], true, { sudo: true }); assert.deepEqual(f.helper.map((call) => call.request.operation), ["create", "mkdir", "rename", "remove"]); assert.ok(f.helper.every((call) => call.auth.sudo === true)); assert.equal(f.helper[0].auth.sudoPassword, "pw"); assert.equal(f.helper[3].request.recursive, true); assert.equal(f.sftpCalls.length, 0, "no unprivileged SFTP attempt when sudo was chosen"); }); test("system directories are never removed or renamed, with or without sudo", async () => { const f = fixture(); for (const target of ["/etc", "/usr", "/var", "/home"]) { await assert.rejects(() => f.service.removeMany("s", [target], true, { sudo: true }), { code: "FILE_PROTECTED_PATH" }); await assert.rejects(() => f.service.remove("s", target), { code: "FILE_PROTECTED_PATH" }); await assert.rejects(() => f.service.rename("s", target, `${target}.old`, { sudo: true }), { code: "FILE_PROTECTED_PATH" }); } assert.equal(f.helper.length, 0); }); test("listing resolves link targets and privileged listing goes through sudo", async () => { const f = fixture({ entries: [{ filename: "sites-enabled", attrs: attrs("directory") }, { filename: "default", attrs: attrs("symlink") }, { filename: "dir-link", attrs: attrs("symlink") }, { filename: "broken", attrs: attrs("symlink") }], stats: { "/etc/nginx/default": attrs("file", 321), "/etc/nginx/dir-link": attrs("directory") } }); const entries = await f.service.list("s", "/etc/nginx"); const byName = Object.fromEntries(entries.map((entry) => [entry.name, entry])); assert.equal(byName.default.targetType, "file"); assert.equal(byName.default.size, 321); assert.equal(byName["dir-link"].targetType, "directory"); assert.equal(byName.broken.targetType, "missing"); const privileged = await f.service.list("s", "/etc/ssl/private", { sudo: true, sudoPassword: "pw" }); assert.equal(privileged[0].name, "shadow"); assert.equal(privileged[0].privileged, true); assert.equal(f.helper.at(-1).request.operation, "listdir"); assert.equal(f.helper.at(-1).auth.sudo, true); }); test("privileges report the detected mode and whether a password is cached in memory", async () => { assert.deepEqual(await fixture().service.privileges("s"), { mode: "sudo-password", username: "user", sudoCached: true }); }); test("plugin IPC exposes transfers only with files.transfer and scopes them to the plugin", async () => { const handlers = new Map(); const calls = []; const transferManager = { on() {}, list: (owner) => { calls.push(["list", owner]); return { jobs: [] }; }, pause: (owner, ids) => { calls.push(["pause", owner, ids]); return { paused: 0 }; }, start: (owner, planId, options) => { calls.push(["start", owner, planId, options]); return {}; } }; const runtime = new PluginRuntime({ BrowserWindow: class {}, registry: {}, remoteFileService: {}, transferManager, isVaultUnlocked: () => true }); runtime.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler) }); const window = { isDestroyed: () => false }; runtime.contexts.set(1, { manifest: { id: "chj.file-manager", permissions: ["files.transfer"] }, window }); runtime.contexts.set(2, { manifest: { id: "other", permissions: ["files.read"] }, window }); await handlers.get("plugin:files:transfers:list")({ sender: { id: 1 } }, {}); await handlers.get("plugin:files:transfers:pause")({ sender: { id: 1 } }, { ids: "all" }); await handlers.get("plugin:files:transfers:start")({ sender: { id: 1 } }, { planId: "p1", options: { conflict: "skip" } }); assert.deepEqual(calls, [["list", "chj.file-manager"], ["pause", "chj.file-manager", "all"], ["start", "chj.file-manager", "p1", { conflict: "skip" }]]); await assert.rejects(() => handlers.get("plugin:files:transfers:list")({ sender: { id: 2 } }, {}), { code: "PLUGIN_PERMISSION_DENIED" }); }); test("plugin file errors keep their code in the message across Electron IPC", async () => { const handlers = new Map(); const remoteFileService = { createFile: async () => { throw Object.assign(new Error("Permission denied"), { code: "FILE_PERMISSION_DENIED" }); } }; const runtime = new PluginRuntime({ BrowserWindow: class {}, registry: {}, remoteFileService, isVaultUnlocked: () => true }); runtime.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler) }); runtime.contexts.set(1, { manifest: { id: "fm", permissions: ["files.write"] }, window: { isDestroyed: () => false } }); await assert.rejects(() => handlers.get("plugin:files:createFile")({ sender: { id: 1 } }, { sessionId: "s", path: "/etc/x" }), /^Error: FILE_PERMISSION_DENIED: /); });