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

744 lines
29 KiB
TypeScript

import type { ImporterRecord, JsonValue } from '@netcatty/plugin-contract';
import { sanitizeHost } from './host';
import { isBuiltInHostProtocol, isPluginHostProtocol } from './pluginConnection';
import type { Host, Identity, Snippet, SSHKey } from './models';
import { applyVaultImportDestination, type VaultImportDestination } from './vaultImport';
import { buildVaultHostMergeKey } from './vaultHostCreate';
export interface PluginImporterDrafts {
hosts: Host[];
identities: Identity[];
keys: SSHKey[];
snippets: Snippet[];
groups: string[];
warnings: string[];
errors: string[];
}
export interface PluginImporterPreviewItem {
kind: 'host' | 'identity' | 'key' | 'snippet' | 'group';
label: string;
detail?: string;
}
export interface PluginImporterSafePreview {
items: PluginImporterPreviewItem[];
warnings: string[];
errors: string[];
omittedItemCount: number;
omittedDiagnosticCount: number;
}
const unicodeLength = (value: string): number => [...value].length;
const truncateUnicode = (value: string, maximum: number): string => [...value].slice(0, maximum).join('');
const visiblePreviewText = (value: string, maximum = 512): string => (
truncateUnicode([...value]
.map((character) => {
const code = character.charCodeAt(0);
return code <= 31 || code === 127 ? ' ' : character;
})
.join('')
.replace(/\s+/g, ' ')
.trim(), maximum)
);
export function buildPluginImporterSafePreview(
drafts: PluginImporterDrafts,
maximumItems = 50,
maximumDiagnostics = 20,
): PluginImporterSafePreview {
const items: PluginImporterPreviewItem[] = [
...drafts.hosts.map((host): PluginImporterPreviewItem => ({
kind: 'host',
label: visiblePreviewText(host.label),
detail: visiblePreviewText(host.pluginConnection?.providerId ?? host.hostname, 1024),
})),
...drafts.identities.map((identity): PluginImporterPreviewItem => ({
kind: 'identity',
label: visiblePreviewText(identity.label),
detail: visiblePreviewText(`${identity.username} · ${identity.authMethod}`),
})),
...drafts.keys.map((key): PluginImporterPreviewItem => ({
kind: 'key',
label: visiblePreviewText(key.label),
detail: visiblePreviewText(`${key.type} · ${key.source}`),
})),
...drafts.snippets.map((snippet): PluginImporterPreviewItem => ({
kind: 'snippet',
label: visiblePreviewText(snippet.label),
detail: visiblePreviewText(snippet.kind),
})),
...drafts.groups.map((group): PluginImporterPreviewItem => ({
kind: 'group',
label: visiblePreviewText(group),
})),
];
const diagnostics = [
...drafts.warnings.map((message) => ({ kind: 'warning' as const, message: visiblePreviewText(message, 2048) })),
...drafts.errors.map((message) => ({ kind: 'error' as const, message: visiblePreviewText(message, 2048) })),
];
const boundedDiagnostics = diagnostics.slice(0, Math.max(0, maximumDiagnostics));
return {
items: items.slice(0, Math.max(0, maximumItems)),
warnings: boundedDiagnostics.filter(({ kind }) => kind === 'warning').map(({ message }) => message),
errors: boundedDiagnostics.filter(({ kind }) => kind === 'error').map(({ message }) => message),
omittedItemCount: Math.max(0, items.length - maximumItems),
omittedDiagnosticCount: Math.max(0, diagnostics.length - maximumDiagnostics),
};
}
const asObject = (value: JsonValue): Record<string, JsonValue> | null => (
value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, JsonValue>
: null
);
const INVALID_FIELD = Symbol('invalid-plugin-importer-field');
type InvalidField = typeof INVALID_FIELD;
const stringValue = (value: JsonValue | undefined, maximum: number): string | undefined => {
if (typeof value !== 'string') return undefined;
const result = value.trim();
return result && unicodeLength(result) <= maximum && !result.includes('\0') ? result : undefined;
};
const optionalRawStringValue = (
object: Record<string, JsonValue>,
key: string,
maximum: number,
): string | undefined | InvalidField => {
if (!Object.prototype.hasOwnProperty.call(object, key)) return undefined;
const value = object[key];
return typeof value === 'string' && unicodeLength(value) <= maximum
? value
: INVALID_FIELD;
};
const optionalStringValue = (
object: Record<string, JsonValue>,
key: string,
maximum: number,
): string | undefined | InvalidField => {
if (!Object.prototype.hasOwnProperty.call(object, key)) return undefined;
if (typeof object[key] !== 'string') return INVALID_FIELD;
const result = stringValue(object[key], maximum);
return result ?? undefined;
};
const optionalBooleanValue = (
object: Record<string, JsonValue>,
key: string,
): boolean | undefined | InvalidField => {
if (!Object.prototype.hasOwnProperty.call(object, key)) return undefined;
return typeof object[key] === 'boolean' ? object[key] : INVALID_FIELD;
};
const optionalIntegerValue = (
object: Record<string, JsonValue>,
key: string,
minimum: number,
maximum: number,
): number | undefined | InvalidField => {
if (!Object.prototype.hasOwnProperty.call(object, key)) return undefined;
const value = object[key];
return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum && value <= maximum
? value
: INVALID_FIELD;
};
const optionalEnumValue = <T extends string>(
object: Record<string, JsonValue>,
key: string,
allowed: readonly T[],
): T | undefined | InvalidField => {
if (!Object.prototype.hasOwnProperty.call(object, key)) return undefined;
const value = object[key];
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? value as T : INVALID_FIELD;
};
const stringArray = (value: JsonValue | undefined, maximumItems = 128): string[] => (
Array.isArray(value)
? [...new Set(value.slice(0, maximumItems).flatMap((item) => {
const normalized = stringValue(item, 256);
return normalized ? [normalized] : [];
}))]
: []
);
const normalizeHost = (value: JsonValue): Host | null => {
const object = asObject(value);
if (!object) return null;
const hasProtocol = Object.prototype.hasOwnProperty.call(object, 'protocol');
const protocolValue = hasProtocol ? stringValue(object.protocol, 199) : undefined;
if (hasProtocol && !protocolValue) return null;
if (protocolValue && !isBuiltInHostProtocol(protocolValue) && !isPluginHostProtocol(protocolValue)) return null;
const protocol = protocolValue as Host['protocol'] | undefined;
const pluginProtocol = isPluginHostProtocol(protocol);
const providerId = asObject(object.pluginConnection)
? stringValue(asObject(object.pluginConnection)?.providerId, 192)
: undefined;
const hostname = stringValue(object.hostname, 1024) ?? (pluginProtocol ? providerId : undefined);
const label = stringValue(object.label, 512) ?? hostname;
if (!hostname || !label) return null;
const pluginConnection = Object.prototype.hasOwnProperty.call(object, 'pluginConnection')
? asObject(object.pluginConnection)
: undefined;
if (pluginConnection === null) return null;
const sourceCredentialId = pluginConnection
? optionalStringValue(pluginConnection, 'credentialId', 256)
: undefined;
if (sourceCredentialId === INVALID_FIELD) return null;
const tags = Object.prototype.hasOwnProperty.call(object, 'tags') && !Array.isArray(object.tags)
? null
: stringArray(object.tags);
if (!tags) return null;
const optionalStrings = {
group: optionalStringValue(object, 'group', 512),
identityId: optionalStringValue(object, 'identityId', 256),
identityFileId: optionalStringValue(object, 'identityFileId', 256),
telnetIdentityId: optionalStringValue(object, 'telnetIdentityId', 256),
notes: optionalStringValue(object, 'notes', 65_536),
theme: optionalStringValue(object, 'theme', 256),
sftpEncoding: optionalStringValue(object, 'sftpEncoding', 64),
};
if (Object.values(optionalStrings).some((item) => item === INVALID_FIELD)) return null;
const optionalBooleans = {
moshEnabled: optionalBooleanValue(object, 'moshEnabled'),
etEnabled: optionalBooleanValue(object, 'etEnabled'),
telnetEnabled: optionalBooleanValue(object, 'telnetEnabled'),
sftpSudo: optionalBooleanValue(object, 'sftpSudo'),
requiresMfa: optionalBooleanValue(object, 'requiresMfa'),
useSshAgent: optionalBooleanValue(object, 'useSshAgent'),
identitiesOnly: optionalBooleanValue(object, 'identitiesOnly'),
agentForwarding: optionalBooleanValue(object, 'agentForwarding'),
x11Forwarding: optionalBooleanValue(object, 'x11Forwarding'),
showLineTimestamps: optionalBooleanValue(object, 'showLineTimestamps'),
disableDynamicTabTitle: optionalBooleanValue(object, 'disableDynamicTabTitle'),
pinned: optionalBooleanValue(object, 'pinned'),
autoOpenSftpPanel: optionalBooleanValue(object, 'autoOpenSftpPanel'),
sftpFollowTerminalCwd: optionalBooleanValue(object, 'sftpFollowTerminalCwd'),
};
if (Object.values(optionalBooleans).some((item) => item === INVALID_FIELD)) return null;
const optionalIntegers = {
port: optionalIntegerValue(object, 'port', 1, 65535),
telnetPort: optionalIntegerValue(object, 'telnetPort', 1, 65535),
etPort: optionalIntegerValue(object, 'etPort', 1, 65535),
keepaliveInterval: optionalIntegerValue(object, 'keepaliveInterval', 0, 3600),
keepaliveCountMax: optionalIntegerValue(object, 'keepaliveCountMax', 1, 100),
};
if (Object.values(optionalIntegers).some((item) => item === INVALID_FIELD)) return null;
const deviceType = optionalEnumValue(object, 'deviceType', ['general', 'network'] as const);
if (deviceType === INVALID_FIELD) return null;
const sftpFileProtocol = optionalEnumValue(object, 'sftpFileProtocol', ['auto', 'sftp', 'scp'] as const);
if (sftpFileProtocol === INVALID_FIELD) return null;
const draft = {
id: crypto.randomUUID(),
label,
hostname,
username: stringValue(object.username, 512) ?? '',
tags,
os: ['linux', 'windows', 'macos'].includes(String(object.os)) ? object.os : 'linux',
createdAt: Date.now(),
ephemeral: false,
managedSourceId: undefined,
...(protocol ? { protocol } : {}),
...(pluginConnection ? { pluginConnection: structuredClone(pluginConnection) } : {}),
...(deviceType ? { deviceType } : {}),
...(sftpFileProtocol ? { sftpFileProtocol } : {}),
...Object.fromEntries(Object.entries(optionalStrings).filter(([, item]) => item !== undefined)),
...Object.fromEntries(Object.entries(optionalBooleans).filter(([, item]) => item !== undefined)),
...Object.fromEntries(Object.entries(optionalIntegers).filter(([, item]) => item !== undefined)),
} as unknown as Host;
const sanitized = sanitizeHost(draft);
if (!sanitized.hostname || (pluginProtocol && !sanitized.pluginConnection)) return null;
return sanitized.pluginConnection && sourceCredentialId
? {
...sanitized,
pluginConnection: { ...sanitized.pluginConnection, credentialId: sourceCredentialId },
}
: sanitized;
};
const normalizeIdentity = (value: JsonValue): Identity | null => {
const object = asObject(value);
if (!object) return null;
const label = stringValue(object.label, 512);
const username = stringValue(object.username, 512);
const authMethod = stringValue(object.authMethod, 32);
const password = optionalRawStringValue(object, 'password', 65_536);
if (!label || !username || !['password', 'key', 'certificate'].includes(authMethod ?? '')
|| password === INVALID_FIELD) return null;
return {
id: crypto.randomUUID(),
label,
username,
authMethod: authMethod as Identity['authMethod'],
...(password !== undefined ? { password } : {}),
...(stringValue(object.keyId, 256) ? { keyId: stringValue(object.keyId, 256) } : {}),
created: Date.now(),
};
};
const normalizeKey = (value: JsonValue): SSHKey | null => {
const object = asObject(value);
if (!object) return null;
const label = stringValue(object.label, 512);
const type = stringValue(object.type, 32);
const privateKey = optionalRawStringValue(object, 'privateKey', 2 * 1024 * 1024);
const publicKey = optionalRawStringValue(object, 'publicKey', 1024 * 1024);
const certificate = optionalRawStringValue(object, 'certificate', 1024 * 1024);
const passphrase = optionalRawStringValue(object, 'passphrase', 65_536);
const filePath = stringValue(object.filePath, 8192);
if (!label || !['RSA', 'ECDSA', 'ED25519'].includes(type ?? '')
|| [privateKey, publicKey, certificate, passphrase].some((item) => item === INVALID_FIELD)
|| (!privateKey && !filePath)) return null;
return {
id: crypto.randomUUID(),
label,
type: type as SSHKey['type'],
privateKey: privateKey ?? '',
...(publicKey !== undefined ? { publicKey } : {}),
...(certificate !== undefined ? { certificate } : {}),
...(passphrase !== undefined ? { passphrase } : {}),
...(filePath ? { filePath } : {}),
source: filePath && !privateKey ? 'reference' : 'imported',
category: ['key', 'certificate', 'identity'].includes(String(object.category))
? object.category as SSHKey['category']
: 'key',
created: Date.now(),
};
};
const normalizeSnippet = (value: JsonValue): Snippet | null => {
const object = asObject(value);
if (!object) return null;
const label = stringValue(object.label, 512);
const command = optionalRawStringValue(object, 'command', 1024 * 1024);
if (!label || !command || command === INVALID_FIELD || command.includes('\0')) return null;
return {
id: crypto.randomUUID(),
label,
command,
tags: stringArray(object.tags),
kind: object.kind === 'script' ? 'script' : 'snippet',
...(stringValue(object.description, 4096) ? { description: stringValue(object.description, 4096) } : {}),
};
};
export function normalizePluginImporterRecords(records: ReadonlyArray<ImporterRecord>): PluginImporterDrafts {
const result: PluginImporterDrafts = {
hosts: [], identities: [], keys: [], snippets: [], groups: [], warnings: [], errors: [],
};
const keyIds = new Map<string, string | null>();
const identityIds = new Map<string, string | null>();
const registerSourceId = (
index: Map<string, string | null>,
sourceId: string,
id: string,
kind: 'identity' | 'key',
): void => {
if (!index.has(sourceId)) {
index.set(sourceId, id);
return;
}
if (index.get(sourceId) !== null) {
result.errors.push(`Importer returned a duplicate ${kind} source ID.`);
}
index.set(sourceId, null);
};
const sourceIds = (value: JsonValue): string | undefined => {
const object = asObject(value);
return object ? stringValue(object.id, 256) : undefined;
};
for (const record of records) {
if (record.type === 'warning') {
result.warnings.push(record.message);
continue;
}
if (record.type === 'error') {
result.errors.push(record.message);
continue;
}
if (record.type !== 'draft') continue;
const { kind, value } = record.draft;
if (kind === 'group') {
const object = asObject(value);
const group = typeof value === 'string'
? stringValue(value, 512)
: object ? stringValue(object.path ?? object.label, 512) : undefined;
if (group) result.groups.push(group);
else result.errors.push('Importer returned an invalid group draft.');
continue;
}
const normalized = kind === 'host'
? normalizeHost(value)
: kind === 'identity'
? normalizeIdentity(value)
: kind === 'key'
? normalizeKey(value)
: normalizeSnippet(value);
if (!normalized) {
result.errors.push(`Importer returned an invalid ${kind} draft.`);
continue;
}
if (kind === 'host') result.hosts.push(normalized as Host);
else if (kind === 'identity') {
const identity = normalized as Identity;
result.identities.push(identity);
const sourceId = sourceIds(value);
if (sourceId) registerSourceId(identityIds, sourceId, identity.id, 'identity');
} else if (kind === 'key') {
const key = normalized as SSHKey;
result.keys.push(key);
const sourceId = sourceIds(value);
if (sourceId) registerSourceId(keyIds, sourceId, key.id, 'key');
}
else result.snippets.push(normalized as Snippet);
}
const resolveReference = (
index: Map<string, string | null>,
sourceId: string | undefined,
label: string,
): string | undefined => {
if (!sourceId) return undefined;
const resolved = index.get(sourceId);
if (typeof resolved === 'string') return resolved;
result.errors.push(resolved === null
? `Importer returned an ambiguous ${label} reference.`
: `Importer returned an unresolved ${label} reference.`);
return undefined;
};
result.identities = result.identities.map((identity) => {
const keyId = resolveReference(keyIds, identity.keyId, 'identity key');
return {
...identity,
...(typeof keyId === 'string' ? { keyId } : { keyId: undefined }),
};
});
result.hosts = result.hosts.map((host) => {
const sourceCredentialId = host.pluginConnection?.credentialId;
const identityCredentialId = sourceCredentialId ? identityIds.get(sourceCredentialId) : undefined;
const keyCredentialId = sourceCredentialId ? keyIds.get(sourceCredentialId) : undefined;
const hasAmbiguousCredential = identityCredentialId === null
|| keyCredentialId === null
|| (typeof identityCredentialId === 'string' && typeof keyCredentialId === 'string');
const credentialId = hasAmbiguousCredential
? undefined
: identityCredentialId ?? keyCredentialId;
if (sourceCredentialId && !credentialId) {
result.errors.push(hasAmbiguousCredential
? 'Importer returned an ambiguous plugin credential reference.'
: 'Importer returned an unresolved plugin credential reference.');
}
const identityId = resolveReference(identityIds, host.identityId, 'host identity');
const telnetIdentityId = resolveReference(identityIds, host.telnetIdentityId, 'host Telnet identity');
const identityFileId = resolveReference(keyIds, host.identityFileId, 'host key');
return {
...host,
...(typeof identityId === 'string'
? { identityId }
: { identityId: undefined }),
...(typeof telnetIdentityId === 'string'
? { telnetIdentityId }
: { telnetIdentityId: undefined }),
...(typeof identityFileId === 'string'
? { identityFileId }
: { identityFileId: undefined }),
...(host.pluginConnection
? {
pluginConnection: {
...host.pluginConnection,
...(credentialId ? { credentialId } : { credentialId: undefined }),
},
}
: {}),
};
});
result.groups = [...new Set(result.groups)];
return result;
}
export interface PluginImporterMergeResult {
hosts: Host[];
identities: Identity[];
keys: SSHKey[];
snippets: Snippet[];
customGroups: string[];
duplicateCount: number;
addedCount: number;
}
export function applyPluginImporterDestination(
merged: PluginImporterMergeResult,
existingHostCount: number,
destination?: VaultImportDestination,
existingCustomGroups: ReadonlyArray<string> = [],
existingCounts: {
identities?: number;
keys?: number;
} = {},
): PluginImporterMergeResult {
if (!destination || destination.mode === 'preserve') return merged;
const splitAt = Math.max(0, Math.min(existingHostCount, merged.hosts.length));
const existingHosts = merged.hosts.slice(0, splitAt);
const importedHosts = merged.hosts.slice(splitAt);
const existingIdentityCount = Math.max(
0,
Math.min(existingCounts.identities ?? 0, merged.identities.length),
);
const existingKeyCount = Math.max(
0,
Math.min(existingCounts.keys ?? 0, merged.keys.length),
);
const existingIdentityIds = new Set(
merged.identities.slice(0, existingIdentityCount).map((identity) => identity.id),
);
const existingKeyIds = new Set(
merged.keys.slice(0, existingKeyCount).map((key) => key.id),
);
const targeted = applyVaultImportDestination({
hosts: importedHosts,
groups: [],
issues: [],
stats: {
parsed: importedHosts.length,
imported: importedHosts.length,
skipped: 0,
duplicates: 0,
},
}, destination, {
// Plugin hosts that differ by configuration/credentials must survive a
// shared destination group; classic vault hosts still collapse by endpoint.
isCollapsible: (host) => !host.pluginConnection,
});
// Destination rewrite can make an imported vault host collide with an existing
// one (same endpoint, now same group). Re-check like the CSV import path.
const existingFingerprints = new Set(existingHosts.map(hostFingerprint));
const retainedImported: Host[] = [];
for (const host of targeted.hosts) {
const fingerprint = hostFingerprint(host);
if (existingFingerprints.has(fingerprint)) continue;
existingFingerprints.add(fingerprint);
retainedImported.push(host);
}
const retainedImportedIds = new Set(retainedImported.map((host) => host.id));
const droppedImported = importedHosts.filter((host) => !retainedImportedIds.has(host.id));
const collectHostCredentialIds = (hosts: ReadonlyArray<Host>): Set<string> => {
const ids = new Set<string>();
for (const host of hosts) {
if (host.identityId) ids.add(host.identityId);
if (host.telnetIdentityId) ids.add(host.telnetIdentityId);
if (host.identityFileId) ids.add(host.identityFileId);
const pluginCredentialId = host.pluginConnection?.credentialId;
if (pluginCredentialId) ids.add(pluginCredentialId);
}
return ids;
};
const keptCredentialIds = collectHostCredentialIds([...existingHosts, ...retainedImported]);
const droppedOnlyCredentialIds = new Set(
[...collectHostCredentialIds(droppedImported)].filter((id) => !keptCredentialIds.has(id)),
);
// Only prune credentials introduced by this import. Remapped duplicates that
// point at pre-existing vault identities/keys must stay in the vault.
const nextIdentities = merged.identities.filter((identity) => (
existingIdentityIds.has(identity.id)
|| !droppedOnlyCredentialIds.has(identity.id)
));
const keptIdentityKeyIds = new Set(
nextIdentities.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
);
const prunedIdentityKeyIds = new Set(
merged.identities
.filter((identity) => (
!existingIdentityIds.has(identity.id)
&& droppedOnlyCredentialIds.has(identity.id)
))
.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
);
const nextKeys = merged.keys.filter((key) => {
if (existingKeyIds.has(key.id)) return true;
if (keptCredentialIds.has(key.id) || keptIdentityKeyIds.has(key.id)) return true;
if (droppedOnlyCredentialIds.has(key.id) || prunedIdentityKeyIds.has(key.id)) return false;
return true;
});
const prunedIdentityCount = merged.identities.length - nextIdentities.length;
const prunedKeyCount = merged.keys.length - nextKeys.length;
const existingGroupSet = new Set([
...existingCustomGroups,
...existingHosts.flatMap((host) => host.group ? [host.group] : []),
]);
const previousAddedGroupCount = merged.customGroups.filter(
(group) => !existingGroupSet.has(group),
).length;
const customGroups = [...new Set([...existingCustomGroups, ...targeted.groups])];
const nextAddedGroupCount = customGroups.filter(
(group) => !existingGroupSet.has(group),
).length;
// Destination rewriting can collapse same-endpoint hosts into one group;
// keep added/duplicate counts aligned with the hosts actually retained.
const collapsedHostCount = Math.max(0, importedHosts.length - retainedImported.length);
return {
...merged,
hosts: [...existingHosts, ...retainedImported],
identities: nextIdentities,
keys: nextKeys,
customGroups,
duplicateCount: merged.duplicateCount + collapsedHostCount,
addedCount: merged.addedCount
- previousAddedGroupCount
+ nextAddedGroupCount
- collapsedHostCount
- prunedIdentityCount
- prunedKeyCount,
};
}
const stableJson = (value: unknown): string => {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
if (value && typeof value === 'object') {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
.join(',')}}`;
}
return JSON.stringify(value) ?? 'undefined';
};
const keyFingerprint = (key: SSHKey): string => stableJson({
type: key.type,
privateKey: key.privateKey,
publicKey: key.publicKey,
certificate: key.certificate,
filePath: key.filePath,
});
const identityFingerprint = (identity: Identity): string => stableJson({
username: identity.username.trim().toLowerCase(),
authMethod: identity.authMethod,
keyId: identity.keyId,
password: identity.password,
});
const hostFingerprint = (host: Host): string => host.pluginConnection
? stableJson({
protocol: host.protocol,
providerId: host.pluginConnection.providerId,
authenticationProviderId: host.pluginConnection.authenticationProviderId,
credentialId: host.pluginConnection.credentialId,
configuration: host.pluginConnection.configuration,
username: host.username.trim().toLowerCase(),
identityId: host.identityId,
telnetIdentityId: host.telnetIdentityId,
identityFileId: host.identityFileId,
})
: buildVaultHostMergeKey(host);
const snippetFingerprint = (snippet: Snippet): string => stableJson({
label: snippet.label.trim().toLowerCase(),
kind: snippet.kind,
command: snippet.command,
});
export function mergePluginImporterDrafts(
existing: Pick<PluginImporterMergeResult, 'hosts' | 'identities' | 'keys' | 'snippets' | 'customGroups'>,
drafts: PluginImporterDrafts,
): PluginImporterMergeResult {
let duplicateCount = 0;
const keyByFingerprint = new Map(existing.keys.map((key) => [keyFingerprint(key), key]));
const keyIdRemap = new Map<string, string>();
const addedKeys: SSHKey[] = [];
for (const key of drafts.keys) {
const fingerprint = keyFingerprint(key);
const duplicate = keyByFingerprint.get(fingerprint);
if (duplicate) {
duplicateCount += 1;
keyIdRemap.set(key.id, duplicate.id);
continue;
}
keyByFingerprint.set(fingerprint, key);
addedKeys.push(key);
}
const identityByFingerprint = new Map(existing.identities.map((identity) => [identityFingerprint(identity), identity]));
const identityIdRemap = new Map<string, string>();
const addedIdentities: Identity[] = [];
for (const source of drafts.identities) {
const identity = source.keyId && keyIdRemap.has(source.keyId)
? { ...source, keyId: keyIdRemap.get(source.keyId) }
: source;
const fingerprint = identityFingerprint(identity);
const duplicate = identityByFingerprint.get(fingerprint);
if (duplicate) {
duplicateCount += 1;
identityIdRemap.set(source.id, duplicate.id);
continue;
}
identityByFingerprint.set(fingerprint, identity);
addedIdentities.push(identity);
}
const hostFingerprints = new Set(existing.hosts.map(hostFingerprint));
const addedHosts: Host[] = [];
for (const source of drafts.hosts) {
const pluginCredentialId = source.pluginConnection?.credentialId;
const remappedPluginCredentialId = pluginCredentialId
? identityIdRemap.get(pluginCredentialId) ?? keyIdRemap.get(pluginCredentialId)
: undefined;
const host = sanitizeHost({
...source,
...(source.identityId && identityIdRemap.has(source.identityId)
? { identityId: identityIdRemap.get(source.identityId) }
: {}),
...(source.telnetIdentityId && identityIdRemap.has(source.telnetIdentityId)
? { telnetIdentityId: identityIdRemap.get(source.telnetIdentityId) }
: {}),
...(source.identityFileId && keyIdRemap.has(source.identityFileId)
? { identityFileId: keyIdRemap.get(source.identityFileId) }
: {}),
...(source.pluginConnection && remappedPluginCredentialId
? {
pluginConnection: {
...source.pluginConnection,
credentialId: remappedPluginCredentialId,
},
}
: {}),
});
const fingerprint = hostFingerprint(host);
if (hostFingerprints.has(fingerprint)) {
duplicateCount += 1;
continue;
}
hostFingerprints.add(fingerprint);
addedHosts.push(host);
}
const snippetFingerprints = new Set(existing.snippets.map(snippetFingerprint));
const addedSnippets = drafts.snippets.filter((snippet) => {
const fingerprint = snippetFingerprint(snippet);
if (snippetFingerprints.has(fingerprint)) {
duplicateCount += 1;
return false;
}
snippetFingerprints.add(fingerprint);
return true;
});
const customGroups = [...new Set([
...existing.customGroups,
...drafts.groups,
...addedHosts.flatMap((host) => host.group ? [host.group] : []),
])];
const addedCount = addedKeys.length + addedIdentities.length + addedHosts.length + addedSnippets.length
+ Math.max(0, customGroups.length - existing.customGroups.length);
return {
keys: [...existing.keys, ...addedKeys],
identities: [...existing.identities, ...addedIdentities],
hosts: [...existing.hosts, ...addedHosts],
snippets: [...existing.snippets, ...addedSnippets],
customGroups,
duplicateCount,
addedCount,
};
}