Files
NetMesh/domain/vaultImport.ts
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

1508 lines
48 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Host, HostChainConfig, HostProtocol } from "./models";
import { isEncryptedCredentialPlaceholder } from "./credentials";
import { sanitizeHost } from "./host";
import { hasMacKeychainAgentDirectives } from "./sshAuth";
import {
buildVaultHostFromDraft,
buildVaultHostMergeKey,
type VaultHostKeyPassphrase,
type VaultHostDraftProtocol,
} from "./vaultHostCreate";
export {
buildVaultHostEndpointKey,
buildVaultHostMergeKey,
} from "./vaultHostCreate";
import { parseQuickConnectInput } from "./quickConnect";
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
export {
exportHostsToCsvWithStats,
getVaultCsvTemplate,
resolveVaultCsvHostKeyPath,
} from "./vaultImport/csvExport";
interface ParsedJumpHost {
hostname: string;
username?: string;
port?: number;
}
const parseJumpHostSpec = (spec: string): ParsedJumpHost | null => {
const trimmed = spec.trim();
if (!trimmed || trimmed.toLowerCase() === "none") return null;
if (trimmed.startsWith("ssh://")) {
try {
const url = new URL(trimmed);
return {
hostname: url.hostname,
username: url.username || undefined,
port: url.port ? parseInt(url.port, 10) : undefined,
};
} catch {
return null;
}
}
let username: string | undefined;
let hostname: string;
let port: number | undefined;
let rest = trimmed;
const atIndex = rest.indexOf("@");
if (atIndex !== -1) {
username = rest.slice(0, atIndex);
rest = rest.slice(atIndex + 1);
}
if (rest.startsWith("[")) {
const bracketEnd = rest.indexOf("]");
if (bracketEnd !== -1) {
hostname = rest.slice(1, bracketEnd);
const portPart = rest.slice(bracketEnd + 1);
if (portPart.startsWith(":")) {
const p = parseInt(portPart.slice(1), 10);
if (Number.isFinite(p) && p >= 1 && p <= 65535) port = p;
}
} else {
hostname = rest;
}
} else {
const colonIndex = rest.lastIndexOf(":");
if (colonIndex !== -1) {
const portStr = rest.slice(colonIndex + 1);
const p = parseInt(portStr, 10);
if (Number.isFinite(p) && p >= 1 && p <= 65535) {
port = p;
hostname = rest.slice(0, colonIndex);
} else {
hostname = rest;
}
} else {
hostname = rest;
}
}
if (!hostname) return null;
return { hostname, username, port };
};
const parseProxyJump = (value: string): ParsedJumpHost[] => {
if (!value || value.toLowerCase() === "none") return [];
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(parseJumpHostSpec)
.filter((h): h is ParsedJumpHost => h !== null);
};
export type VaultImportFormat =
| "putty"
| "mobaxterm"
| "csv"
| "securecrt"
| "ssh_config";
export const VAULT_IMPORT_FORMATS: VaultImportFormat[] = [
"csv",
"putty",
"mobaxterm",
"securecrt",
"ssh_config",
];
type VaultImportIssueLevel = "warning" | "error";
export interface VaultImportIssue {
level: VaultImportIssueLevel;
message: string;
}
export interface VaultImportStats {
parsed: number;
imported: number;
skipped: number;
duplicates: number;
}
export interface VaultImportResult {
hosts: Host[];
groups: string[];
issues: VaultImportIssue[];
stats: VaultImportStats;
keyPassphrases?: VaultHostKeyPassphrase[];
keyPassphraseCandidates?: VaultHostKeyPassphrase[];
}
export type VaultImportDestination =
| { mode: "preserve" }
| { mode: "group"; group: string };
export type ApplyVaultImportDestinationOptions = {
/**
* When false, only rewrite groups. SecureCRT keeps distinct session files that
* share an endpoint even after an import-location override.
* @default true
*/
collapseDuplicateEndpoints?: boolean;
/**
* Return false to keep a host even when another shares its merge key after the
* destination rewrite (plugin hosts that differ by configuration/credentials).
*/
isCollapsible?: (host: Host) => boolean;
};
export function mergeVaultImportIssues(
...groups: ReadonlyArray<ReadonlyArray<VaultImportIssue>>
): VaultImportIssue[] {
const seen = new Set<string>();
return groups.flatMap((issues) => issues.filter((issue) => {
const key = `${issue.level}\u0000${issue.message}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}));
}
const DEFAULT_SSH_PORT = 22;
const normalizeGroupPath = (raw: string | undefined): string | undefined => {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
const normalized = trimmed.replace(/\\/g, "/");
const parts = normalized.split("/").map((p) => p.trim()).filter(Boolean);
if (parts.length === 0) return undefined;
return parts.join("/");
};
export function applyVaultImportDestination(
result: VaultImportResult,
destination: VaultImportDestination,
options?: ApplyVaultImportDestinationOptions,
): VaultImportResult {
if (destination.mode === "preserve") return result;
const group = normalizeGroupPath(destination.group);
if (!group) return result;
const rewrittenHosts = result.hosts.map((host) => ({ ...host, group }));
// SecureCRT (and similar) keep same-endpoint sessions as distinct imports.
if (options?.collapseDuplicateEndpoints === false) {
return {
...result,
hosts: rewrittenHosts,
groups: [group],
};
}
// Destination rewriting can collapse previously distinct group-scoped sessions
// onto one merge key; re-dedupe so identical endpoints are not imported twice.
// Plugin hosts can opt out via isCollapsible so configuration/credential
// differences survive a shared destination group.
const { hosts, duplicates } = dedupeHosts(rewrittenHosts, {
isCollapsible: options?.isCollapsible,
});
const retainedIds = new Set(hosts.map((host) => host.id));
const retainedByMergeKey = new Map(
hosts
.filter((host) => options?.isCollapsible?.(host) !== false)
.map((host) => [hostKey(host), host]),
);
const originalById = new Map(result.hosts.map((host) => [host.id, host]));
const remapHostId = (hostId: string): string | undefined => {
if (retainedIds.has(hostId)) return hostId;
const original = originalById.get(hostId);
if (!original) return undefined;
if (options?.isCollapsible?.({ ...original, group }) === false) return undefined;
return retainedByMergeKey.get(hostKey({ ...original, group }))?.id;
};
const selectedKeyByHostId = new Map(
hosts.map((host) => {
const selected = host.identityFilePaths?.find((path) => path.trim())?.trim();
return [host.id, selected ? normalizeKeyPathKey(selected) : undefined] as const;
}),
);
const remapKeyPassphrases = (
entries: VaultHostKeyPassphrase[] | undefined,
remapOptions?: { matchSelectedKey?: boolean },
): VaultHostKeyPassphrase[] | undefined => {
if (!entries) return entries;
const remapped: VaultHostKeyPassphrase[] = [];
const seen = new Set<string>();
for (const entry of entries) {
const nextHostId = remapHostId(entry.hostId);
if (!nextHostId) continue;
// Match CSV same-group dedupe: never attach a passphrase for a key the
// retained host did not keep as its selected identity file.
if (remapOptions?.matchSelectedKey) {
const selectedKey = selectedKeyByHostId.get(nextHostId);
if (!selectedKey || normalizeKeyPathKey(entry.keyPath) !== selectedKey) continue;
}
const dedupeKey = `${nextHostId}\u0000${normalizeKeyPathKey(entry.keyPath)}\u0000${entry.passphrase}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
remapped.push(nextHostId === entry.hostId ? entry : { ...entry, hostId: nextHostId });
}
return remapped;
};
return {
...result,
hosts,
groups: [group],
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
// Keep remapped candidates for alias conflict checks (same as CSV same-group
// dedupe); the import handler still gates saves on the retained key path.
keyPassphraseCandidates: remapKeyPassphrases(result.keyPassphraseCandidates),
stats: {
...result.stats,
imported: hosts.length,
duplicates: result.stats.duplicates + duplicates,
},
};
}
const normalizeProtocol = (
raw: string | undefined,
): Exclude<HostProtocol, "mosh" | "et"> | undefined => {
const s = raw?.trim().toLowerCase();
if (!s) return undefined;
if (s === "ssh" || s === "ssh2" || s === "ssh-2") return "ssh";
if (s === "telnet") return "telnet";
if (s === "local") return "local";
return undefined;
};
const parsePort = (raw: string | undefined): number | undefined => {
const s = raw?.trim();
if (!s) return undefined;
const n = parseInt(s, 10);
if (!Number.isFinite(n) || n < 1 || n > 65535) return undefined;
return n;
};
const splitTags = (raw: string | undefined): string[] => {
const s = raw?.trim();
if (!s) return [];
return s
.split(/[,;]/g)
.map((t) => t.trim())
.filter(Boolean);
};
const hostKey = buildVaultHostMergeKey;
const normalizeKeyPathKey = (keyPath: string): string => {
const isWindowsPath = /^[A-Za-z]:[\\/]/u.test(keyPath) || /^[\\/]{2}/u.test(keyPath);
return isWindowsPath ? keyPath.replace(/\\/g, "/").toLowerCase() : keyPath;
};
const createHost = (input: {
label?: string;
hostname: string;
username?: string;
password?: string;
keyPath?: string;
port?: number;
protocol?: VaultHostDraftProtocol;
group?: string;
tags?: string[];
notes?: string;
}): Host => {
const built = buildVaultHostFromDraft(input);
if (!built.ok) {
throw new Error(built.error);
}
return built.host;
};
const dedupeHosts = (
hosts: Host[],
options?: { isCollapsible?: (host: Host) => boolean },
): { hosts: Host[]; duplicates: number } => {
const seen = new Map<string, Host>();
const retained: Host[] = [];
let duplicates = 0;
for (const host of hosts) {
if (options?.isCollapsible?.(host) === false) {
retained.push(host);
continue;
}
const key = hostKey(host);
const existing = seen.get(key);
if (!existing) {
seen.set(key, host);
retained.push(host);
continue;
}
duplicates++;
const mergedTags = Array.from(new Set([...(existing.tags ?? []), ...(host.tags ?? [])]));
existing.tags = mergedTags;
if (!existing.password && host.password) existing.password = host.password;
if (!existing.identityFilePaths?.some((path) => path.trim()) && host.identityFilePaths?.length) {
existing.identityFilePaths = host.identityFilePaths;
existing.authMethod = host.authMethod;
existing.authPolicyVersion = host.authPolicyVersion;
existing.useSshAgent = host.useSshAgent;
}
// Only graft referenced credentials onto a retained host that has no auth
// source yet. Otherwise identity auth can override an existing key path.
const retainedHasCredentialSource = Boolean(
existing.identityId
|| existing.telnetIdentityId
|| existing.identityFileId
|| existing.identityFilePaths?.some((path) => path.trim())
|| (typeof existing.password === "string" && existing.password.length > 0)
);
if (!retainedHasCredentialSource) {
if (host.identityId) existing.identityId = host.identityId;
if (host.telnetIdentityId) existing.telnetIdentityId = host.telnetIdentityId;
if (host.identityFileId) existing.identityFileId = host.identityFileId;
}
if (existing.group == null && host.group != null) existing.group = host.group;
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
existing.label = host.label;
}
}
return { hosts: retained, duplicates };
};
const uniq = (items: string[]) => Array.from(new Set(items.filter(Boolean)));
const looksLikeHostnameToken = (token: string): boolean => {
const qc = parseQuickConnectInput(token.trim());
return qc !== null;
};
const parseTarget = (
raw: string,
): { hostname: string; username?: string; port?: number; protocol?: Exclude<HostProtocol, "mosh" | "et"> } | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
// URL form: ssh://user@host:22
if (trimmed.includes("://")) {
try {
const url = new URL(trimmed);
const protocol = normalizeProtocol(url.protocol.replace(/:$/, ""));
const hostname = url.hostname;
const port = url.port ? parsePort(url.port) : undefined;
const username = url.username || undefined;
if (!hostname) return null;
return { hostname, username, port, protocol };
} catch {
// fall through
}
}
// host:proto form (seen in some CSV exports)
const protoSuffixMatch = trimmed.match(/^(.*?)(?::|\s+)(ssh|ssh2|telnet|local)$/i);
if (protoSuffixMatch) {
const left = protoSuffixMatch[1].trim();
const protocol = normalizeProtocol(protoSuffixMatch[2]);
const base = parseQuickConnectInput(left);
if (base) return { hostname: base.hostname, username: base.username, port: base.port, protocol };
}
const qc = parseQuickConnectInput(trimmed);
if (qc) return { hostname: qc.hostname, username: qc.username, port: qc.port };
return null;
};
const importFromCsv = (text: string): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
if (rows.length === 0) {
return {
hosts: [],
groups: [],
issues: [{ level: "error", message: "CSV is empty." }],
stats: { parsed: 0, imported: 0, skipped: 0, duplicates: 0 },
};
}
const header = rows[0];
const dataRows = rows.slice(1);
const groupsIdx = findHeaderIndex(header, ["groups", "group", "folder", "path"]);
const labelIdx = findHeaderIndex(header, ["label", "name"]);
const tagsIdx = findHeaderIndex(header, ["tags", "tag"]);
const notesIdx = findHeaderIndex(header, ["notes", "note", "remark", "description", "memo"]);
const hostnameIdx = findHeaderIndex(header, ["hostname", "host", "server"]);
const protocolIdx = findHeaderIndex(header, ["protocol", "proto", "scheme"]);
const portIdx = findHeaderIndex(header, ["port"]);
const usernameIdx = findHeaderIndex(header, ["username", "user", "login"]);
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
const fuzzyNamedPasswordIdx = findHeaderIndex(header, ["password", "passwd"]);
const fuzzyPassIdx = header.findIndex((value) => {
const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]/gu, "");
if (!normalized.startsWith("pass")) return false;
if (!normalized.startsWith("passphrase")) return true;
const suffix = normalized.slice("passphrase".length);
return keyPathIdx < 0 && ["", "optional", "value"].includes(suffix);
});
const fuzzyPasswordIdx = fuzzyNamedPasswordIdx >= 0 ? fuzzyNamedPasswordIdx : fuzzyPassIdx;
const passwordIdx = exactPasswordIdx >= 0
? exactPasswordIdx
: keyPathIdx < 0 && explicitPassphraseIdx >= 0
? explicitPassphraseIdx
: fuzzyPasswordIdx;
if (hostnameIdx === -1) {
return {
hosts: [],
groups: [],
issues: [
{
level: "error",
message:
"CSV header must include a Hostname column (e.g. Hostname, Host).",
},
],
stats: { parsed: 0, imported: 0, skipped: 0, duplicates: 0 },
};
}
const parsedHosts: Host[] = [];
const keyPassphraseCandidates: Array<{
hostKey: string;
keyPathKey: string;
keyPath: string;
passphrase: string;
}> = [];
const keyPassphrasesByPath = new Map<string, {
keyPath: string;
passphrase?: string;
conflict: boolean;
}>();
let parsed = 0;
let skipped = 0;
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
const hostnameRaw = (row[hostnameIdx] ?? "").trim();
if (!hostnameRaw) continue;
parsed++;
const target = parseTarget(hostnameRaw);
if (!target) {
skipped++;
issues.push({
level: "warning",
message: `CSV row ${i + 2}: invalid hostname value "${hostnameRaw}".`,
});
continue;
}
const group = groupsIdx >= 0 ? normalizeGroupPath(row[groupsIdx]) : undefined;
const label = labelIdx >= 0 ? row[labelIdx] : undefined;
const tags = tagsIdx >= 0 ? splitTags(row[tagsIdx]) : [];
const notesRaw = notesIdx >= 0 ? row[notesIdx] : undefined;
const notes = notesRaw?.trim() || undefined;
const protocol =
normalizeProtocol(protocolIdx >= 0 ? row[protocolIdx] : undefined) ??
target.protocol ??
"ssh";
const port = parsePort(portIdx >= 0 ? row[portIdx] : undefined) ?? target.port;
const username = (usernameIdx >= 0 ? row[usernameIdx] : undefined)?.trim() || target.username;
const password = (passwordIdx >= 0 ? row[passwordIdx] : undefined) || undefined;
const keyPathRaw = (keyPathIdx >= 0 ? row[keyPathIdx] : undefined)?.trim();
const keyPath = keyPathRaw ? decodeCsvKeyPath(keyPathRaw) : undefined;
const passphraseRaw = (passphraseIdx >= 0 ? row[passphraseIdx] : undefined) || undefined;
const decodedPassphrase = passphraseRaw ? decodeCsvPassphrase(passphraseRaw) : undefined;
const passphrase = decodedPassphrase && !isEncryptedCredentialPlaceholder(decodedPassphrase)
? decodedPassphrase
: undefined;
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
issues.push({
level: "warning",
message: `CSV row ${i + 2}: Passphrase was ignored because encrypted credential values cannot be imported.`,
});
}
if (passphrase && !keyPath) {
issues.push({
level: "warning",
message: `CSV row ${i + 2}: Passphrase was ignored because KeyPath is empty.`,
});
}
const host = createHost({
label,
hostname: target.hostname,
username,
password,
keyPath,
port,
protocol,
group,
tags,
notes,
});
parsedHosts.push(host);
if (keyPath && passphrase) {
const keyPathKey = normalizeKeyPathKey(keyPath);
const stored = keyPassphrasesByPath.get(keyPathKey);
if (!stored) {
keyPassphrasesByPath.set(keyPathKey, { keyPath, passphrase, conflict: false });
} else if (stored.passphrase !== passphrase && !stored.conflict) {
stored.conflict = true;
issues.push({
level: "warning",
message: `CSV contains conflicting passphrases for KeyPath "${keyPath}"; no passphrase was saved for that path.`,
});
}
keyPassphraseCandidates.push({
hostKey: buildVaultHostMergeKey(host),
keyPathKey,
keyPath,
passphrase,
});
}
}
const { hosts, duplicates } = dedupeHosts(parsedHosts);
const keyPassphrases = hosts.flatMap((host) => {
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
if (!selectedKeyPath) return [];
const selectedKeyPathKey = normalizeKeyPathKey(selectedKeyPath);
const candidate = keyPassphraseCandidates.find((entry) => (
entry.hostKey === buildVaultHostMergeKey(host)
&& entry.keyPathKey === selectedKeyPathKey
));
const entry = candidate ? keyPassphrasesByPath.get(candidate.keyPathKey) : undefined;
return entry?.passphrase && !entry.conflict
? [{ hostId: host.id, keyPath: entry.keyPath, passphrase: entry.passphrase }]
: [];
});
const allKeyPassphraseCandidates = hosts.flatMap((host) => {
return keyPassphraseCandidates
.filter((entry) => entry.hostKey === buildVaultHostMergeKey(host))
.map((entry) => ({
hostId: host.id,
keyPath: entry.keyPath,
passphrase: entry.passphrase,
}));
});
const groups = uniq(hosts.map((h) => h.group).filter(Boolean) as string[]);
return {
hosts,
groups,
issues,
keyPassphrases,
keyPassphraseCandidates: allKeyPassphraseCandidates,
stats: {
parsed,
imported: hosts.length,
skipped,
duplicates,
},
};
};
const decodeRegString = (raw: string): string => {
const trimmed = raw.trim();
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed;
const inner = trimmed.slice(1, -1);
return inner.replace(/\\\\/g, "\\").replace(/\\"/g, '"');
};
const parseDword = (raw: string): number | undefined => {
const trimmed = raw.trim();
const m = trimmed.match(/^dword:([0-9a-fA-F]{8})$/);
if (!m) return undefined;
const n = parseInt(m[1], 16);
if (!Number.isFinite(n) || n < 1 || n > 65535) return undefined;
return n;
};
const decodePuttySessionName = (raw: string): string => {
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
};
const importFromPuttyReg = (text: string): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const lines = text.split(/\r?\n/);
type Session = {
name: string;
hostname?: string;
username?: string;
port?: number;
protocol?: Exclude<HostProtocol, "mosh" | "et">;
};
const sessions: Session[] = [];
let current: Session | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const sectionMatch = trimmed.match(
/^\[HKEY_(?:CURRENT_USER|LOCAL_MACHINE)\\Software\\SimonTatham\\PuTTY\\Sessions\\(.+)\]$/i,
);
if (sectionMatch) {
if (current) sessions.push(current);
current = { name: decodePuttySessionName(sectionMatch[1]) };
continue;
}
if (!current) continue;
const kvMatch = trimmed.match(/^"([^"]+)"=(.+)$/);
if (!kvMatch) continue;
const key = kvMatch[1];
const value = kvMatch[2];
if (key === "HostName") current.hostname = decodeRegString(value);
else if (key === "UserName") current.username = decodeRegString(value);
else if (key === "PortNumber") current.port = parseDword(value);
else if (key === "Protocol") current.protocol = normalizeProtocol(decodeRegString(value));
}
if (current) sessions.push(current);
const parsedHosts: Host[] = [];
let parsed = 0;
let skipped = 0;
for (const s of sessions) {
if (!s.hostname) continue;
parsed++;
const protocol = s.protocol ?? "ssh";
if (protocol !== "ssh" && protocol !== "telnet") {
skipped++;
issues.push({
level: "warning",
message: `PuTTY session "${s.name}": unsupported protocol.`,
});
continue;
}
parsedHosts.push(
createHost({
label: s.name,
hostname: s.hostname,
username: s.username,
port: s.port ?? (protocol === "ssh" ? DEFAULT_SSH_PORT : 23),
protocol,
}),
);
}
const { hosts, duplicates } = dedupeHosts(parsedHosts);
return {
hosts,
groups: [],
issues,
stats: { parsed, imported: hosts.length, skipped, duplicates },
};
};
const importFromSshConfig = (text: string): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const lines = text.split(/\r?\n/);
type Block = {
patterns: string[];
hostname?: string;
username?: string;
port?: number;
proxyJump?: string;
identityFiles?: string[];
identityAgent?: string;
identitiesOnly?: boolean;
addKeysToAgent?: string;
useKeychain?: boolean;
forwardX11?: boolean;
};
const blocks: Block[] = [];
let current: Block | null = null;
const flush = () => {
if (current) blocks.push(current);
current = null;
};
for (const line of lines) {
const cleaned = line.replace(/#.*/, "").trim();
if (!cleaned) continue;
const tokens = cleaned.split(/\s+/).filter(Boolean);
const keyword = tokens[0]?.toLowerCase();
if (!keyword) continue;
if (keyword === "host") {
flush();
current = { patterns: tokens.slice(1) };
continue;
}
if (keyword === "match") {
flush();
continue;
}
if (!current) continue;
const value = tokens.slice(1).join(" ");
if (!value) continue;
if (keyword === "hostname") current.hostname = value;
else if (keyword === "user") current.username = value;
else if (keyword === "port") current.port = parsePort(value);
else if (keyword === "proxyjump") current.proxyJump = value;
else if (keyword === "forwardx11") current.forwardX11 = value.toLowerCase() === "yes";
else if (keyword === "identityagent") current.identityAgent = value.replace(/^["']|["']$/g, "");
else if (keyword === "identitiesonly") current.identitiesOnly = value.toLowerCase() === "yes";
else if (keyword === "addkeystoagent") current.addKeysToAgent = value.toLowerCase();
else if (keyword === "usekeychain") current.useKeychain = value.toLowerCase() === "yes";
else if (keyword === "identityfile") {
if (!current.identityFiles) current.identityFiles = [];
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
const unquoted = value.replace(/^["']|["']$/g, "");
current.identityFiles.push(unquoted);
}
}
flush();
const parsedHosts: Host[] = [];
// Use hostname+port as key instead of host.id to survive deduplication
const hostProxyJumpMap = new Map<string, string>();
let parsed = 0;
let skipped = 0;
const isWildcardPattern = (p: string) => /[*?]/.test(p) || p === "!" || p.startsWith("!");
// Helper to create a stable key for ProxyJump mapping
const makeHostKey = (hostname: string, port?: number) =>
`${hostname.toLowerCase()}:${port ?? 22}`;
for (const block of blocks) {
const patterns = block.patterns.filter((p) => p && !isWildcardPattern(p));
if (patterns.length === 0) continue;
for (const pat of patterns) {
parsed++;
const hostname = block.hostname ?? pat;
if (!looksLikeHostnameToken(hostname)) {
skipped++;
issues.push({
level: "warning",
message: `ssh_config: skipped host "${pat}" (invalid hostname).`,
});
continue;
}
const host = createHost({
label: pat,
hostname,
username: block.username,
port: block.port,
protocol: "ssh",
});
// Attach IdentityFile paths if present
if (block.identityFiles && block.identityFiles.length > 0) {
host.identityFilePaths = [...block.identityFiles];
}
if (block.identityAgent !== undefined) {
host.identityAgent = block.identityAgent;
}
if (block.identitiesOnly !== undefined) {
host.identitiesOnly = block.identitiesOnly;
}
if (block.addKeysToAgent !== undefined) {
host.addKeysToAgent = block.addKeysToAgent;
}
if (block.useKeychain !== undefined) {
host.useKeychain = block.useKeychain;
}
const identityAgentEnabled = block.identityAgent !== undefined
&& block.identityAgent.toLowerCase() !== "none";
const identityAgentDisabled = block.identityAgent?.toLowerCase() === "none";
// The #2119 macOS pattern relies on AddKeysToAgent + UseKeychain without
// declaring IdentityAgent. Treat that pair as an agent-backed login so
// the bridge can ask Apple's ssh-add to load the configured IdentityFile.
// AddKeysToAgent alone still keeps direct-key semantics on other setups.
const macKeychainAgentEnabled = hasMacKeychainAgentDirectives(block);
if (!identityAgentDisabled && (identityAgentEnabled || macKeychainAgentEnabled)) {
host.useSshAgent = true;
} else if (identityAgentDisabled) {
host.useSshAgent = false;
}
if (block.forwardX11 !== undefined) {
host.x11Forwarding = block.forwardX11;
}
parsedHosts.push(host);
// Store ProxyJump using hostname key (survives deduplication)
if (block.proxyJump && block.proxyJump.toLowerCase() !== "none") {
const hostKey = makeHostKey(hostname, block.port);
hostProxyJumpMap.set(hostKey, block.proxyJump);
}
}
}
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
const hostnameToId = new Map<string, string>();
const labelToId = new Map<string, string>();
for (const host of dedupedHosts) {
hostnameToId.set(host.hostname.toLowerCase(), host.id);
labelToId.set(host.label.toLowerCase(), host.id);
}
const resolveJumpHostToId = (jumpHost: ParsedJumpHost): string | null => {
const hostnameKey = jumpHost.hostname.toLowerCase();
if (labelToId.has(hostnameKey)) return labelToId.get(hostnameKey)!;
if (hostnameToId.has(hostnameKey)) return hostnameToId.get(hostnameKey)!;
return null;
};
// Collect inline hosts separately to avoid modifying array during iteration
const inlineHosts: Host[] = [];
// Process ProxyJump for each host (iterate over a copy to avoid issues)
const hostsToProcess = [...dedupedHosts];
for (const host of hostsToProcess) {
const hostKey = makeHostKey(host.hostname, host.port);
const proxyJumpValue = hostProxyJumpMap.get(hostKey);
if (!proxyJumpValue) continue;
const jumpHosts = parseProxyJump(proxyJumpValue);
if (jumpHosts.length === 0) continue;
const resolvedIds: string[] = [];
const unresolvedJumps: string[] = [];
for (const jumpHost of jumpHosts) {
const existingId = resolveJumpHostToId(jumpHost);
if (existingId) {
// Avoid duplicate IDs in the chain
if (!resolvedIds.includes(existingId)) {
resolvedIds.push(existingId);
}
} else {
// Check if we already created an inline host for this
const inlineKey = jumpHost.hostname.toLowerCase();
let inlineId = hostnameToId.get(inlineKey);
if (!inlineId) {
const inlineHost = createHost({
label: jumpHost.hostname,
hostname: jumpHost.hostname,
username: jumpHost.username,
port: jumpHost.port,
protocol: "ssh",
});
inlineHosts.push(inlineHost);
hostnameToId.set(inlineHost.hostname.toLowerCase(), inlineHost.id);
labelToId.set(inlineHost.label.toLowerCase(), inlineHost.id);
inlineId = inlineHost.id;
unresolvedJumps.push(jumpHost.hostname);
}
if (!resolvedIds.includes(inlineId)) {
resolvedIds.push(inlineId);
}
}
}
if (resolvedIds.length > 0) {
// Cycle detection: check if this host appears in its own chain
if (resolvedIds.includes(host.id)) {
issues.push({
level: "warning",
message: `ssh_config: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
});
continue;
}
const hostChain: HostChainConfig = { hostIds: resolvedIds };
host.hostChain = hostChain;
}
if (unresolvedJumps.length > 0) {
issues.push({
level: "warning",
message: `ssh_config: created inline jump host(s) for "${host.label}": ${unresolvedJumps.join(", ")}`,
});
}
}
// Add inline hosts to the final result
const allHosts = [...dedupedHosts, ...inlineHosts];
// Deep cycle detection: check for indirect cycles (A -> B -> C -> A)
const detectCycle = (hostId: string, visited: Set<string>): boolean => {
if (visited.has(hostId)) return true;
visited.add(hostId);
const host = allHosts.find(h => h.id === hostId);
if (host?.hostChain?.hostIds) {
for (const chainId of host.hostChain.hostIds) {
if (detectCycle(chainId, visited)) return true;
}
}
visited.delete(hostId);
return false;
};
// Remove chains that form cycles
for (const host of allHosts) {
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
if (detectCycle(host.id, new Set())) {
issues.push({
level: "warning",
message: `ssh_config: detected circular dependency in jump chain for "${host.label}", removing chain.`,
});
delete host.hostChain;
}
}
}
return {
hosts: allHosts,
groups: [],
issues,
stats: { parsed, imported: allHosts.length, skipped, duplicates },
};
};
const importFromSecureCrt = (text: string, fileName?: string): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const lines = text.split(/\r?\n/);
type Session = {
label?: string;
hostname?: string;
username?: string;
port?: number;
ssh1Port?: number;
ssh2Port?: number;
sshVersion?: "ssh1" | "ssh2";
protocol?: Exclude<HostProtocol, "mosh" | "et">;
};
const sessions: Session[] = [];
let current: Session = {};
const flush = () => {
if (current.hostname) sessions.push(current);
current = {};
};
const parseSecureCrtPort = (raw: string): number | undefined => {
const trimmed = raw.trim().replace(/^"+|"+$/g, "");
if (!trimmed) return undefined;
if (/^[0-9a-fA-F]{8}$/.test(trimmed)) {
const n = parseInt(trimmed, 16);
if (Number.isFinite(n) && n >= 1 && n <= 65535) return n;
}
return parsePort(trimmed);
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const kv = trimmed.match(/^[SDB]:"([^"]+)"=(.*)$/);
if (!kv) continue;
const key = kv[1];
const rawValue = kv[2].trim();
const value = rawValue.replace(/^"+|"+$/g, "");
if (key === "Hostname") {
if (current.hostname) flush();
current.hostname = value;
} else if (key === "Username") {
current.username = value;
} else if (key === "Port") {
current.port = parseSecureCrtPort(value);
} else if (key === "[SSH2] Port") {
current.ssh2Port = parseSecureCrtPort(value);
} else if (key === "[SSH1] Port") {
current.ssh1Port = parseSecureCrtPort(value);
} else if (key === "Protocol Name") {
const p = normalizeProtocol(value);
const normalizedName = value.trim().toLowerCase();
current.sshVersion = normalizedName === "ssh1"
? "ssh1"
: normalizedName === "ssh2" || normalizedName === "ssh-2"
? "ssh2"
: undefined;
current.protocol = p ?? (current.sshVersion ? "ssh" : undefined);
} else if (key === "Session Name") {
current.label = value;
}
}
flush();
const parsedHosts: Host[] = [];
let parsed = 0;
let skipped = 0;
const fallbackLabel =
fileName?.replace(/\.[^.]+$/, "") || "SecureCRT Session";
for (let i = 0; i < sessions.length; i++) {
const s = sessions[i];
if (!s.hostname) continue;
parsed++;
const protocol = s.protocol ?? "ssh";
if (protocol !== "ssh" && protocol !== "telnet") {
skipped++;
issues.push({
level: "warning",
message: `SecureCRT session: unsupported protocol.`,
});
continue;
}
const label = s.label || (sessions.length > 1 ? `${fallbackLabel} ${i + 1}` : fallbackLabel);
const protocolSpecificPort = protocol === "ssh"
? s.sshVersion === "ssh1"
? s.ssh1Port
: s.sshVersion === "ssh2"
? s.ssh2Port
: s.ssh2Port ?? s.ssh1Port
: undefined;
parsedHosts.push(
createHost({
label,
hostname: s.hostname,
username: s.username,
port: protocolSpecificPort ?? s.port ?? (protocol === "ssh" ? DEFAULT_SSH_PORT : 23),
protocol,
}),
);
}
const { hosts, duplicates } = dedupeHosts(parsedHosts);
return {
hosts,
groups: [],
issues,
stats: { parsed, imported: hosts.length, skipped, duplicates },
};
};
const importFromMobaXterm = (
text: string,
options?: { masterPassword?: string },
): VaultImportResult => {
const issues: VaultImportIssue[] = [];
const lines = text.split(/\r?\n/);
type Entry = { section: string; key: string; value: string };
const entries: Entry[] = [];
const sectionGroups = new Map<string, string | undefined>();
const passwordEntries = new Map<string, string>();
const credentialEntries = new Map<string, { username: string; ciphertext: string }>();
const misc = new Map<string, string>();
let hasSesspass = false;
let section = "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith(";") || trimmed.startsWith("#")) continue;
const mSection = trimmed.match(/^\[(.+)\]$/);
if (mSection) {
section = mSection[1];
continue;
}
const mKv = trimmed.match(/^([^=]+)=(.*)$/);
if (!mKv) continue;
const key = mKv[1].trim();
const value = mKv[2].trim();
const sectionName = section.trim();
const isBookmarkSection = /^bookmarks(?:_\d+)?$/i.test(sectionName);
if (isBookmarkSection && key.toLowerCase() === "subrep") {
sectionGroups.set(section, normalizeGroupPath(value));
continue;
}
if (isBookmarkSection && key.toLowerCase() === "imgnum") continue;
if (/^passwords$/i.test(sectionName) && key && value) {
passwordEntries.set(key, value);
continue;
}
if (/^credentials$/i.test(sectionName) && key && value) {
const colon = value.indexOf(":");
if (colon > 0) {
credentialEntries.set(key, {
username: value.slice(0, colon),
ciphertext: value.slice(colon + 1),
});
}
continue;
}
if (/^misc$/i.test(sectionName)) {
misc.set(key.toLowerCase(), value);
continue;
}
if (/^sesspass$/i.test(sectionName)) {
hasSesspass = true;
continue;
}
entries.push({ section, key, value });
}
const candidateEntries = entries.filter((e) =>
/^(?:sessions|bookmarks(?:_\d+)?|bookmarks2|bookmark)$/i.test(e.section.trim()),
);
const parsedHosts: Host[] = [];
let parsed = 0;
let skipped = 0;
for (const e of candidateEntries) {
const rawKey = e.key;
const rawValue = e.value;
if (!rawKey || !rawValue) continue;
parsed++;
const isBookmarkSection = /^bookmarks(?:_\d+)?$/i.test(e.section.trim());
const hasBookmarkGroup = sectionGroups.has(e.section);
const keyParts = rawKey.replace(/\\/g, "/").split("/").filter(Boolean);
const label = isBookmarkSection && hasBookmarkGroup
? rawKey
: keyParts[keyParts.length - 1] || rawKey;
const group = isBookmarkSection && hasBookmarkGroup
? sectionGroups.get(e.section)
: keyParts.length > 1
? keyParts.slice(0, -1).join("/")
: undefined;
let protocol: Exclude<HostProtocol, "mosh" | "et"> | undefined;
let hostname: string | undefined;
let username: string | undefined;
let port: number | undefined;
const outerFields = rawValue.split("#");
const sessionFields = outerFields.length >= 3 ? outerFields[2].split("%") : [];
const sessionType = sessionFields[0]?.trim();
const isStandardSession = /^(?:;\s*logout)?\s*#\d+(?:#|$)/i.test(rawValue);
if (isStandardSession) {
if (!/^\d+$/.test(sessionType ?? "")) {
skipped++;
issues.push({
level: "warning",
message: `MobaXterm entry "${label}": invalid session type.`,
});
continue;
}
if (sessionType !== "0" && sessionType !== "7") {
skipped++;
issues.push({
level: "warning",
message: `MobaXterm entry "${label}": unsupported session type.`,
});
continue;
}
protocol = "ssh";
hostname = sessionFields[1]?.trim() || undefined;
port = parsePort(sessionFields[2]);
const rawUsername = sessionFields[3]?.trim();
username = rawUsername && rawUsername !== "<default>" ? rawUsername : undefined;
} else {
// Retain support for the simpler token layouts accepted by older imports.
const tokens = rawValue
.split("#")
.map((t) => t.trim())
.filter(Boolean);
if (tokens.length > 0) {
protocol =
normalizeProtocol(tokens[0]) ??
tokens.map((t) => normalizeProtocol(t)).find(Boolean);
for (const tok of tokens) {
const target = parseTarget(tok.replace(/^ssh:/i, "").trim());
if (target) {
hostname = target.hostname;
username = target.username ?? username;
port = target.port ?? port;
protocol = target.protocol ?? protocol;
break;
}
}
const numericPort = tokens.map((t) => parsePort(t)).find(Boolean);
if (numericPort) port = numericPort;
if (!username) {
const userToken = tokens.find((t) => t.includes("@"));
if (userToken) username = userToken.split("@")[0];
}
}
}
if (!hostname) {
skipped++;
issues.push({
level: "warning",
message: `MobaXterm entry "${label}": missing hostname.`,
});
continue;
}
parsedHosts.push(
createHost({
label,
hostname,
username,
port,
protocol: protocol ?? "ssh",
group,
}),
);
}
const { hosts: uniqueHosts, duplicates } = dedupeHosts(parsedHosts);
const attached = attachMobaXtermPasswords(uniqueHosts, {
passwords: passwordEntries,
credentials: credentialEntries,
sessionP: misc.get("sessionp"),
sysUsername: misc.get("mpsetaccount"),
sysHostname: misc.get("mpsetcomputer"),
passwordsInRegistry: misc.get("passwordsinregistry") === "1",
hasSesspass,
}, { masterPassword: options?.masterPassword });
issues.push(...attached.issues);
const hosts = attached.hosts;
const groups = uniq(hosts.map((h) => h.group).filter(Boolean) as string[]);
return {
hosts,
groups,
issues,
stats: { parsed, imported: hosts.length, skipped, duplicates },
};
};
export const importVaultHostsFromText = (
format: VaultImportFormat,
text: string,
options?: { fileName?: string; masterPassword?: string },
): VaultImportResult => {
const input = text ?? "";
switch (format) {
case "csv":
return importFromCsv(input);
case "putty":
return importFromPuttyReg(input);
case "ssh_config":
return importFromSshConfig(input);
case "securecrt":
return importFromSecureCrt(input, options?.fileName);
case "mobaxterm":
return importFromMobaXterm(input, options);
default: {
const _exhaustive: never = format;
return _exhaustive;
}
}
};
export function detectVaultImportFormat(text: string): VaultImportFormat | null {
const input = (text ?? "").trim();
if (!input) return null;
if (
/Windows Registry Editor Version/i.test(input)
|| /\\Software\\SimonTatham\\PuTTY\\Sessions/i.test(input)
) {
return "putty";
}
const hasMobaBookmarkSection = /^\[Bookmarks(?:_\d+)?\]\s*$/im.test(input);
const hasMobaBookmarkMetadata = /^SubRep=.*$/im.test(input) && /^ImgNum=\d+\s*$/im.test(input);
const hasMobaSessionLine = /^[^=\r\n]+=\s*(?:; logout)?\s*#\d+#\d+%[^%\r\n]+%\d+/im.test(input);
const hasMobaFullConfig = /^\[Misc\]\s*$/im.test(input)
&& (/^SessionP=/im.test(input) || /^\[Passwords\]\s*$/im.test(input) || /^\[Credentials\]\s*$/im.test(input));
if (
/\[MobaXterm\]/i.test(input)
|| (hasMobaBookmarkSection && (hasMobaBookmarkMetadata || hasMobaSessionLine || hasMobaFullConfig))
|| (hasMobaFullConfig && hasMobaBookmarkSection)
) {
return "mobaxterm";
}
if (/^Host\s+/m.test(input) && (/^\s+HostName\s+/m.test(input) || /^\s+User\s+/m.test(input))) {
return "ssh_config";
}
if (/S:"Hostname"/m.test(input) && (/S:"Username"/m.test(input) || /D:"\[Sessions\]/i.test(input))) {
return "securecrt";
}
const firstLine = input.split(/\r?\n/, 1)[0] ?? "";
if (
/hostname|host(name)?|server/i.test(firstLine)
&& (firstLine.includes(",") || firstLine.includes("\t"))
) {
return "csv";
}
return null;
}
export function applyVaultHostImport(
existingHosts: Host[],
existingGroups: string[],
importResult: VaultImportResult,
options?: { skipDuplicates?: boolean },
): {
hosts: Host[];
customGroups: string[];
addedCount: number;
skippedExistingCount: number;
addedHosts: Host[];
} {
const skipDuplicates = options?.skipDuplicates !== false;
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
let newHosts = importResult.hosts;
let skippedExistingCount = 0;
if (skipDuplicates) {
newHosts = importResult.hosts.filter((host) => {
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
if (duplicate) skippedExistingCount++;
return !duplicate;
});
}
const customGroups = Array.from(
new Set([
...existingGroups,
...importResult.groups,
...newHosts.map((host) => host.group).filter(Boolean),
]),
) as string[];
return {
hosts: [...existingHosts, ...newHosts].map(sanitizeHost),
customGroups,
addedCount: newHosts.length,
skippedExistingCount,
addedHosts: newHosts,
};
}
export async function resolveVaultImportKeyPassphraseConflicts(
entries: VaultHostKeyPassphrase[],
resolveAliases: (keyPath: string) => Promise<string[]>,
eligibleHostIds?: ReadonlySet<string>,
eligibleKeyPathsByHostId?: ReadonlyMap<string, string>,
): Promise<{ keyPassphrases: VaultHostKeyPassphrase[]; issues: VaultImportIssue[] }> {
const groups: Array<{
aliases: Set<string>;
entries: VaultHostKeyPassphrase[];
}> = [];
for (const entry of entries) {
const aliases = new Set((await resolveAliases(entry.keyPath)).map(normalizeKeyPathKey));
aliases.add(normalizeKeyPathKey(entry.keyPath));
const eligibleKeyPath = eligibleKeyPathsByHostId?.get(entry.hostId);
if (eligibleKeyPath) {
const eligibleAliases = new Set(
(await resolveAliases(eligibleKeyPath)).map(normalizeKeyPathKey),
);
eligibleAliases.add(normalizeKeyPathKey(eligibleKeyPath));
if (![...aliases].some((alias) => eligibleAliases.has(alias))) continue;
} else if (eligibleKeyPathsByHostId && eligibleHostIds?.has(entry.hostId)) {
continue;
}
const matching = groups.filter((group) => (
[...aliases].some((alias) => group.aliases.has(alias))
));
const mergedAliases = new Set([
...aliases,
...matching.flatMap((group) => [...group.aliases]),
]);
const mergedEntries = [
...matching.flatMap((group) => group.entries),
entry,
];
for (const group of matching) {
groups.splice(groups.indexOf(group), 1);
}
groups.push({ aliases: mergedAliases, entries: mergedEntries });
}
const keyPassphrases: VaultHostKeyPassphrase[] = [];
const issues: VaultImportIssue[] = [];
for (const group of groups) {
const passphrases = new Set(group.entries.map((entry) => entry.passphrase));
if (passphrases.size > 1) {
issues.push({
level: "warning",
message: `CSV contains conflicting passphrases for KeyPath "${group.entries[0].keyPath}"; no passphrase was saved for that path.`,
});
} else {
const selected = eligibleHostIds
? group.entries.find((entry) => eligibleHostIds.has(entry.hostId))
: group.entries[0];
if (selected) keyPassphrases.push(selected);
}
}
return { keyPassphrases, issues };
}
export interface VaultExistingKeyPassphraseRead {
values: string[];
unreadable: boolean;
}
export async function filterVaultImportKeyPassphrasesAgainstExisting(
entries: VaultHostKeyPassphrase[],
readExisting: (keyPath: string) => Promise<VaultExistingKeyPassphraseRead>,
): Promise<{ keyPassphrases: VaultHostKeyPassphrase[]; issues: VaultImportIssue[] }> {
const keyPassphrases: VaultHostKeyPassphrase[] = [];
const issues: VaultImportIssue[] = [];
for (const entry of entries) {
const existing = await readExisting(entry.keyPath);
if (existing.unreadable) {
issues.push({
level: "warning",
message: `Could not verify the existing saved passphrase for KeyPath "${entry.keyPath}"; the imported passphrase was not saved.`,
});
continue;
}
if (existing.values.some((value) => value !== entry.passphrase)) {
issues.push({
level: "warning",
message: `CSV passphrase conflicts with an existing saved passphrase for KeyPath "${entry.keyPath}"; the existing passphrase was kept.`,
});
continue;
}
keyPassphrases.push(entry);
}
return { keyPassphrases, issues };
}