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
744 lines
29 KiB
TypeScript
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,
|
|
};
|
|
}
|