"use strict"; const crypto = require("node:crypto"); const net = require("node:net"); const { normalizeProfile } = require("./profileService"); const FORMAT = "chj-server-profiles"; const VERSION = 1; const MAX_BYTES = 5 * 1024 * 1024; const FIELDS = ["name", "host", "username", "port"]; function portableProfile(input) { if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Invalid server profile."); for (const field of ["name", "host", "username"]) { if (typeof input[field] !== "string" || !input[field].trim()) throw new Error(`Missing or invalid ${field}.`); } if (input.name.length > 100 || /[\0\r\n]/.test(input.name)) throw new Error("Invalid server name (maximum 100 characters)."); if (input.port !== undefined && !/^[0-9]+$/.test(String(input.port))) throw new Error("Invalid SSH port."); if (input.port !== undefined && (Number(input.port) < 1 || Number(input.port) > 65535)) throw new Error("SSH port must be between 1 and 65535."); const profile = normalizeProfile({ label: input.name, host: input.host, username: input.username, port: input.port ?? 22 }); return { name: profile.label, host: profile.host, username: profile.username, port: profile.port }; } function identity(profile) { const host = net.isIP(profile.host) === 6 ? new URL(`http://[${profile.host}]/`).hostname : profile.host.toLowerCase(); return JSON.stringify([host, profile.port, profile.username]); } // RFC 4180 quoting, including embedded commas, quotes and line breaks. function csvRows(content) { const rows = []; let row = [], cell = "", quoted = false, closed = false; for (let i = 0; i < content.length; i++) { const char = content[i]; if (quoted) { if (char === '"' && content[i + 1] === '"') { cell += '"'; i++; } else if (char === '"') { quoted = false; closed = true; } else cell += char; } else if (char === '"' && !cell && !closed) quoted = true; else if (char === ",") { row.push(cell); cell = ""; closed = false; } else if (char === "\n" || char === "\r") { if (char === "\r" && content[i + 1] === "\n") i++; row.push(cell); rows.push(row); row = []; cell = ""; closed = false; } else { if (closed || char === '"') throw new Error("Malformed CSV quoting."); cell += char; } } if (quoted) throw new Error("Unterminated CSV field."); if (cell || row.length || closed) { row.push(cell); rows.push(row); } return rows; } function decodeCsvCell(value) { return /^'(?:[=+@\-\t\r]|')/.test(value) ? value.slice(1) : value; } function parseProfiles(content) { if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > MAX_BYTES) throw new Error("Profile file exceeds the 5 MiB limit."); content = content.replace(/^\uFEFF/, "").trim(); let servers, format; if (/^[{\[]/.test(content)) { const document = JSON.parse(content); if (document.format !== FORMAT || document.version !== VERSION || !Array.isArray(document.servers)) throw new Error("Unsupported server profile format or version."); servers = document.servers; format = "json"; } else { const [header, ...rows] = csvRows(content); if (!header || header.length !== FIELDS.length || new Set(header).size !== FIELDS.length || FIELDS.some((field) => !header.includes(field))) throw new Error("CSV columns must be name,host,username,port."); servers = rows.filter((row) => row.some(Boolean)).map((row) => { if (row.length !== header.length) throw new Error("CSV row has an unexpected number of columns."); return Object.fromEntries(header.map((field, index) => [field, decodeCsvCell(row[index])])); }); format = "csv"; } if (!servers.length || servers.length > 10000) throw new Error("Import must contain between 1 and 10000 servers."); return { format, servers: servers.map((server, index) => { try { return portableProfile(server); } catch (error) { throw new Error(`Server ${index + 1}: ${error.message}`); } }) }; } function serializeProfiles(profiles, format = "json") { const servers = profiles.map((profile) => portableProfile({ name: profile.label, host: profile.host, username: profile.username, port: profile.port })); if (format === "json") return JSON.stringify({ format: FORMAT, version: VERSION, servers }, null, 2) + "\n"; if (format !== "csv") throw new Error("Unsupported export format."); const escape = (value) => { let cell = String(value); // Prevent spreadsheet formula execution; the importer reverses this prefix. if (/^[=+@\-\t\r']/.test(cell)) cell = "'" + cell; return /[",\r\n]/.test(cell) ? '"' + cell.replace(/"/g, '""') + '"' : cell; }; return "\uFEFF" + [FIELDS.join(","), ...servers.map((server) => FIELDS.map((field) => escape(server[field])).join(","))].join("\r\n") + "\r\n"; } class ProfileTransfer { constructor(profileService) { this.profiles = profileService; this.pending = null; } preview(content) { const parsed = parseProfiles(content); const seen = new Set(this.profiles.list().map(identity)); const rows = parsed.servers.map((server) => { const duplicate = seen.has(identity(server)); seen.add(identity(server)); return { ...server, duplicate }; }); const token = crypto.randomUUID(); this.pending = { token, servers: parsed.servers, generation: this.profiles.vaultStore.generation, expires: Date.now() + 15 * 60 * 1000 }; return { token, format: parsed.format, servers: rows }; } cancel() { this.pending = null; } commit(token, policy) { const pending = this.pending; if (!pending || pending.token !== token || pending.generation !== this.profiles.vaultStore.generation || pending.expires < Date.now()) throw new Error("Import preview expired. Select the file again."); if (!["skip", "update", "create"].includes(policy)) throw new Error("Invalid duplicate policy."); const result = { created: 0, updated: 0, skipped: 0 }; this.profiles.vaultStore.update((data) => { const existing = new Map(data.profiles.map((profile) => [identity(profile), profile])); for (const server of pending.servers) { const match = existing.get(identity(server)); if (match && policy === "skip") { result.skipped++; continue; } const previous = match && policy === "update" ? match : null; const profile = normalizeProfile({ label: server.name, ...server, tags: previous?.tags || [] }, previous); if (previous) { data.profiles[data.profiles.findIndex((item) => item.id === previous.id)] = profile; if (data.secrets?.profilePasswords) delete data.secrets.profilePasswords[previous.id]; result.updated++; } else { data.profiles.push(profile); result.created++; } existing.set(identity(server), profile); } }); this.cancel(); return result; } } module.exports = { FORMAT, VERSION, MAX_BYTES, ProfileTransfer, parseProfiles, serializeProfiles, identity };