Files
NetMesh/electron/bridges/localFsBridge.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

702 lines
24 KiB
JavaScript

/**
* Local Filesystem Bridge - Handles local file operations
* Extracted from main.cjs for single responsibility
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const MAX_LOCAL_TREE_DIRECTORIES = 50_000;
const MAX_LOCAL_TREE_ENTRIES = 200_000;
const WINDOWS_ATTRIB_TIMEOUT_MS = 15_000;
function normalizeLocalTreeLimit(value, fallback) {
return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
}
let cachedLocalUserInfo;
function localOwnerFromStat(stat) {
if (process.platform === "win32") return undefined;
if (!stat || typeof stat.uid !== "number") return undefined;
try {
cachedLocalUserInfo ??= os.userInfo();
if (cachedLocalUserInfo.uid === stat.uid && cachedLocalUserInfo.username) {
return cachedLocalUserInfo.username;
}
} catch {
// userInfo() can throw when the process has no passwd entry.
}
return String(stat.uid);
}
function createLocalTreeTraversalBudget(limits = {}) {
return {
directories: 0,
entries: 0,
maxDirectories: normalizeLocalTreeLimit(limits.maxDirectories, MAX_LOCAL_TREE_DIRECTORIES),
maxEntries: normalizeLocalTreeLimit(limits.maxEntries, MAX_LOCAL_TREE_ENTRIES),
};
}
function claimLocalTreeDirectory(budget) {
if (budget.directories >= budget.maxDirectories) {
throw new Error(
`Local directory traversal directory limit exceeded (${budget.maxDirectories}). Select a smaller folder to upload.`,
);
}
budget.directories += 1;
}
function accountLocalTreeEntries(budget, count) {
const next = budget.entries + Math.max(0, Number(count) || 0);
if (next > budget.maxEntries) {
throw new Error(
`Local directory traversal entry limit exceeded (${budget.maxEntries}). Select a smaller folder to upload.`,
);
}
budget.entries = next;
}
/**
* Parse the output of `attrib.exe <dir>\*` into a set of basenames whose
* `H` (hidden) flag is set. Exposed separately so the parser can be
* unit-tested without spawning a real subprocess.
*
* Example attrib output (one entry per line):
* A C:\path\file1.txt
* H C:\path\file2.txt
* A H R C:\path\file3.txt
* H C:\path\hidden_dir [DIR]
*/
function parseAttribOutput(stdout) {
const hidden = new Set();
for (const line of String(stdout).split(/\r?\n/)) {
if (!line) continue;
// Flags occupy the leading columns. Locate the path by the first
// drive letter ("C:\") or UNC prefix ("\\server\share"). The `\\\\`
// alternative has no leading anchor because attrib output has the
// path inside the line, not at column 0 (leading whitespace holds
// the attribute flags).
const pathStart = line.search(/[A-Za-z]:[\\/]|\\\\/);
if (pathStart < 0) continue;
const attrPart = line.substring(0, pathStart).toUpperCase();
if (!attrPart.includes("H")) continue;
const fullPath = line.substring(pathStart).trim();
// Some Windows versions append a trailing literal "[DIR]" marker
// when attrib is invoked with /d. Strip only that exact marker —
// not any arbitrary bracketed suffix — so legitimate filenames
// ending in brackets ("Notes [old]", "Draft [v2].md") survive
// intact and still get matched by hiddenSet.has(entry.name).
const cleaned = fullPath.replace(/\s+\[DIR\]\s*$/, "");
// Always use the win32 basename here — attrib output uses backslash
// separators, and the parser must work under CI on non-Windows hosts.
const basename = path.win32.basename(cleaned);
if (basename) hidden.add(basename);
}
return hidden;
}
/**
* Batch-list hidden filenames in a Windows directory.
*
* Previously we called `attrib` once per entry inside the concurrency
* worker loop. On a directory with ~800 files, that spawns ~800 subprocesses
* and takes ~30 s (see #766). One subprocess call with a wildcard returns
* the hidden attribute for every entry at once, so we replace the per-file
* check with a single upfront pass and a Set lookup in the worker.
*
* Returns the set of hidden basenames (empty on non-Windows or on failure).
*/
async function listWindowsHiddenBasenames(dirPath) {
if (process.platform !== "win32") return new Set();
try {
const pattern = path.join(dirPath, "*");
// `/d` is required so attrib.exe also reports directory entries —
// without it the wildcard is file-centric and hidden folders would
// be silently omitted from the set, causing the SFTP browser to
// show them as not-hidden (a regression from the per-file path
// that passed each entry's full path directly).
const { stdout } = await execFileAsync("attrib.exe", [pattern, "/d"], {
maxBuffer: 64 * 1024 * 1024,
timeout: WINDOWS_ATTRIB_TIMEOUT_MS,
windowsHide: true,
});
return parseAttribOutput(stdout);
} catch (err) {
console.warn(`[localFsBridge] Batch attrib failed for ${dirPath}:`, err.message);
return new Set();
}
}
/**
* List files in a local directory
* Properly handles symlinks by resolving their target type
* On Windows, also detects hidden files using the hidden attribute
*/
async function listLocalDir(event, payload) {
const dirPath = payload.path;
const isWindows = process.platform === "win32";
// Read directory entries and the Windows hidden-attribute set in
// parallel. The hidden lookup is a single subprocess that covers every
// entry in the directory; per-file attrib calls were the ~30 s hotspot
// that #766 reported on an 800-file directory.
const [entries, hiddenSet] = await Promise.all([
fs.promises.readdir(dirPath, { withFileTypes: true }),
isWindows ? listWindowsHiddenBasenames(dirPath) : Promise.resolve(new Set()),
]);
// Stat entries in parallel with a small concurrency limit.
// Serial stats can be very slow on Windows for large dirs.
const CONCURRENCY = 32;
const result = new Array(entries.length);
let cursor = 0;
const worker = async () => {
while (true) {
const i = cursor++;
if (i >= entries.length) return;
const entry = entries[i];
try {
const fullPath = path.join(dirPath, entry.name);
// fs.promises.stat follows symlinks, so we get the target's stats
const stat = await fs.promises.stat(fullPath);
let type;
let linkTarget = null;
if (entry.isSymbolicLink()) {
// This is a symlink - mark it as such and record the target type
type = "symlink";
// stat follows symlinks, so stat.isDirectory() tells us if target is a directory
linkTarget = stat.isDirectory() ? "directory" : "file";
} else if (entry.isDirectory()) {
type = "directory";
} else {
type = "file";
}
// Windows hidden attribute: resolved from the batched lookup.
const hidden = isWindows ? hiddenSet.has(entry.name) : false;
// Follow the target for size/mtime/type; owner is the directory entry itself.
const ownerStat = type === "symlink" ? await fs.promises.lstat(fullPath) : stat;
const owner = localOwnerFromStat(ownerStat);
result[i] = {
name: entry.name,
type,
linkTarget,
size: `${stat.size} bytes`,
lastModified: stat.mtime.toISOString(),
hidden,
...(owner ? { owner } : {}),
};
} catch (err) {
// Handle broken symlinks - lstat doesn't follow symlinks
if (err.code === 'ENOENT' || err.code === 'ELOOP') {
const brokenEntry = entries[i];
try {
const fullPath = path.join(dirPath, brokenEntry.name);
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? hiddenSet.has(brokenEntry.name) : false;
const owner = localOwnerFromStat(lstat);
result[i] = {
name: brokenEntry.name,
type: "symlink",
linkTarget: null, // Broken link - target unknown
size: `${lstat.size} bytes`,
lastModified: lstat.mtime.toISOString(),
hidden,
...(owner ? { owner } : {}),
};
return;
}
} catch (lstatErr) {
console.warn(`Could not lstat ${brokenEntry.name}:`, lstatErr.message);
}
}
console.warn(`Could not stat ${entries[i].name}:`, err.message);
result[i] = null;
}
}
};
const workers = Array.from(
{ length: Math.min(CONCURRENCY, entries.length) },
() => worker(),
);
await Promise.all(workers);
return result.filter(Boolean);
}
/**
* Read a local file.
*
* Optional `maxBytes` returns only the trailing bytes of the file (used by
* Local Terminal histfile seeding so multi-MB shell histories do not stall
* the renderer IPC path).
*/
async function readLocalFile(event, payload) {
const maxBytes =
Number.isFinite(payload?.maxBytes) && payload.maxBytes > 0
? Math.floor(payload.maxBytes)
: null;
if (!maxBytes) {
return fs.promises.readFile(payload.path);
}
const handle = await fs.promises.open(payload.path, "r");
try {
const { size } = await handle.stat();
if (size <= maxBytes) {
const buffer = Buffer.alloc(size);
await handle.read(buffer, 0, size, 0);
return buffer;
}
const buffer = Buffer.alloc(maxBytes);
await handle.read(buffer, 0, maxBytes, size - maxBytes);
return buffer;
} finally {
await handle.close();
}
}
/**
* Write to a local file
*/
async function writeLocalFile(event, payload) {
await fs.promises.writeFile(payload.path, Buffer.from(payload.content));
return true;
}
/**
* Delete a local file or directory
*/
async function deleteLocalFile(event, payload) {
const stat = await fs.promises.lstat(payload.path);
const actualType = stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "file";
if (payload.expectedType && actualType !== payload.expectedType) {
const error = new Error(
`Local target changed before replace: expected ${payload.expectedType}, found ${actualType}`,
);
error.code = "ESTALE";
throw error;
}
if (actualType === "directory") {
await fs.promises.rm(payload.path, { recursive: true, force: true });
} else {
await fs.promises.unlink(payload.path);
}
return true;
}
/**
* Rename a local file or directory
*/
async function renameLocalFile(event, payload) {
await fs.promises.rename(payload.oldPath, payload.newPath);
return true;
}
/**
* Create a local directory
*/
async function mkdirLocal(event, payload) {
try {
await fs.promises.mkdir(payload.path, { recursive: true });
} catch (err) {
// On Windows, mkdir on drive roots (e.g. "E:\") throws EPERM.
// If the directory already exists, that's fine — ignore the error.
try {
const stat = await fs.promises.stat(payload.path);
if (stat.isDirectory()) return true;
} catch { /* stat failed, re-throw original */ }
throw err;
}
return true;
}
/**
* Get local file statistics (follows symlinks — size/mtime of the target).
* Resume and upload sizing rely on target bytes, not the link node.
*/
async function statLocal(event, payload) {
const stat = await fs.promises.stat(payload.path);
return {
name: path.basename(payload.path),
type: stat.isDirectory() ? "directory" : "file",
size: stat.size,
lastModified: stat.mtime.getTime(),
};
}
/**
* Get local path metadata without following symlinks.
* Conflict resolution needs this so Replace can unlink a link instead of
* writing through it via writeLocalFile.
*/
async function lstatLocal(event, payload) {
const stat = await fs.promises.lstat(payload.path);
return {
name: path.basename(payload.path),
type: stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "file",
size: stat.size,
lastModified: stat.mtime.getTime(),
};
}
function throwIfLocalTreeCancelled(isCancelled) {
if (typeof isCancelled === "function" && isCancelled()) {
const error = new Error("Local directory traversal cancelled");
error.code = "ERR_LOCAL_TREE_CANCELLED";
throw error;
}
}
const LOCAL_TREE_ENTRY_BATCH_SIZE = 64;
async function collectLocalTreeEntries(rootPath, limits = {}, onProgress, isCancelled, onEntries) {
const rootStat = await fs.promises.stat(rootPath);
if (!rootStat.isDirectory()) {
throw new Error("Selected path is not a directory");
}
throwIfLocalTreeCancelled(isCancelled);
const traversalBudget = createLocalTreeTraversalBudget(limits);
claimLocalTreeDirectory(traversalBudget);
const rootName = path.basename(rootPath);
const rootRealPath = await fs.promises.realpath(rootPath);
const rootEntry = {
localPath: rootPath,
relativePath: rootName,
type: "directory",
size: rootStat.size,
lastModified: rootStat.mtime.getTime(),
};
// When streaming batches, avoid retaining the full tree in memory for 100k+
// drops. Callers that only need the complete array still get it below.
const retainAll = typeof onEntries !== "function";
const entries = retainAll ? [rootEntry] : null;
let pendingBatch = [rootEntry];
const flushBatch = (force = false) => {
if (typeof onEntries !== "function") return;
if (!force && pendingBatch.length < LOCAL_TREE_ENTRY_BATCH_SIZE) return;
if (pendingBatch.length === 0) return;
const batch = pendingBatch;
pendingBatch = [];
onEntries(batch);
};
flushBatch(true);
const queue = [{
localPath: rootPath,
relativePath: rootName,
realPath: rootRealPath,
ancestorRealPaths: new Set([rootRealPath]),
}];
let queueIndex = 0;
let fileCount = 0;
let directoryCount = 1;
let lastReportedTotal = 0;
const reportProgress = (force = false) => {
if (typeof onProgress !== "function") return;
const entryCount = fileCount + directoryCount;
if (!force && entryCount - lastReportedTotal < 32) return;
lastReportedTotal = entryCount;
onProgress({ fileCount, directoryCount, entryCount });
};
reportProgress(true);
while (queueIndex < queue.length) {
throwIfLocalTreeCancelled(isCancelled);
const current = queue[queueIndex++];
const children = await fs.promises.readdir(current.localPath, { withFileTypes: true });
accountLocalTreeEntries(traversalBudget, children.length);
children.sort((a, b) => a.name.localeCompare(b.name));
const metadataConcurrency = 32;
for (let start = 0; start < children.length; start += metadataConcurrency) {
throwIfLocalTreeCancelled(isCancelled);
const inspected = (await Promise.all(
children.slice(start, start + metadataConcurrency).map(async (child) => {
const childPath = path.join(current.localPath, child.name);
const childRelativePath = `${current.relativePath}/${child.name}`;
try {
// Use lstat to distinguish links, then stat the target. Directory
// links retain the established folder-upload behavior, while the
// real-path ancestor chain prevents junction/symlink cycles.
// Ordinary files/dirs skip realpath: node_modules-sized trees pay
// an extra syscall per directory otherwise, which shows up as
// "scanning" stall before the first byte is uploaded.
const linkStat = await fs.promises.lstat(childPath);
const followLink = linkStat.isSymbolicLink();
const stat = followLink
? await fs.promises.stat(childPath).catch(() => linkStat)
: linkStat;
const isDirectory = stat.isDirectory();
// Ordinary directories keep their ancestor identity by composing
// from the already-resolved parent. Only directory links pay for
// realpath, which is what cycle detection actually needs.
const realPath = !isDirectory
? null
: followLink
? await fs.promises.realpath(childPath)
: path.join(current.realPath, child.name);
const isCycle = !!realPath && current.ancestorRealPaths.has(realPath);
const ancestorRealPaths = realPath
? new Set([...current.ancestorRealPaths, realPath])
: current.ancestorRealPaths;
return {
childPath,
childRelativePath,
stat,
isDirectory,
isCycle,
realPath,
ancestorRealPaths,
};
} catch (error) {
// A folder can change while it is being scanned. Match the
// tolerant browser traversal and skip entries that disappear or
// become inaccessible instead of aborting the whole drop.
console.warn(`Could not inspect ${childPath}:`, error.message);
return null;
}
}),
)).filter(Boolean);
// Promise.all preserves the sorted child order, so restart manifests stay
// deterministic while metadata I/O is parallelized.
for (const child of inspected) {
// Count every directory-shaped alias before cycle suppression. Otherwise
// a non-cyclic symlink fan-out can expand one real tree thousands of
// times without consuming the global directory budget.
if (child.isDirectory) claimLocalTreeDirectory(traversalBudget);
// Cyclic links cannot be represented by a finite copied tree. Skip the
// loop itself instead of misreporting it as a file that later fails.
if (child.isCycle) continue;
const row = {
localPath: child.childPath,
relativePath: child.childRelativePath,
type: child.isDirectory ? "directory" : "file",
size: child.stat.size,
lastModified: child.stat.mtime.getTime(),
};
if (retainAll) entries.push(row);
pendingBatch.push(row);
if (child.isDirectory) {
directoryCount += 1;
queue.push({
localPath: child.childPath,
relativePath: child.childRelativePath,
realPath: child.realPath,
ancestorRealPaths: child.ancestorRealPaths,
});
} else {
fileCount += 1;
}
}
flushBatch();
reportProgress();
}
}
throwIfLocalTreeCancelled(isCancelled);
flushBatch(true);
reportProgress(true);
return retainAll ? entries : [];
}
async function listLocalTree(event, payload) {
const progressChannel = typeof payload?.progressChannel === "string" && payload.progressChannel
? payload.progressChannel
: null;
const cancelChannel = typeof payload?.cancelChannel === "string" && payload.cancelChannel
? payload.cancelChannel
: null;
let cancelled = false;
const onCancel = () => {
cancelled = true;
};
// Lazy-require so unit tests can import collectLocalTreeEntries without a
// full Electron binary (top-level require("electron") breaks node --test).
let electronIpcMain = null;
if (cancelChannel) {
try {
electronIpcMain = require("electron").ipcMain;
electronIpcMain.on(cancelChannel, onCancel);
} catch {
electronIpcMain = null;
}
}
const onProgress = progressChannel
? (stats) => {
try {
event.sender.send(progressChannel, stats);
} catch {
// Renderer may have gone away mid-scan.
}
}
: undefined;
const entriesChannel = typeof payload?.entriesChannel === "string" && payload.entriesChannel
? payload.entriesChannel
: null;
const onEntries = entriesChannel
? (batch) => {
try {
// Always send plain arrays for entry batches. The stream end marker is
// a separate object so the preload can keep its listener until every
// nested batch has been delivered (invoke reply races with send).
event.sender.send(entriesChannel, batch);
} catch {
// Renderer may have gone away mid-scan.
}
}
: undefined;
try {
return await collectLocalTreeEntries(
payload.path,
payload.limits || {},
onProgress,
() => cancelled,
onEntries,
);
} finally {
// Must be sent after the last entry batch and before the invoke resolves
// is not enough alone — preload must wait for this marker before removing
// its listener, otherwise deep nested files (discovered late) are dropped.
if (entriesChannel) {
try {
event.sender.send(entriesChannel, { type: "tree-end" });
} catch {
// Renderer may have gone away mid-scan.
}
}
if (cancelChannel && electronIpcMain) {
electronIpcMain.removeListener(cancelChannel, onCancel);
}
}
}
/**
* Get the home directory
*/
async function getHomeDir() {
return os.homedir();
}
/**
* Get system info (username and hostname)
*/
async function getSystemInfo() {
return {
username: os.userInfo().username,
hostname: os.hostname(),
};
}
/**
* Read system known_hosts file
*/
async function readKnownHosts() {
const homeDir = os.homedir();
const knownHostsPaths = [];
if (process.platform === "win32") {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push(path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts"));
} else if (process.platform === "darwin") {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
} else {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
}
let combinedContent = "";
for (const knownHostsPath of knownHostsPaths) {
try {
if (fs.existsSync(knownHostsPath)) {
const content = fs.readFileSync(knownHostsPath, "utf8");
if (content.trim()) {
combinedContent += content + "\n";
}
}
} catch (err) {
console.warn(`Failed to read known_hosts from ${knownHostsPath}:`, err.message);
}
}
return combinedContent || null;
}
async function listDrives() {
if (process.platform !== "win32") return [];
const letters = [];
for (let i = 65; i <= 90; i++) {
letters.push(String.fromCharCode(i));
}
const results = await Promise.allSettled(
letters.map((letter) => fs.promises.access(letter + ":\\"))
);
return letters.filter((_, idx) => results[idx].status === "fulfilled").map((letter) => letter + ":");
}
async function extractLocalArchive(_event, payload) {
const { extractLocalArchiveFile } = require("./sftpBridge/archiveExtract.cjs");
return extractLocalArchiveFile(payload?.path);
}
/**
* Register IPC handlers for local filesystem operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:local:list", listLocalDir);
ipcMain.handle("netcatty:local:read", readLocalFile);
ipcMain.handle("netcatty:local:write", writeLocalFile);
ipcMain.handle("netcatty:local:delete", deleteLocalFile);
ipcMain.handle("netcatty:local:rename", renameLocalFile);
ipcMain.handle("netcatty:local:extract", extractLocalArchive);
ipcMain.handle("netcatty:local:mkdir", mkdirLocal);
ipcMain.handle("netcatty:local:stat", statLocal);
ipcMain.handle("netcatty:local:lstat", lstatLocal);
ipcMain.handle("netcatty:local:tree", listLocalTree);
ipcMain.handle("netcatty:local:homedir", getHomeDir);
ipcMain.handle("netcatty:local:drives", listDrives);
ipcMain.handle("netcatty:system:info", getSystemInfo);
ipcMain.handle("netcatty:known-hosts:read", readKnownHosts);
}
module.exports = {
WINDOWS_ATTRIB_TIMEOUT_MS,
registerHandlers,
listLocalDir,
readLocalFile,
writeLocalFile,
deleteLocalFile,
renameLocalFile,
extractLocalArchive,
mkdirLocal,
statLocal,
lstatLocal,
collectLocalTreeEntries,
createLocalTreeTraversalBudget,
MAX_LOCAL_TREE_DIRECTORIES,
MAX_LOCAL_TREE_ENTRIES,
listLocalTree,
getHomeDir,
listDrives,
getSystemInfo,
readKnownHosts,
parseAttribOutput,
listWindowsHiddenBasenames,
};