Files

515 lines
15 KiB
JavaScript
Raw Permalink Normal View History

const assert = require("node:assert/strict");
const test = require("node:test");
const { createFileOpsApi } = require("./fileOps.cjs");
test("home discovery accepts a virtual SFTP root when SSH exec is unavailable", async () => {
const channel = {};
const listed = [];
const api = createFileOpsApi({
sftpClients: new Map([["jumpserver", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async (resolvedChannel, remotePath) => {
assert.equal(resolvedChannel, channel);
assert.equal(remotePath, ".");
return "/";
},
readdirAsync: async (resolvedChannel, remotePath) => {
listed.push([resolvedChannel, remotePath]);
return [{ filename: "data" }];
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "jumpserver" });
assert.deepEqual(result, { success: true, homeDir: "/" });
assert.deepEqual(listed, [[channel, "/"]]);
});
test("home discovery rejects non-listable root so candidate probing can run", async () => {
const channel = {};
const api = createFileOpsApi({
sftpClients: new Map([["restricted", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/",
readdirAsync: async () => {
const error = new Error("Permission denied");
error.code = "EACCES";
throw error;
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "restricted" });
assert.equal(result.success, false);
assert.match(result.error || "", /Could not determine home directory/);
});
test("home discovery still accepts non-root realpath without listing", async () => {
const channel = {};
let readdirCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["normal", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/home/deploy",
readdirAsync: async () => {
readdirCalls += 1;
return [];
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "normal" });
assert.deepEqual(result, { success: true, homeDir: "/home/deploy" });
assert.equal(readdirCalls, 0);
});
test("statSftp follows symlinks and reports target size for resume sizing", async () => {
const channel = { stat() {}, lstat() {} };
let lstatCalls = 0;
let statCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
path: require("node:path"),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
lstatCalls += 1;
return {
size: 11,
mode: 0o120777,
mtime: 10,
isDirectory: () => false,
isSymbolicLink: () => true,
};
},
statAsync: async () => {
statCalls += 1;
return {
size: 42,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
};
},
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
});
const result = await api.statSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/tool",
});
assert.equal(statCalls, 1);
assert.equal(lstatCalls, 0, "shared stat must follow for resume/sizing");
assert.equal(result.type, "file");
assert.equal(result.size, 42);
});
test("lstatSftp classifies symlinks without following the target", async () => {
const channel = { lstat() {} };
let lstatCalls = 0;
let statCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
path: require("node:path"),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
lstatCalls += 1;
return {
size: 11,
mode: 0o120777,
mtime: 10,
isDirectory: () => false,
isSymbolicLink: () => true,
};
},
statAsync: async () => {
statCalls += 1;
return {
size: 42,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
};
},
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
});
const result = await api.lstatSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/tool",
});
assert.equal(lstatCalls, 1);
assert.equal(statCalls, 0, "must not follow the symlink with STAT");
assert.equal(result.type, "symlink");
assert.equal(result.size, 11);
});
test("lstatSftp refuses followed STAT when LSTAT is unsupported", async () => {
const channel = { lstat() {}, stat() {} };
let statCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
path: require("node:path"),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
const error = new Error("SSH_FX_OP_UNSUPPORTED");
error.code = 8;
throw error;
},
statAsync: async () => {
statCalls += 1;
return {
size: 42,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
};
},
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
});
await assert.rejects(
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/usr/local/bin/tool" }),
(error) => {
assert.equal(error.code, "ENOTSUP");
assert.equal(error.lstatUnavailable, true);
assert.match(String(error.message), /does not support LSTAT/i);
return true;
},
);
assert.equal(statCalls, 0, "must not classify via followed STAT");
});
test("lstatSftp returns null for SSH_FX_NO_SUCH_FILE even with a localized message", async () => {
const channel = { lstat() {} };
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
const error = new Error("File not found");
error.code = 2;
throw error;
},
});
const result = await api.lstatSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/new-file.sh",
});
assert.equal(result, null);
});
test("lstatSftp still throws permission errors on an existing path", async () => {
const channel = { lstat() {} };
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => {
const error = new Error("Permission denied");
error.code = "EACCES";
throw error;
},
});
await assert.rejects(
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/root/secret" }),
(error) => {
assert.equal(error.code, "EACCES");
assert.match(String(error.message), /Permission denied/);
return true;
},
);
});
test("lstatSftp refuses a channel without native LSTAT", async () => {
const channel = { stat() {} };
let lstatCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
requireSftpChannel: async () => channel,
lstatAsync: async () => { lstatCalls += 1; },
});
await assert.rejects(
() => api.lstatSftp(null, { sftpId: "sftp-1", path: "/usr/local/bin/tool" }),
(error) => {
assert.equal(error.code, "ENOTSUP");
assert.equal(error.lstatUnavailable, true);
return true;
},
);
assert.equal(lstatCalls, 0, "must not call the followed-stat fallback");
});
test("expected symlink delete refuses a replacement file", async () => {
const channel = { lstat() {} };
let unlinkCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
lstatAsync: async () => ({
size: 4,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
}),
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
unlinkAsync: async () => { unlinkCalls += 1; },
});
await assert.rejects(
() => api.deleteSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/tool",
expectedType: "symlink",
}),
(error) => {
assert.equal(error.code, "ESTALE");
return true;
},
);
assert.equal(unlinkCalls, 0);
});
test("SCP expected symlink delete stays non-recursive after the type check", async () => {
let unlinkCalls = 0;
let removeCalls = 0;
const backend = {
async stat() {
return { isDirectory: false, isSymbolicLink: true };
},
async unlink(remotePath, options) {
unlinkCalls += 1;
assert.equal(remotePath, "/usr/local/bin/tool");
assert.equal(options.encoding, "utf-8");
},
async remove() {
removeCalls += 1;
},
};
const client = {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: backend,
};
const api = createFileOpsApi({
sftpClients: new Map([["scp-1", client]]),
throwIfAborted() {},
resolveEncodingForRequest: () => "utf-8",
});
await api.deleteSftp(null, {
sftpId: "scp-1",
path: "/usr/local/bin/tool",
expectedType: "symlink",
});
assert.equal(unlinkCalls, 1);
assert.equal(removeCalls, 0);
});
test("expected symlink delete refuses a channel without native LSTAT", async () => {
const channel = { stat() {} };
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
});
await assert.rejects(
() => api.deleteSftp(null, {
sftpId: "sftp-1",
path: "/usr/local/bin/tool",
expectedType: "symlink",
}),
(error) => {
assert.equal(error.code, "ENOTSUP");
return true;
},
);
});
test("non-UTF-8 expected symlink delete refuses a replacement file", async () => {
const channel = { lstat() {} };
let removeCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "gb18030",
normalizeRemotePathString: async (_client, remotePath) => remotePath,
encodePath: (remotePath) => Buffer.from(remotePath),
lstatAsync: async () => ({
size: 4,
mode: 0o100644,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => false,
}),
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
removeRemotePathInternal: async () => { removeCalls += 1; },
});
await assert.rejects(
() => api.deleteSftp(null, {
sftpId: "sftp-1",
path: "/remote/tool",
encoding: "gb18030",
expectedType: "symlink",
}),
(error) => {
assert.equal(error.code, "ESTALE");
return true;
},
);
assert.equal(removeCalls, 0);
});
test("non-UTF-8 expected symlink delete stays non-recursive after the type check", async () => {
const channel = { lstat() {} };
let unlinkCalls = 0;
let removeCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
resolveEncodingForRequest: () => "gb18030",
normalizeRemotePathString: async (_client, remotePath) => remotePath,
encodePath: (remotePath) => Buffer.from(remotePath),
lstatAsync: async () => ({
size: 4,
mode: 0o120777,
mtime: 20,
isDirectory: () => false,
isSymbolicLink: () => true,
}),
statResultFromAttrs: (attrs) => ({
size: attrs.size,
modifyTime: attrs.mtime * 1000,
mode: attrs.mode,
isDirectory: attrs.isDirectory(),
isSymbolicLink: attrs.isSymbolicLink(),
}),
unlinkAsync: async (_sftp, encodedPath) => {
unlinkCalls += 1;
assert.equal(encodedPath.toString(), "/remote/tool");
},
removeRemotePathInternal: async () => { removeCalls += 1; },
});
await api.deleteSftp(null, {
sftpId: "sftp-1",
path: "/remote/tool",
encoding: "gb18030",
expectedType: "symlink",
});
assert.equal(unlinkCalls, 1);
assert.equal(removeCalls, 0);
});
test("listSftp includes owner from longname and falls back to uid", async () => {
const channel = {
readdir(_path, callback) {
callback(null, [
{
filename: "root.txt",
longname: "-rw-r--r-- 1 root root 12 Jan 1 00:00 root.txt",
attrs: {
size: 12,
mtime: 1700000000,
uid: 0,
mode: 0o100644,
isDirectory: () => false,
isSymbolicLink: () => false,
},
},
{
filename: "uid-only.bin",
longname: "",
attrs: {
size: 1,
mtime: 1700000000,
uid: 1000,
mode: 0o100644,
isDirectory: () => false,
isSymbolicLink: () => false,
},
},
]);
},
};
const api = createFileOpsApi({
sftpClients: new Map([["sftp-1", { sftp: channel }]]),
path: require("node:path"),
normalizeEncoding: (value) => value || "utf-8",
resolveEncodingForRequest: () => "utf-8",
encodePath: (remotePath) => remotePath,
requireSftpChannel: async () => channel,
detectEncodingFromList: () => "utf-8",
updateResolvedEncoding: (_id, _req, detected) => detected,
decodeName: (raw) => (raw ? Buffer.from(raw).toString("utf8") : ""),
isAsciiString: () => true,
sftpEncodingState: new Map(),
});
const entries = await api.listSftp(null, { sftpId: "sftp-1", path: "/home" });
assert.equal(entries[0].name, "root.txt");
assert.equal(entries[0].owner, "root");
assert.equal(entries[1].name, "uid-only.bin");
assert.equal(entries[1].owner, "1000");
});