const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { collectLocalTreeEntries, MAX_LOCAL_TREE_DIRECTORIES, MAX_LOCAL_TREE_ENTRIES, WINDOWS_ATTRIB_TIMEOUT_MS, parseAttribOutput, listWindowsHiddenBasenames, statLocal, lstatLocal, deleteLocalFile, } = require("./localFsBridge.cjs"); test("local tree traversal defaults match the remote traversal safety budget", () => { assert.equal(MAX_LOCAL_TREE_DIRECTORIES, 50_000); assert.equal(MAX_LOCAL_TREE_ENTRIES, 200_000); }); test("expected symlink delete refuses a replacement file", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-delete-type-")); const target = path.join(root, "target"); t.after(async () => fs.promises.rm(root, { recursive: true, force: true })); await fs.promises.writeFile(target, "new owner"); await assert.rejects( () => deleteLocalFile(null, { path: target, expectedType: "symlink" }), (error) => { assert.equal(error.code, "ESTALE"); return true; }, ); assert.equal(await fs.promises.readFile(target, "utf8"), "new owner"); }); test("parseAttribOutput returns an empty set for empty input", () => { assert.equal(parseAttribOutput("").size, 0); assert.equal(parseAttribOutput("\r\n\r\n").size, 0); }); test("parseAttribOutput captures basenames of files with the H flag", () => { const stdout = [ "A C:\\Users\\foo\\public.txt", " H C:\\Users\\foo\\.secret", "A H R C:\\Users\\foo\\hidden-readonly.exe", "A C:\\Users\\foo\\another.log", ].join("\r\n"); const hidden = parseAttribOutput(stdout); assert.deepEqual( [...hidden].sort(), [".secret", "hidden-readonly.exe"].sort(), ); }); test("parseAttribOutput ignores the trailing [DIR] marker on some Windows versions", () => { const stdout = [ " H C:\\data\\node_modules [DIR]", " H C:\\data\\.git [DIR]", "A C:\\data\\README.md", ].join("\r\n"); const hidden = parseAttribOutput(stdout); assert.deepEqual([...hidden].sort(), [".git", "node_modules"].sort()); }); test("parseAttribOutput preserves filenames that legitimately end with bracketed suffixes", () => { // Regression: a prior version stripped ANY trailing bracketed suffix // via /\s+\[[^\]]+\]\s*$/, truncating "Notes [old]" to "Notes". // Only the literal [DIR] marker that attrib emits with /d is a parser // artifact; user-facing filenames with brackets must survive intact so // hiddenSet.has(entry.name) still matches the actual readdir entry. const stdout = [ " H C:\\data\\Notes [old]", " H C:\\data\\Draft [v2].md", " H C:\\data\\archived [2024]", " H C:\\data\\node_modules [DIR]", ].join("\r\n"); const hidden = parseAttribOutput(stdout); assert.deepEqual( [...hidden].sort(), ["Draft [v2].md", "Notes [old]", "archived [2024]", "node_modules"].sort(), ); }); test("parseAttribOutput handles UNC paths", () => { const stdout = [ " H \\\\fileserver\\share\\secret.cfg", "A \\\\fileserver\\share\\public.cfg", ].join("\r\n"); const hidden = parseAttribOutput(stdout); assert.deepEqual([...hidden], ["secret.cfg"]); }); test("parseAttribOutput skips malformed lines", () => { const stdout = [ "Parameter format not correct", "", " H C:\\good\\hidden.txt", "File not found", " H not-a-windows-path.txt", ].join("\r\n"); const hidden = parseAttribOutput(stdout); assert.deepEqual([...hidden], ["hidden.txt"]); }); test("listWindowsHiddenBasenames returns an empty set on non-Windows without spawning anything", async () => { // Running this test file is only meaningful on a non-Windows host for this // assertion. On Windows CI we skip the subprocess-free guarantee. if (process.platform === "win32") return; const result = await listWindowsHiddenBasenames("/tmp"); assert.ok(result instanceof Set); assert.equal(result.size, 0); }); test("listWindowsHiddenBasenames invokes attrib.exe with /d so hidden directories aren't omitted", async () => { // Regression: without `/d`, `attrib \*` treats the wildcard as // file-centric and hidden directories (node_modules, .git, …) never // reach parseAttribOutput — the SFTP browser then shows them as // not-hidden, a behavior regression from the per-file implementation. const Module = require("node:module"); const realChildProcess = require("node:child_process"); const originalLoad = Module._load; const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); let capturedArgs = null; let capturedExecutable = null; let capturedOptions = null; Module._load = function patchedLoad(request, parent, isMain) { if (request === "node:child_process") { return { ...realChildProcess, execFile: (executable, args, options, cb) => { capturedExecutable = executable; capturedArgs = args; capturedOptions = options; cb(null, { stdout: "", stderr: "" }); }, }; } return originalLoad.call(this, request, parent, isMain); }; Object.defineProperty(process, "platform", { value: "win32", writable: true, configurable: true, }); const bridgePath = require.resolve("./localFsBridge.cjs"); delete require.cache[bridgePath]; try { const { listWindowsHiddenBasenames: fn } = require("./localFsBridge.cjs"); await fn("C:\\fixture"); } finally { Module._load = originalLoad; Object.defineProperty(process, "platform", originalPlatform); delete require.cache[bridgePath]; } assert.equal(capturedExecutable, "attrib.exe"); assert.ok( Array.isArray(capturedArgs) && capturedArgs.includes("/d"), `expected /d in attrib args so hidden directories are included, got ${JSON.stringify(capturedArgs)}`, ); assert.equal(capturedOptions.timeout, WINDOWS_ATTRIB_TIMEOUT_MS); assert.equal(capturedOptions.maxBuffer, 64 * 1024 * 1024); }); test("collectLocalTreeEntries preserves empty directories in selected folders", async () => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-")); const selected = path.join(root, "project"); await fs.promises.mkdir(path.join(selected, "empty"), { recursive: true }); await fs.promises.mkdir(path.join(selected, "src"), { recursive: true }); await fs.promises.writeFile(path.join(selected, "src", "main.txt"), "hello"); try { const entries = await collectLocalTreeEntries(selected); const summary = entries.map((entry) => ({ relativePath: entry.relativePath, type: entry.type, })); assert.deepEqual(summary, [ { relativePath: "project", type: "directory" }, { relativePath: "project/empty", type: "directory" }, { relativePath: "project/src", type: "directory" }, { relativePath: "project/src/main.txt", type: "file" }, ]); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("collectLocalTreeEntries reports progressive file counts during scan", async () => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-progress-")); const selected = path.join(root, "project"); await fs.promises.mkdir(path.join(selected, "src"), { recursive: true }); await fs.promises.writeFile(path.join(selected, "readme.txt"), "hi"); await fs.promises.writeFile(path.join(selected, "src", "main.txt"), "hello"); const progress = []; try { const entries = await collectLocalTreeEntries(selected, {}, (stats) => { progress.push({ ...stats }); }); assert.equal(entries.filter((entry) => entry.type === "file").length, 2); assert.ok(progress.length >= 1, "expected at least one progress event"); const final = progress[progress.length - 1]; assert.equal(final.fileCount, 2); assert.equal(final.directoryCount, 2); assert.equal(final.entryCount, 4); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("collectLocalTreeEntries aborts when cancelled mid-scan", async () => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-cancel-")); const selected = path.join(root, "project"); await fs.promises.mkdir(path.join(selected, "a"), { recursive: true }); await fs.promises.mkdir(path.join(selected, "b"), { recursive: true }); await fs.promises.writeFile(path.join(selected, "a", "1.txt"), "1"); await fs.promises.writeFile(path.join(selected, "b", "2.txt"), "2"); let cancel = false; try { await assert.rejects( () => collectLocalTreeEntries( selected, {}, () => { // Cancel as soon as progress starts flowing. cancel = true; }, () => cancel, ), /cancelled/i, ); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("collectLocalTreeEntries bounds and parallelizes metadata reads within a directory", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-parallel-")); const selected = path.join(root, "project"); await fs.promises.mkdir(selected, { recursive: true }); await Promise.all(Array.from({ length: 64 }, (_, index) => ( fs.promises.writeFile(path.join(selected, `file-${String(index).padStart(3, "0")}.txt`), "x") ))); const originalLstat = fs.promises.lstat; let active = 0; let maxActive = 0; fs.promises.lstat = async (...args) => { active += 1; maxActive = Math.max(maxActive, active); try { await new Promise((resolve) => setImmediate(resolve)); return await originalLstat.apply(fs.promises, args); } finally { active -= 1; } }; t.after(async () => { fs.promises.lstat = originalLstat; await fs.promises.rm(root, { recursive: true, force: true }); }); const entries = await collectLocalTreeEntries(selected); assert.equal(entries.length, 65); assert.ok(maxActive > 1, `expected concurrent lstat calls, got ${maxActive}`); assert.ok(maxActive <= 32, `metadata concurrency must stay bounded, got ${maxActive}`); }); test("collectLocalTreeEntries skips a child that disappears during inspection", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-vanished-")); const selected = path.join(root, "project"); await fs.promises.mkdir(selected, { recursive: true }); await fs.promises.writeFile(path.join(selected, "stable.txt"), "stable"); await fs.promises.writeFile(path.join(selected, "vanished.txt"), "vanished"); const originalLstat = fs.promises.lstat; fs.promises.lstat = async (candidate, ...args) => { if (candidate === path.join(selected, "vanished.txt")) { const error = new Error("file disappeared"); error.code = "ENOENT"; throw error; } return originalLstat.call(fs.promises, candidate, ...args); }; t.after(async () => { fs.promises.lstat = originalLstat; await fs.promises.rm(root, { recursive: true, force: true }); }); const entries = await collectLocalTreeEntries(selected); assert.deepEqual(entries.map((entry) => entry.relativePath), [ "project", "project/stable.txt", ]); }); test("collectLocalTreeEntries does not follow a directory symlink cycle", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-cycle-")); const selected = path.join(root, "project"); const nested = path.join(selected, "nested"); await fs.promises.mkdir(nested, { recursive: true }); try { await fs.promises.symlink(selected, path.join(nested, "back-to-project"), process.platform === "win32" ? "junction" : "dir"); } catch (error) { await fs.promises.rm(root, { recursive: true, force: true }); t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`); return; } try { const entries = await Promise.race([ collectLocalTreeEntries(selected), new Promise((_, reject) => setTimeout(() => reject(new Error("local tree scan followed a symlink cycle")), 500)), ]); const loopRows = entries.filter((entry) => entry.relativePath.endsWith("back-to-project")); assert.equal(loopRows.length, 0, "cyclic directory links must be skipped instead of uploaded as files"); assert.ok(entries.length <= 3, `cycle scan must stay bounded, got ${entries.length} rows`); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("collectLocalTreeEntries skips a symlink back to a non-root ancestor", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-nested-cycle-")); const selected = path.join(root, "project"); const nested = path.join(selected, "a", "b"); await fs.promises.mkdir(nested, { recursive: true }); await fs.promises.writeFile(path.join(selected, "a", "keep.txt"), "keep"); try { await fs.promises.symlink( path.join(selected, "a"), path.join(nested, "back"), process.platform === "win32" ? "junction" : "dir", ); } catch (error) { await fs.promises.rm(root, { recursive: true, force: true }); t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`); return; } try { const entries = await Promise.race([ collectLocalTreeEntries(selected), new Promise((_, reject) => setTimeout(() => reject(new Error("local tree scan followed a nested ancestor symlink cycle")), 500)), ]); const loopRows = entries.filter((entry) => entry.relativePath.includes("/back/")); assert.equal(loopRows.length, 0, "symlink to a non-root ancestor must not duplicate the ancestor subtree"); assert.equal( entries.filter((entry) => entry.relativePath.endsWith("/back")).length, 0, "cyclic directory links must be skipped instead of uploaded as files", ); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("collectLocalTreeEntries counts a cyclic directory alias against the global budget", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-cycle-budget-")); const selected = path.join(root, "project"); const nested = path.join(selected, "nested"); await fs.promises.mkdir(nested, { recursive: true }); try { await fs.promises.symlink(selected, path.join(nested, "back"), process.platform === "win32" ? "junction" : "dir"); } catch (error) { await fs.promises.rm(root, { recursive: true, force: true }); t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`); return; } try { await assert.rejects( collectLocalTreeEntries(selected, { maxDirectories: 2, maxEntries: 10 }), /Local directory traversal directory limit exceeded \(2\).*smaller folder/i, ); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("collectLocalTreeEntries bounds non-cyclic directory symlink fan-out", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-fanout-")); const selected = path.join(root, "project"); const shared = path.join(root, "shared"); await fs.promises.mkdir(selected, { recursive: true }); await fs.promises.mkdir(shared, { recursive: true }); await fs.promises.writeFile(path.join(shared, "payload.txt"), "payload"); try { for (let index = 0; index < 4; index += 1) { await fs.promises.symlink( shared, path.join(selected, `alias-${index}`), process.platform === "win32" ? "junction" : "dir", ); } } catch (error) { await fs.promises.rm(root, { recursive: true, force: true }); t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`); return; } try { await assert.rejects( collectLocalTreeEntries(selected, { maxDirectories: 4, maxEntries: 20 }), /Local directory traversal directory limit exceeded \(4\).*smaller folder/i, ); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("collectLocalTreeEntries accepts exact directory and entry budget boundaries", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-budget-edge-")); const selected = path.join(root, "project"); await fs.promises.mkdir(path.join(selected, "nested"), { recursive: true }); await fs.promises.writeFile(path.join(selected, "one.txt"), "1"); await fs.promises.writeFile(path.join(selected, "nested", "two.txt"), "2"); t.after(() => fs.promises.rm(root, { recursive: true, force: true })); const entries = await collectLocalTreeEntries(selected, { maxDirectories: 2, maxEntries: 3, }); assert.deepEqual(entries.map((entry) => entry.relativePath), [ "project", "project/nested", "project/one.txt", "project/nested/two.txt", ]); }); test("collectLocalTreeEntries rejects one entry beyond the global entry budget", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-entry-budget-")); const selected = path.join(root, "project"); await fs.promises.mkdir(selected, { recursive: true }); await Promise.all([ fs.promises.writeFile(path.join(selected, "one.txt"), "1"), fs.promises.writeFile(path.join(selected, "two.txt"), "2"), fs.promises.writeFile(path.join(selected, "three.txt"), "3"), ]); t.after(() => fs.promises.rm(root, { recursive: true, force: true })); await assert.rejects( collectLocalTreeEntries(selected, { maxDirectories: 1, maxEntries: 2 }), /Local directory traversal entry limit exceeded \(2\).*smaller folder/i, ); }); test("collectLocalTreeEntries follows a non-cyclic directory symlink for folder upload", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-directory-link-")); const selected = path.join(root, "project"); const target = path.join(root, "shared-assets"); const link = path.join(selected, "assets"); await fs.promises.mkdir(selected, { recursive: true }); await fs.promises.mkdir(target, { recursive: true }); await fs.promises.writeFile(path.join(target, "logo.txt"), "logo"); try { await fs.promises.symlink(target, link, process.platform === "win32" ? "junction" : "dir"); } catch (error) { await fs.promises.rm(root, { recursive: true, force: true }); t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`); return; } try { const entries = await collectLocalTreeEntries(selected); const linkedDirectory = entries.find((entry) => entry.relativePath === "project/assets"); const linkedFile = entries.find((entry) => entry.relativePath === "project/assets/logo.txt"); assert.equal(linkedDirectory?.type, "directory"); assert.equal(linkedFile?.type, "file"); assert.equal(linkedFile?.localPath, path.join(link, "logo.txt")); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("statLocal follows symlinks and reports target size for resume sizing", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stat-local-follow-")); const target = path.join(root, "outside.bin"); const link = path.join(root, "tool.sh"); await fs.promises.writeFile(target, "target-bytes-longer"); try { await fs.promises.symlink(target, link, "file"); } catch (error) { await fs.promises.rm(root, { recursive: true, force: true }); t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`); return; } try { const linkStat = await fs.promises.lstat(link); const targetStat = await fs.promises.stat(target); assert.notEqual(linkStat.size, targetStat.size, "fixture must distinguish link metadata from target"); const result = await statLocal(null, { path: link }); assert.equal(result.type, "file"); assert.equal(result.size, targetStat.size); assert.equal(result.lastModified, targetStat.mtime.getTime()); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("lstatLocal classifies symlinks without following the target", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-lstat-local-link-")); const target = path.join(root, "outside.bin"); const link = path.join(root, "tool.sh"); await fs.promises.writeFile(target, "target-bytes-longer"); try { await fs.promises.symlink(target, link, "file"); } catch (error) { await fs.promises.rm(root, { recursive: true, force: true }); t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`); return; } try { const linkStat = await fs.promises.lstat(link); const targetStat = await fs.promises.stat(target); assert.notEqual(linkStat.size, targetStat.size, "fixture must distinguish link metadata from target"); const result = await lstatLocal(null, { path: link }); assert.equal(result.type, "symlink"); assert.equal(result.size, linkStat.size); assert.equal(result.lastModified, linkStat.mtime.getTime()); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("collectLocalTreeEntries skips realpath for ordinary files and directories", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-realpath-")); const selected = path.join(root, "project"); await fs.promises.mkdir(path.join(selected, "src"), { recursive: true }); await fs.promises.writeFile(path.join(selected, "src", "index.txt"), "ok"); const originalRealpath = fs.promises.realpath; let realpathCalls = 0; fs.promises.realpath = async (...args) => { realpathCalls += 1; return originalRealpath.apply(fs.promises, args); }; t.after(async () => { fs.promises.realpath = originalRealpath; await fs.promises.rm(root, { recursive: true, force: true }); }); const entries = await collectLocalTreeEntries(selected); assert.equal(entries.some((entry) => entry.relativePath === "project/src/index.txt"), true); assert.equal(realpathCalls, 1, "only the selected root should be realpath'd"); }); test("collectLocalTreeEntries uses the target metadata for a leaf file symlink", async (t) => { const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-upload-tree-file-link-")); const selected = path.join(root, "project"); const target = path.join(root, "target.bin"); const link = path.join(selected, "linked.bin"); await fs.promises.mkdir(selected, { recursive: true }); await fs.promises.writeFile(target, "target-file-contents"); try { await fs.promises.symlink(target, link, "file"); } catch (error) { await fs.promises.rm(root, { recursive: true, force: true }); t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`); return; } try { const targetStat = await fs.promises.stat(target); const linkStat = await fs.promises.lstat(link); assert.notEqual(linkStat.size, targetStat.size, "fixture must distinguish link metadata from target metadata"); const entries = await collectLocalTreeEntries(selected); const linked = entries.find((entry) => entry.relativePath === "project/linked.bin"); assert.equal(linked?.type, "file"); assert.equal(linked?.size, targetStat.size); assert.equal(linked?.lastModified, targetStat.mtime.getTime()); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } }); test("readLocalFile returns only the trailing maxBytes when requested", async () => { const { readLocalFile } = require("./localFsBridge.cjs"); const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-read-")); const filePath = path.join(root, "hist.txt"); const body = "AAAA\nBBBB\nCCCC\nDDDD\n"; await fs.promises.writeFile(filePath, body); try { const full = await readLocalFile(null, { path: filePath }); assert.equal(Buffer.from(full).toString("utf8"), body); const tailed = await readLocalFile(null, { path: filePath, maxBytes: 10 }); assert.equal(Buffer.from(tailed).toString("utf8"), body.slice(-10)); } finally { await fs.promises.rm(root, { recursive: true, force: true }); } });