import dns from "node:dns/promises"; import { readFileSync } from "node:fs"; import path from "node:path"; import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2"; import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types"; import { t } from "../i18n"; import { expandTilde } from "../store/ssh-config"; import type { FileSystemProvider } from "./provider"; const S_IFMT = 0o170000; const S_IFDIR = 0o040000; const S_IFLNK = 0o120000; /** * Owner/group from the ls-style longname openssh sends with each entry * ("-rw-r--r-- 1 root wheel 1024 Jan 1 00:00 name"). Servers aren't * required to use this format — bail to undefined when it doesn't parse. */ function ownerFromLongname(longname: string | undefined): { owner?: string; group?: string } { if (!longname || !/^[-dlbcps]/.test(longname)) return {}; const fields = longname.split(/\s+/); if (fields.length < 5) return {}; return { owner: fields[2], group: fields[3] }; } function typeFromMode(mode: number): EntryType { const fmt = mode & S_IFMT; if (fmt === S_IFDIR) return "dir"; if (fmt === S_IFLNK) return "symlink"; return "file"; } export class SftpProvider implements FileSystemProvider { readonly kind = "sftp" as const; readonly fsKey: string; private closedByUs = false; private constructor( private client: Client, private sftp: SFTPWrapper, fsKey: string, ) { this.fsKey = fsKey; } /** Resolves once the SSH connection is ready and the SFTP subsystem is open. */ static async connect( config: ConnectionConfig, onClose: (reason: string) => void, ): Promise { const connectConfig: ConnectConfig = { host: config.host, port: config.port, username: config.username, readyTimeout: 20_000, keepaliveInterval: 15_000, keepaliveCountMax: 3, }; const agentSock = process.env.SSH_AUTH_SOCK ?? (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : undefined); switch (config.auth.kind) { case "password": connectConfig.password = config.auth.password; break; case "agent": if (!agentSock) throw new Error(t("errors.sshAgentUnavailable")); connectConfig.agent = agentSock; break; case "key": { // Alongside a passphrase-less key file, offer the agent too — it // covers encrypted keys already loaded via ssh-add/UseKeychain. try { connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath)); } catch { if (!agentSock) throw new Error(t("errors.identityFileNotFound", { path: config.auth.privateKeyPath })); } if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase; else if (agentSock) connectConfig.agent = agentSock; break; } } // Filesystem identity: resolve the host so a hostname and its IP compare // equal (self-copy guards). Falls back to the raw host string offline. let resolvedHost = config.host.toLowerCase(); try { resolvedHost = (await dns.lookup(config.host)).address; } catch { // unresolvable (e.g. already an IP on some systems) — keep the string } const fsKey = `${resolvedHost}:${config.port}`; const client = new Client(); const provider = await new Promise((resolve, reject) => { client .on("ready", () => { client.sftp((err, sftp) => { if (err) { client.end(); reject(err); } else { resolve(new SftpProvider(client, sftp, fsKey)); } }); }) .on("error", reject) .connect(connectConfig); }); let notified = false; const notify = (reason: string) => { if (notified || provider.closedByUs) return; notified = true; onClose(reason); }; client.on("close", () => notify("connection closed")); client.on("error", (err) => notify(err.message)); return provider; } join = (...parts: string[]) => path.posix.join(...parts); dirname = (p: string) => path.posix.dirname(p); basename = (p: string) => path.posix.basename(p); normalize = (p: string) => path.posix.normalize(p); homeDir(): Promise { return new Promise((resolve, reject) => { this.sftp.realpath(".", (err, abs) => (err ? reject(err) : resolve(abs))); }); } list(dir: string): Promise { return new Promise((resolve, reject) => { this.sftp.readdir(dir, (err, entries) => { if (err) return reject(err); resolve( entries .filter((e) => e.filename !== "." && e.filename !== "..") .map((e) => ({ name: e.filename, path: path.posix.join(dir, e.filename), type: typeFromMode(e.attrs.mode), size: e.attrs.size, mtime: e.attrs.mtime * 1000, // sftp attrs are seconds mode: e.attrs.mode & 0o7777, ...ownerFromLongname(e.longname), })), ); }); }); } stat(p: string): Promise { return new Promise((resolve, reject) => { this.sftp.lstat(p, (err, stats) => { if (err) return reject(err); resolve({ name: path.posix.basename(p), path: p, type: typeFromMode(stats.mode), size: stats.size, mtime: stats.mtime * 1000, mode: stats.mode & 0o7777, }); }); }); } mkdir(p: string): Promise { return new Promise((resolve, reject) => { this.sftp.mkdir(p, (err) => (err ? reject(err) : resolve())); }); } createEmptyFile(p: string): Promise { return new Promise((resolve, reject) => { this.sftp.writeFile(p, "", { flag: "wx" }, (err) => (err ? reject(err) : resolve())); }); } rename(oldPath: string, newPath: string): Promise { return new Promise((resolve, reject) => { this.sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve())); }); } deleteFile(p: string): Promise { return new Promise((resolve, reject) => { this.sftp.unlink(p, (err) => (err ? reject(err) : resolve())); }); } deleteEmptyDir(p: string): Promise { return new Promise((resolve, reject) => { this.sftp.rmdir(p, (err) => (err ? reject(err) : resolve())); }); } chmod(p: string, mode: number): Promise { return new Promise((resolve, reject) => { this.sftp.chmod(p, mode, (err) => (err ? reject(err) : resolve())); }); } createReadStream(p: string) { return this.sftp.createReadStream(p); } createWriteStream(p: string) { return this.sftp.createWriteStream(p); } async dispose(): Promise { this.closedByUs = true; this.client.end(); } }