717 lines
28 KiB
TypeScript
717 lines
28 KiB
TypeScript
|
|
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
|
||
|
|
|
||
|
|
import {
|
||
|
|
readRememberedKeyPassphrases,
|
||
|
|
rememberImportedKeyPassphrase,
|
||
|
|
resolveDefaultKeyPassphraseAliases,
|
||
|
|
} from "../defaultKeyPassphrases";
|
||
|
|
import {
|
||
|
|
countVaultImportDuplicates,
|
||
|
|
ensureVaultImportPersisted,
|
||
|
|
mergeVaultImportedGroups,
|
||
|
|
rebaseVaultImportedHosts,
|
||
|
|
resolveUniqueManagedImportGroupName,
|
||
|
|
rollbackVaultImportedHosts,
|
||
|
|
waitForVaultImportProgressPaint,
|
||
|
|
type VaultHostPersistenceResult,
|
||
|
|
type VaultImportProgress,
|
||
|
|
} from "./vaultImportProgress";
|
||
|
|
import { importVaultHostsInWorker } from "./vaultImportWorker";
|
||
|
|
import {
|
||
|
|
type VaultLockHandle,
|
||
|
|
withVaultImportLock,
|
||
|
|
} from "./vaultManagedImportLock";
|
||
|
|
|
||
|
|
/** Exit the import lock so a concurrent host save can finish, then retry. */
|
||
|
|
const RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT = Symbol("retry-vault-import-after-concurrent-edit");
|
||
|
|
import { sanitizeHost } from "../../domain/host";
|
||
|
|
import {
|
||
|
|
applyVaultImportDestination,
|
||
|
|
applyVaultHostImport,
|
||
|
|
buildVaultHostEndpointKey,
|
||
|
|
buildVaultHostMergeKey,
|
||
|
|
filterVaultImportKeyPassphrasesAgainstExisting,
|
||
|
|
mergeVaultImportIssues,
|
||
|
|
resolveVaultImportKeyPassphraseConflicts,
|
||
|
|
type VaultImportFormat,
|
||
|
|
} from "../../domain/vaultImport";
|
||
|
|
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||
|
|
import type { VaultImportNotifier, VaultImportOptions } from "./vaultImportOptions";
|
||
|
|
|
||
|
|
interface UseVaultImportHandlersOptions {
|
||
|
|
customGroups: string[];
|
||
|
|
hosts: Host[];
|
||
|
|
keys: SSHKey[];
|
||
|
|
managedSources: ManagedSource[];
|
||
|
|
notify: VaultImportNotifier;
|
||
|
|
onReadPersistedHosts: () => Promise<Host[]>;
|
||
|
|
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||
|
|
onUpdateKeys: (keys: SSHKey[]) => void;
|
||
|
|
onReadPersistedManagedSources: () => ManagedSource[];
|
||
|
|
onCommitVaultImportTransaction: (
|
||
|
|
hosts: Host[],
|
||
|
|
updateGroups: (current: string[]) => string[],
|
||
|
|
updateSources: (current: ManagedSource[]) => ManagedSource[],
|
||
|
|
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||
|
|
expectedHosts?: Host[],
|
||
|
|
lock?: VaultLockHandle | null,
|
||
|
|
) => Promise<
|
||
|
|
| {
|
||
|
|
status: "persisted";
|
||
|
|
groups: string[];
|
||
|
|
sources: ManagedSource[];
|
||
|
|
groupConfigs: GroupConfig[];
|
||
|
|
}
|
||
|
|
| { status: "superseded" }
|
||
|
|
>;
|
||
|
|
setIsImportOpen: (open: boolean) => void;
|
||
|
|
t: (key: string, values?: Record<string, unknown>) => string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useVaultImportHandlers({
|
||
|
|
customGroups,
|
||
|
|
hosts,
|
||
|
|
keys,
|
||
|
|
managedSources,
|
||
|
|
notify,
|
||
|
|
onReadPersistedHosts,
|
||
|
|
onUpdateHosts,
|
||
|
|
onUpdateKeys,
|
||
|
|
onReadPersistedManagedSources,
|
||
|
|
onCommitVaultImportTransaction,
|
||
|
|
setIsImportOpen,
|
||
|
|
t,
|
||
|
|
}: UseVaultImportHandlersOptions) {
|
||
|
|
const [importProgress, setImportProgress] = useState<VaultImportProgress | null>(null);
|
||
|
|
const customGroupsRef = useRef(customGroups);
|
||
|
|
const hostsRef = useRef(hosts);
|
||
|
|
const keysRef = useRef(keys);
|
||
|
|
const managedSourcesRef = useRef(managedSources);
|
||
|
|
const activeImportAbortRef = useRef<AbortController | null>(null);
|
||
|
|
const importCommitStartedRef = useRef(false);
|
||
|
|
const importInFlightRef = useRef(false);
|
||
|
|
customGroupsRef.current = customGroups;
|
||
|
|
hostsRef.current = hosts;
|
||
|
|
keysRef.current = keys;
|
||
|
|
managedSourcesRef.current = managedSources;
|
||
|
|
const resetImportProgress = useCallback(() => setImportProgress(null), []);
|
||
|
|
|
||
|
|
useEffect(() => () => {
|
||
|
|
if (!importCommitStartedRef.current) activeImportAbortRef.current?.abort();
|
||
|
|
activeImportAbortRef.current = null;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const cancelImport = useCallback(() => {
|
||
|
|
if (importCommitStartedRef.current) return;
|
||
|
|
activeImportAbortRef.current?.abort();
|
||
|
|
activeImportAbortRef.current = null;
|
||
|
|
setImportProgress(null);
|
||
|
|
setIsImportOpen(false);
|
||
|
|
}, [setIsImportOpen]);
|
||
|
|
|
||
|
|
const handleImportFileSelected = useCallback(
|
||
|
|
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
|
||
|
|
const file = files[0];
|
||
|
|
if (!file) return;
|
||
|
|
if (importInFlightRef.current) return;
|
||
|
|
importInFlightRef.current = true;
|
||
|
|
activeImportAbortRef.current?.abort();
|
||
|
|
const abortController = new AbortController();
|
||
|
|
activeImportAbortRef.current = abortController;
|
||
|
|
importCommitStartedRef.current = false;
|
||
|
|
const { signal } = abortController;
|
||
|
|
const throwIfCancelled = () => {
|
||
|
|
if (signal.aborted) {
|
||
|
|
throw new DOMException("Vault import cancelled.", "AbortError");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
let rollbackSnapshot: {
|
||
|
|
baselineHosts: Host[];
|
||
|
|
appliedHosts: Host[];
|
||
|
|
} | null = null;
|
||
|
|
let rollbackPendingImport: () => Promise<void> = async () => undefined;
|
||
|
|
const relativeRoot = file.webkitRelativePath?.split(/[\\/]+/).filter(Boolean)[0];
|
||
|
|
const selectionName = files.length > 1 ? (relativeRoot || file.name) : file.name;
|
||
|
|
const formatLabel =
|
||
|
|
format === "putty"
|
||
|
|
? "PuTTY"
|
||
|
|
: format === "mobaxterm"
|
||
|
|
? "MobaXterm"
|
||
|
|
: format === "csv"
|
||
|
|
? "CSV"
|
||
|
|
: format === "securecrt"
|
||
|
|
? "SecureCRT"
|
||
|
|
: "ssh_config";
|
||
|
|
const updateProgress = (next: Partial<VaultImportProgress>) => {
|
||
|
|
setImportProgress((current) => ({
|
||
|
|
status: "running",
|
||
|
|
stage: "reading",
|
||
|
|
percent: 5,
|
||
|
|
formatLabel,
|
||
|
|
fileName: selectionName,
|
||
|
|
totalFiles: files.length,
|
||
|
|
...current,
|
||
|
|
...next,
|
||
|
|
}));
|
||
|
|
};
|
||
|
|
|
||
|
|
setIsImportOpen(false);
|
||
|
|
setImportProgress({
|
||
|
|
status: "running",
|
||
|
|
stage: "reading",
|
||
|
|
percent: 5,
|
||
|
|
formatLabel,
|
||
|
|
fileName: selectionName,
|
||
|
|
completedFiles: 0,
|
||
|
|
totalFiles: files.length,
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
let result = await importVaultHostsInWorker({
|
||
|
|
format,
|
||
|
|
files,
|
||
|
|
encoding: options?.encoding,
|
||
|
|
masterPassword: options?.masterPassword,
|
||
|
|
signal,
|
||
|
|
onProgress: (progress) => {
|
||
|
|
if (!signal.aborted) updateProgress(progress);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
throwIfCancelled();
|
||
|
|
const isManaged = format === "ssh_config" && options?.managed === true;
|
||
|
|
if (!isManaged) {
|
||
|
|
result = applyVaultImportDestination(
|
||
|
|
result,
|
||
|
|
options?.destination ?? { mode: "preserve" },
|
||
|
|
// SecureCRT keeps distinct session files that share an endpoint;
|
||
|
|
// only rewrite their group when the user picks an import location.
|
||
|
|
{ collapseDuplicateEndpoints: format !== "securecrt" },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
updateProgress({ stage: "preparing", percent: 70 });
|
||
|
|
await waitForVaultImportProgressPaint();
|
||
|
|
throwIfCancelled();
|
||
|
|
updateProgress({ stage: "saving", percent: 85 });
|
||
|
|
await waitForVaultImportProgressPaint();
|
||
|
|
throwIfCancelled();
|
||
|
|
|
||
|
|
const currentCustomGroups = customGroupsRef.current;
|
||
|
|
const currentHosts = hostsRef.current;
|
||
|
|
const currentManagedSources = managedSourcesRef.current;
|
||
|
|
const persistHosts = async (
|
||
|
|
nextHosts: Host[],
|
||
|
|
options?: {
|
||
|
|
baselineHosts?: Host[];
|
||
|
|
persistAttempt?: (
|
||
|
|
hosts: Host[],
|
||
|
|
baselineHosts: Host[],
|
||
|
|
) => Promise<VaultHostPersistenceResult>;
|
||
|
|
prepareAttempt?: (attempt: { baselineHosts: Host[]; appliedHosts: Host[] }) => Host[];
|
||
|
|
},
|
||
|
|
) => {
|
||
|
|
throwIfCancelled();
|
||
|
|
importCommitStartedRef.current = true;
|
||
|
|
let baselineHosts = options?.baselineHosts ?? currentHosts;
|
||
|
|
let appliedHosts = nextHosts;
|
||
|
|
while (true) {
|
||
|
|
appliedHosts = options?.prepareAttempt?.({ baselineHosts, appliedHosts }) ?? appliedHosts;
|
||
|
|
rollbackSnapshot = { baselineHosts, appliedHosts };
|
||
|
|
let persisted: VaultHostPersistenceResult;
|
||
|
|
if (options?.persistAttempt) {
|
||
|
|
persisted = await options.persistAttempt(appliedHosts, baselineHosts);
|
||
|
|
} else {
|
||
|
|
let hostUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||
|
|
startTransition(() => {
|
||
|
|
hostUpdate = onUpdateHosts(appliedHosts);
|
||
|
|
});
|
||
|
|
persisted = await hostUpdate!;
|
||
|
|
}
|
||
|
|
if (persisted !== "superseded") {
|
||
|
|
if (persisted !== false) rollbackSnapshot = null;
|
||
|
|
return persisted;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Locked import commits must release the shared lock before retrying
|
||
|
|
// so a concurrent host save queued behind that lock can finish first.
|
||
|
|
if (options?.persistAttempt) {
|
||
|
|
throw RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT;
|
||
|
|
}
|
||
|
|
|
||
|
|
const latestHosts = hostsRef.current;
|
||
|
|
hostsRef.current = latestHosts;
|
||
|
|
appliedHosts = rebaseVaultImportedHosts({
|
||
|
|
currentHosts: latestHosts,
|
||
|
|
baselineHosts,
|
||
|
|
appliedHosts,
|
||
|
|
});
|
||
|
|
baselineHosts = latestHosts;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
rollbackPendingImport = async () => {
|
||
|
|
const snapshot = rollbackSnapshot;
|
||
|
|
if (!snapshot) return;
|
||
|
|
while (true) {
|
||
|
|
const rollbackHosts = rollbackVaultImportedHosts({
|
||
|
|
currentHosts: hostsRef.current,
|
||
|
|
...snapshot,
|
||
|
|
});
|
||
|
|
let rollbackUpdate: VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||
|
|
startTransition(() => {
|
||
|
|
rollbackUpdate = onUpdateHosts(rollbackHosts);
|
||
|
|
});
|
||
|
|
const persisted = await rollbackUpdate!;
|
||
|
|
if (persisted === "superseded") continue;
|
||
|
|
if (persisted === false) {
|
||
|
|
throw new Error(t("vault.import.progress.persistFailed"));
|
||
|
|
}
|
||
|
|
rollbackSnapshot = null;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const fileBaseName = file.name.replace(/\.[^/.]+$/, "");
|
||
|
|
const requestedManagedGroup = options?.destination?.mode === "preserve"
|
||
|
|
? null
|
||
|
|
: options?.destination?.group ?? null;
|
||
|
|
let managedGroupName = requestedManagedGroup ?? `${fileBaseName} - Managed`;
|
||
|
|
|
||
|
|
// Check if this file is already managed
|
||
|
|
const bridge = (window as unknown as { netcatty?: { getPathForFile?: (file: File) => string | undefined } }).netcatty;
|
||
|
|
// Try bridge.getPathForFile first, then fall back to file.path (Electron legacy)
|
||
|
|
const filePath = bridge?.getPathForFile?.(file) || (file as File & { path?: string }).path;
|
||
|
|
|
||
|
|
if (isManaged && !filePath) {
|
||
|
|
// Cannot proceed with managed import without a valid file path
|
||
|
|
const message = t("vault.import.sshConfig.noFilePathDesc");
|
||
|
|
updateProgress({
|
||
|
|
status: "error",
|
||
|
|
stage: "failed",
|
||
|
|
error: message,
|
||
|
|
});
|
||
|
|
notify.error(
|
||
|
|
message,
|
||
|
|
t("vault.import.sshConfig.noFilePath"),
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isManaged) {
|
||
|
|
const existingSource = currentManagedSources.find(s => s.filePath === filePath);
|
||
|
|
if (existingSource) {
|
||
|
|
const message = t("vault.import.sshConfig.alreadyManagedDesc", {
|
||
|
|
group: existingSource.groupName,
|
||
|
|
});
|
||
|
|
updateProgress({
|
||
|
|
status: "error",
|
||
|
|
stage: "failed",
|
||
|
|
error: message,
|
||
|
|
});
|
||
|
|
notify.error(
|
||
|
|
message,
|
||
|
|
t("vault.import.sshConfig.alreadyManaged"),
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
|
||
|
|
// group as part of session identity so direct vs proxy copies can coexist.
|
||
|
|
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
|
||
|
|
|
||
|
|
const existingKeys = new Set(currentHosts.map(makeKey));
|
||
|
|
// Filter out duplicates for both managed and non-managed imports
|
||
|
|
let newHosts = format === "securecrt"
|
||
|
|
? result.hosts
|
||
|
|
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||
|
|
|
||
|
|
// For managed imports, also update existing hosts to be managed
|
||
|
|
let updatedExistingHosts: Host[] = [];
|
||
|
|
if (isManaged) {
|
||
|
|
const importedKeys = new Set(result.hosts.map(makeKey));
|
||
|
|
updatedExistingHosts = currentHosts.filter((h) => importedKeys.has(makeKey(h)));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isManaged && (newHosts.length > 0 || updatedExistingHosts.length > 0)) {
|
||
|
|
while (true) {
|
||
|
|
try {
|
||
|
|
await withVaultImportLock("vault", async (lock) => {
|
||
|
|
const latestPersistedSources = onReadPersistedManagedSources();
|
||
|
|
managedSourcesRef.current = latestPersistedSources;
|
||
|
|
const sourceClaim = latestPersistedSources.find((source) => source.filePath === filePath);
|
||
|
|
if (sourceClaim) {
|
||
|
|
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
|
||
|
|
group: sourceClaim.groupName,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
const managedBaselineHosts = await onReadPersistedHosts();
|
||
|
|
hostsRef.current = managedBaselineHosts;
|
||
|
|
const managedExistingKeys = new Set(managedBaselineHosts.map(makeKey));
|
||
|
|
newHosts = result.hosts.filter((host) => !managedExistingKeys.has(makeKey(host)));
|
||
|
|
const managedImportedKeys = new Set(result.hosts.map(makeKey));
|
||
|
|
updatedExistingHosts = managedBaselineHosts.filter((host) => (
|
||
|
|
!host.managedSourceId && managedImportedKeys.has(makeKey(host))
|
||
|
|
));
|
||
|
|
if (newHosts.length === 0 && updatedExistingHosts.length === 0) return;
|
||
|
|
const sourceId = crypto.randomUUID();
|
||
|
|
let newSource: ManagedSource = {
|
||
|
|
id: sourceId,
|
||
|
|
type: "ssh_config",
|
||
|
|
filePath: filePath,
|
||
|
|
groupName: managedGroupName,
|
||
|
|
lastSyncedAt: Date.now(),
|
||
|
|
};
|
||
|
|
|
||
|
|
newHosts = newHosts.map((h) => ({
|
||
|
|
...h,
|
||
|
|
group: managedGroupName,
|
||
|
|
// Only SSH hosts can be managed (SSH config only supports SSH)
|
||
|
|
managedSourceId: (!h.protocol || h.protocol === "ssh") ? sourceId : undefined,
|
||
|
|
}));
|
||
|
|
|
||
|
|
// Update existing hosts to be managed (move to managed group)
|
||
|
|
const existingHostIds = new Set(updatedExistingHosts.map(h => h.id));
|
||
|
|
const updatedHosts = managedBaselineHosts.map((h) => {
|
||
|
|
if (!existingHostIds.has(h.id)) return h;
|
||
|
|
const canBeManaged = !h.protocol || h.protocol === "ssh";
|
||
|
|
return {
|
||
|
|
...h,
|
||
|
|
group: managedGroupName,
|
||
|
|
managedSourceId: canBeManaged ? sourceId : undefined,
|
||
|
|
// Sanitize label for managed hosts
|
||
|
|
label: canBeManaged && h.label ? h.label.replace(/\s/g, '') : h.label,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
let nextGroups: string[] = [];
|
||
|
|
const ensureManagedSourceStillAvailable = () => {
|
||
|
|
const conflictingSource = managedSourcesRef.current.find((source) => (
|
||
|
|
source.id !== sourceId && source.filePath === filePath
|
||
|
|
));
|
||
|
|
if (conflictingSource) {
|
||
|
|
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
|
||
|
|
group: conflictingSource.groupName,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const prepareManagedAttempt = ({
|
||
|
|
baselineHosts,
|
||
|
|
appliedHosts,
|
||
|
|
}: {
|
||
|
|
baselineHosts: Host[];
|
||
|
|
appliedHosts: Host[];
|
||
|
|
}) => {
|
||
|
|
ensureManagedSourceStillAvailable();
|
||
|
|
managedGroupName = requestedManagedGroup ?? resolveUniqueManagedImportGroupName({
|
||
|
|
baseName: fileBaseName,
|
||
|
|
customGroups: customGroupsRef.current,
|
||
|
|
hosts: baselineHosts,
|
||
|
|
managedSources: managedSourcesRef.current,
|
||
|
|
ownerSourceId: sourceId,
|
||
|
|
});
|
||
|
|
newSource = { ...newSource, groupName: managedGroupName };
|
||
|
|
nextGroups = Array.from(new Set([
|
||
|
|
...currentCustomGroups,
|
||
|
|
...(requestedManagedGroup ? [] : result.groups),
|
||
|
|
managedGroupName,
|
||
|
|
]));
|
||
|
|
return appliedHosts.map((host) => (
|
||
|
|
host.managedSourceId === sourceId
|
||
|
|
? { ...host, group: managedGroupName }
|
||
|
|
: host
|
||
|
|
));
|
||
|
|
};
|
||
|
|
const hostPersisted = await persistHosts(
|
||
|
|
[...updatedHosts, ...newHosts].map((host: Host) => sanitizeHost(host)),
|
||
|
|
{
|
||
|
|
baselineHosts: managedBaselineHosts,
|
||
|
|
prepareAttempt: prepareManagedAttempt,
|
||
|
|
persistAttempt: async (hostsToCommit, baselineHosts) => {
|
||
|
|
const transaction = await onCommitVaultImportTransaction(
|
||
|
|
hostsToCommit,
|
||
|
|
(latestPersistedGroups) => mergeVaultImportedGroups({
|
||
|
|
currentGroups: latestPersistedGroups,
|
||
|
|
baselineGroups: currentCustomGroups,
|
||
|
|
appliedGroups: nextGroups,
|
||
|
|
}),
|
||
|
|
(latestPersistedSources) => {
|
||
|
|
const conflictingSource = latestPersistedSources.find((source) => (
|
||
|
|
source.id !== sourceId && source.filePath === filePath
|
||
|
|
));
|
||
|
|
if (conflictingSource) {
|
||
|
|
throw new Error(t("vault.import.sshConfig.alreadyManagedDesc", {
|
||
|
|
group: conflictingSource.groupName,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
return [
|
||
|
|
...latestPersistedSources.filter((source) => source.id !== newSource.id),
|
||
|
|
newSource,
|
||
|
|
];
|
||
|
|
},
|
||
|
|
undefined,
|
||
|
|
baselineHosts,
|
||
|
|
lock,
|
||
|
|
);
|
||
|
|
if (transaction.status === "superseded") return "superseded";
|
||
|
|
customGroupsRef.current = transaction.groups;
|
||
|
|
managedSourcesRef.current = transaction.sources;
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
);
|
||
|
|
await ensureVaultImportPersisted(
|
||
|
|
hostPersisted,
|
||
|
|
t("vault.import.progress.persistFailed"),
|
||
|
|
undefined,
|
||
|
|
rollbackPendingImport,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
} catch (error) {
|
||
|
|
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
|
||
|
|
// Drain concurrent locked writes outside the critical section.
|
||
|
|
hostsRef.current = await onReadPersistedHosts();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if (newHosts.length > 0) {
|
||
|
|
let addedHostIds = new Set<string>();
|
||
|
|
let addedHostKeyPaths = new Map<string, string>();
|
||
|
|
while (true) {
|
||
|
|
try {
|
||
|
|
await withVaultImportLock("vault", async (lock) => {
|
||
|
|
const importBaselineHosts = await onReadPersistedHosts();
|
||
|
|
hostsRef.current = importBaselineHosts;
|
||
|
|
const importBaselineGroups = customGroupsRef.current;
|
||
|
|
const merged = applyVaultHostImport(
|
||
|
|
importBaselineHosts,
|
||
|
|
importBaselineGroups,
|
||
|
|
result,
|
||
|
|
{ skipDuplicates: format !== "securecrt" },
|
||
|
|
);
|
||
|
|
newHosts = merged.addedHosts;
|
||
|
|
addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
|
||
|
|
addedHostKeyPaths = new Map(merged.addedHosts.flatMap((host) => {
|
||
|
|
const keyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||
|
|
return keyPath ? [[host.id, keyPath] as const] : [];
|
||
|
|
}));
|
||
|
|
if (newHosts.length === 0) return;
|
||
|
|
const hostPersisted = await persistHosts(merged.hosts, {
|
||
|
|
baselineHosts: importBaselineHosts,
|
||
|
|
persistAttempt: async (hostsToCommit, baselineHosts) => {
|
||
|
|
const transaction = await onCommitVaultImportTransaction(
|
||
|
|
hostsToCommit,
|
||
|
|
(latestPersistedGroups) => mergeVaultImportedGroups({
|
||
|
|
currentGroups: latestPersistedGroups,
|
||
|
|
baselineGroups: importBaselineGroups,
|
||
|
|
appliedGroups: merged.customGroups,
|
||
|
|
}),
|
||
|
|
(latestPersistedSources) => latestPersistedSources,
|
||
|
|
undefined,
|
||
|
|
baselineHosts,
|
||
|
|
lock,
|
||
|
|
);
|
||
|
|
if (transaction.status === "superseded") return "superseded";
|
||
|
|
customGroupsRef.current = transaction.groups;
|
||
|
|
managedSourcesRef.current = transaction.sources;
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await ensureVaultImportPersisted(
|
||
|
|
hostPersisted,
|
||
|
|
t("vault.import.progress.persistFailed"),
|
||
|
|
undefined,
|
||
|
|
rollbackPendingImport,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
} catch (error) {
|
||
|
|
if (error !== RETRY_VAULT_IMPORT_AFTER_CONCURRENT_EDIT) throw error;
|
||
|
|
hostsRef.current = await onReadPersistedHosts();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throwIfCancelled();
|
||
|
|
const resolved = await resolveVaultImportKeyPassphraseConflicts(
|
||
|
|
result.keyPassphraseCandidates ?? result.keyPassphrases ?? [],
|
||
|
|
resolveDefaultKeyPassphraseAliases,
|
||
|
|
addedHostIds,
|
||
|
|
addedHostKeyPaths,
|
||
|
|
);
|
||
|
|
throwIfCancelled();
|
||
|
|
const checked = await filterVaultImportKeyPassphrasesAgainstExisting(
|
||
|
|
resolved.keyPassphrases,
|
||
|
|
(keyPath) => readRememberedKeyPassphrases(keyPath, keysRef.current),
|
||
|
|
);
|
||
|
|
throwIfCancelled();
|
||
|
|
result.issues = mergeVaultImportIssues(
|
||
|
|
result.issues,
|
||
|
|
resolved.issues,
|
||
|
|
checked.issues,
|
||
|
|
);
|
||
|
|
for (const entry of checked.keyPassphrases) {
|
||
|
|
try {
|
||
|
|
const saved = await rememberImportedKeyPassphrase({
|
||
|
|
keyPath: entry.keyPath,
|
||
|
|
passphrase: entry.passphrase,
|
||
|
|
keys: keysRef.current,
|
||
|
|
getKeys: () => keysRef.current,
|
||
|
|
updateKeys: onUpdateKeys,
|
||
|
|
setCurrentKeys: (updatedKeys) => {
|
||
|
|
keysRef.current = updatedKeys;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
throwIfCancelled();
|
||
|
|
if (saved === "conflict") {
|
||
|
|
result.issues.push({
|
||
|
|
level: "warning",
|
||
|
|
message: `CSV passphrase conflicts with an existing saved passphrase for KeyPath "${entry.keyPath}"; the existing passphrase was kept.`,
|
||
|
|
});
|
||
|
|
} else if (saved === "unreadable") {
|
||
|
|
result.issues.push({
|
||
|
|
level: "warning",
|
||
|
|
message: `Could not verify the existing saved passphrase for KeyPath "${entry.keyPath}"; the imported passphrase was not saved.`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
result.issues.push({
|
||
|
|
level: "warning",
|
||
|
|
message: `Could not save the passphrase for KeyPath "${entry.keyPath}".`,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result.issues = mergeVaultImportIssues(result.issues);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Count total hosts affected (new + converted to managed)
|
||
|
|
const totalAffected = newHosts.length + (isManaged ? updatedExistingHosts.length : 0);
|
||
|
|
|
||
|
|
const skipped = result.stats.skipped;
|
||
|
|
const duplicates = countVaultImportDuplicates({
|
||
|
|
importedHostCount: result.hosts.length,
|
||
|
|
newHostCount: newHosts.length,
|
||
|
|
fileDuplicateCount: result.stats.duplicates,
|
||
|
|
managed: isManaged,
|
||
|
|
});
|
||
|
|
const hasWarnings = skipped > 0 || duplicates > 0 || result.issues.length > 0;
|
||
|
|
|
||
|
|
if (result.stats.parsed === 0 && totalAffected === 0) {
|
||
|
|
const message = t("vault.import.toast.noEntries", { format: formatLabel });
|
||
|
|
updateProgress({
|
||
|
|
status: "error",
|
||
|
|
stage: "failed",
|
||
|
|
error: message,
|
||
|
|
});
|
||
|
|
notify.error(
|
||
|
|
message,
|
||
|
|
t("vault.import.toast.failedTitle"),
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (totalAffected === 0) {
|
||
|
|
updateProgress({
|
||
|
|
status: "complete",
|
||
|
|
stage: "complete",
|
||
|
|
percent: 100,
|
||
|
|
imported: 0,
|
||
|
|
skipped,
|
||
|
|
duplicates,
|
||
|
|
});
|
||
|
|
notify.warning(
|
||
|
|
t("vault.import.toast.noNewHosts", { format: formatLabel }),
|
||
|
|
t("vault.import.toast.completedTitle"),
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
throwIfCancelled();
|
||
|
|
rollbackSnapshot = null;
|
||
|
|
|
||
|
|
if (isManaged) {
|
||
|
|
notify.success(
|
||
|
|
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
|
||
|
|
t("vault.import.toast.completedTitle"),
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
const details = t("vault.import.toast.summary", {
|
||
|
|
count: totalAffected,
|
||
|
|
skipped,
|
||
|
|
duplicates,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (hasWarnings) {
|
||
|
|
const firstIssue = result.issues[0]?.message;
|
||
|
|
notify.warning(
|
||
|
|
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
|
||
|
|
t("vault.import.toast.completedTitle"),
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
notify.success(details, t("vault.import.toast.completedTitle"));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
updateProgress({
|
||
|
|
status: "complete",
|
||
|
|
stage: "complete",
|
||
|
|
percent: 100,
|
||
|
|
imported: totalAffected,
|
||
|
|
skipped,
|
||
|
|
duplicates,
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
let rollbackFailure: unknown;
|
||
|
|
if (rollbackSnapshot) {
|
||
|
|
try {
|
||
|
|
await rollbackPendingImport();
|
||
|
|
} catch (rollbackError) {
|
||
|
|
rollbackFailure = rollbackError;
|
||
|
|
console.error("[vault import] Failed to rollback imported hosts.", rollbackError);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
!rollbackFailure
|
||
|
|
&& (
|
||
|
|
signal.aborted
|
||
|
|
|| (err instanceof DOMException && err.name === "AbortError")
|
||
|
|
)
|
||
|
|
) return;
|
||
|
|
const originalMessage =
|
||
|
|
err instanceof Error ? err.message : t("common.unknownError");
|
||
|
|
const message = rollbackFailure
|
||
|
|
? `${originalMessage} ${t("vault.import.progress.rollbackFailed")}`
|
||
|
|
: originalMessage;
|
||
|
|
updateProgress({
|
||
|
|
status: "error",
|
||
|
|
stage: "failed",
|
||
|
|
error: message,
|
||
|
|
});
|
||
|
|
notify.error(message, t("vault.import.toast.failedTitle"));
|
||
|
|
} finally {
|
||
|
|
if (activeImportAbortRef.current === abortController) {
|
||
|
|
activeImportAbortRef.current = null;
|
||
|
|
}
|
||
|
|
importCommitStartedRef.current = false;
|
||
|
|
importInFlightRef.current = false;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[
|
||
|
|
notify,
|
||
|
|
onReadPersistedHosts,
|
||
|
|
onCommitVaultImportTransaction,
|
||
|
|
onReadPersistedManagedSources,
|
||
|
|
onUpdateHosts,
|
||
|
|
onUpdateKeys,
|
||
|
|
setIsImportOpen,
|
||
|
|
t,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
cancelImport,
|
||
|
|
handleImportFileSelected,
|
||
|
|
importProgress,
|
||
|
|
resetImportProgress,
|
||
|
|
};
|
||
|
|
}
|