Compare commits

...
11 changed files with 469 additions and 34 deletions

No files matched your search

@@ -133,6 +133,11 @@ export type OpenTransferSftpSessionOptions = {
* credentials. When false, try sourceSessionId first (shared transport).
*/
dedicated?: boolean;
/**
* Connect-time jump-host snapshots. When set, this session dials through the
* pinned chain the pane connected with, not the live (possibly edited) chain.
*/
jumpHosts?: Host[];
};
/**
@@ -159,6 +164,7 @@ export async function openTransferSftpSession(
identities: [...deps.identities],
knownHosts: deps.knownHosts ? [...deps.knownHosts] : undefined,
terminalSettings: deps.terminalSettings,
jumpHostsOverride: options?.jumpHosts,
});
// Shared transport path: open an SFTP channel on the already-authenticated
// terminal connection. The transport registry lease keeps the conn alive
+25 -2
View File
@@ -137,11 +137,26 @@ async function tryStatTransferTarget(
return tryStatTransferPath(targetPath, targetIsLocal, targetSftpId, targetEncoding);
}
export interface TransferConnectHosts {
source?: Host;
target?: Host;
/**
* Connect-time jump-host chain snapshots. Pinned together with the top-level
* host so leases opened after a bastion edit still dial through the chain the
* pane's session connected with (private hostnames can resolve differently
* per bastion). Present only when the matching endpoint is pinned.
*/
sourceChain?: Host[];
targetChain?: Host[];
}
export type AcquireTransferSessionFn = (
hostId: string,
transferId: string,
/** Connect-time host (session hostname/port/user overrides). Prefer over vault. */
connectHost?: Host,
/** Connect-time jump-host snapshots; undefined re-resolves the live chain. */
jumpHosts?: Host[],
) => Promise<TransferConnectionLease>;
interface UseSftpDirectoryTransferOpsParams {
@@ -268,6 +283,7 @@ export function useSftpDirectoryTransferOps({
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
connectHosts?: TransferConnectHosts,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -315,10 +331,14 @@ export function useSftpDirectoryTransferOps({
// Dedicated pool only for remote ends — panel/browse sessions die when
// the SFTP tab is closed, which freezes global transfer center rows.
if (acquireTransferSession && !sourceIsLocal && task.sourceHostId) {
sourceLease = await acquireTransferSession(task.sourceHostId, task.id);
sourceLease = await acquireTransferSession(
task.sourceHostId, task.id, connectHosts?.source, connectHosts?.sourceChain,
);
}
if (acquireTransferSession && !targetIsLocal && task.targetHostId) {
targetLease = await acquireTransferSession(task.targetHostId, task.id);
targetLease = await acquireTransferSession(
task.targetHostId, task.id, connectHosts?.target, connectHosts?.targetChain,
);
}
};
@@ -541,6 +561,7 @@ export function useSftpDirectoryTransferOps({
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
discoveryProgress?: DirectoryDiscoveryProgress,
traversalBudget?: SftpDirectoryTraversalBudget,
connectHosts?: TransferConnectHosts,
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -698,6 +719,7 @@ export function useSftpDirectoryTransferOps({
followSymlinks,
progress,
traversal,
connectHosts,
);
}
} finally {
@@ -925,6 +947,7 @@ export function useSftpDirectoryTransferOps({
targetEncoding,
rootTaskId,
sameHost,
connectHosts,
);
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
@@ -42,6 +42,12 @@ interface UseSftpConnectionsParams {
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
connectedHostByTabIdRef: MutableRefObject<Map<string, Host | "local">>;
/**
* Jump-host chain snapshots captured when each tab connected, keyed by tab
* id. Same lifecycle as connectedHostByTabIdRef; used to keep transfer
* session opens on the pane's connect-time bastion chain after chain edits.
*/
connectedHostChainByTabIdRef?: MutableRefObject<Map<string, Host[]>>;
connectInFlightRef: MutableRefObject<Map<string, Promise<void>>>;
connectRequestByTabIdRef: MutableRefObject<Map<string, symbol>>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
@@ -415,12 +421,15 @@ export async function closeSftpTabLifecycle(params: {
connectRequests: Map<string, symbol>;
connectInFlight: Map<string, Promise<void>>;
connectedHosts: { delete: (tabId: string) => boolean };
/** Connect-time jump-host chain snapshots, same tab-id lifecycle. */
connectedHostChains?: { delete: (tabId: string) => boolean };
closeTab: (side: "left" | "right") => void;
releaseConnection: () => Promise<void>;
}): Promise<void> {
invalidateSftpTabConnectRequest(params.connectRequests, params.tabId);
clearSftpConnectInFlightForTab(params.connectInFlight, params.tabId);
params.connectedHosts.delete(params.tabId);
params.connectedHostChains?.delete(params.tabId);
applyToLiveSftpTabSide({
requestedSide: params.requestedSide,
tabId: params.tabId,
@@ -581,6 +590,7 @@ export const useSftpConnections = ({
lastConnectedHostRef,
connectionCacheKeyMapRef,
connectedHostByTabIdRef,
connectedHostChainByTabIdRef,
connectInFlightRef,
connectRequestByTabIdRef,
makeCacheKey,
@@ -863,6 +873,24 @@ export const useSftpConnections = ({
// Always remember the full connect-time Host for this tab so background
// upload reconnects can restore session-time overrides (not just vault hostId).
connectedHostByTabIdRef.current.set(activeTabId, host);
// Snapshot the resolved jump-host chain so retained transfer leases keep
// dialing the chain this tab connected through, even after one of the
// bastion hosts is edited (private hostnames can resolve differently
// per bastion — the live chain must not silently replace this one).
if (connectedHostChainByTabIdRef) {
if (host !== "local" && host.hostChain?.hostIds?.length) {
const chain = host.hostChain.hostIds
.map((chainId) => hosts.find((candidate) => candidate.id === chainId))
.filter((candidate): candidate is Host => !!candidate);
if (chain.length === host.hostChain.hostIds.length) {
connectedHostChainByTabIdRef.current.set(activeTabId, chain);
} else {
connectedHostChainByTabIdRef.current.delete(activeTabId);
}
} else {
connectedHostChainByTabIdRef.current.delete(activeTabId);
}
}
// Store the cache key for this connection so pane actions can look it up
// by connectionId instead of relying on the per-side lastConnectedHostRef.
if (host !== "local") {
@@ -1426,6 +1454,7 @@ export const useSftpConnections = ({
lastConnectedHostRef.current[side] = null;
connectedHostByTabIdRef.current.delete(activeTabId);
connectedHostChainByTabIdRef?.current.delete(activeTabId);
if (pane.connection && !pane.connection.isLocal && sftpId) {
try {
@@ -148,6 +148,7 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef,
ensureRemoteSftpId,
resolveConnectedHost,
resolveConnectedChain,
acquireTransferSession,
clearDirCacheEntry,
useCompressedUpload = false,
@@ -165,6 +166,15 @@ export const useSftpExternalOperations = (
[resolveConnectedHost],
);
/** Jump-host chain the tab dialed at connect time, when still meaningful. */
const resolveUploadConnectChain = useCallback(
(tabId: string, isLocal: boolean): Host[] | undefined => {
if (isLocal || !resolveConnectedChain) return undefined;
return resolveConnectedChain(tabId) ?? undefined;
},
[resolveConnectedChain],
);
/**
* Resolve an SFTP id for upload prep (mkdir/stat/conflict checks).
* File bytes use dedicated pool connections inside startStreamTransfer
@@ -843,6 +853,7 @@ export const useSftpExternalOperations = (
// (session hostname/port/user overrides), not the vault entry by hostId alone.
const createUploadBridge = useCallback((
connectHost?: Host,
connectChain?: Host[],
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
): UploadBridge => {
const bridge = netcattyBridge.get();
@@ -920,6 +931,7 @@ export const useSftpExternalOperations = (
options.targetHostId,
options.transferId,
connectHost,
connectChain,
);
}
@@ -1042,8 +1054,10 @@ export const useSftpExternalOperations = (
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(
connectHost,
connectChain,
readSftpFileTransferConcurrency,
);
const liveCallbacks = livePane.connection.id === pane.connection.id
@@ -1209,7 +1223,8 @@ export const useSftpExternalOperations = (
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost, connectChain);
// Retarget callbacks if the pane path/id changed after reconnect.
const liveCallbacks = livePane.connection.id === pane.connection.id
@@ -1252,7 +1267,7 @@ export const useSftpExternalOperations = (
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
@@ -1331,6 +1346,7 @@ export const useSftpExternalOperations = (
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
resolveUploadConnectChain,
useCompressedUpload,
],
);
@@ -1367,7 +1383,8 @@ export const useSftpExternalOperations = (
),
);
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost, connectChain);
try {
const files = Array.from(fileList);
@@ -1382,7 +1399,7 @@ export const useSftpExternalOperations = (
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadFromFileList(
@@ -1441,6 +1458,7 @@ export const useSftpExternalOperations = (
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
resolveUploadConnectChain,
useCompressedUpload,
],
);
@@ -1557,7 +1575,11 @@ export const useSftpExternalOperations = (
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost);
const uploadConnectChain = resolveUploadConnectChain(
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost, uploadConnectChain);
const liveCallbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
@@ -1603,7 +1625,7 @@ export const useSftpExternalOperations = (
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost)
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost, uploadConnectChain)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
@@ -1687,6 +1709,7 @@ export const useSftpExternalOperations = (
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
resolveUploadConnectChain,
useCompressedUpload,
],
);
@@ -1775,7 +1798,8 @@ export const useSftpExternalOperations = (
),
);
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const directUploadBridge = createUploadBridge(connectHost);
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
const directUploadBridge = createUploadBridge(connectHost, connectChain);
try {
const hasDirectory = entries.some((entry) => (
@@ -1789,7 +1813,7 @@ export const useSftpExternalOperations = (
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
@@ -1856,6 +1880,7 @@ export const useSftpExternalOperations = (
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
resolveUploadConnectChain,
useCompressedUpload,
],
);
@@ -32,6 +32,12 @@ export interface UseSftpExternalOperationsParams {
* Used so pooled stream uploads open the pinned browse endpoint.
*/
resolveConnectedHost?: (tabId: string) => Host | "local" | null | undefined;
/**
* Jump-host chain snapshots captured when the tab connected, keyed by tab id.
* Lets pooled uploads keep dialing the pane's connect-time bastion chain
* after a chain host is edited.
*/
resolveConnectedChain?: (tabId: string) => Host[] | null | undefined;
/**
* FileZilla-style dedicated transfer sessions for bulk uploads.
* When set, remote stream uploads prefer pool connections (1–2/host)
@@ -41,6 +47,7 @@ export interface UseSftpExternalOperationsParams {
hostId: string,
transferId: string,
connectHost?: Host,
connectChain?: Host[],
) => Promise<{ sftpId: string; release: () => void; discard: () => void }>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
useCompressedUpload?: boolean;
@@ -707,3 +707,30 @@ test("imported IdentityFile paths remain usable when agent login is disabled", (
assert.equal(credentials.useSshAgent, false);
assert.deepEqual(credentials.identityFilePaths, ["~/.ssh/id_work"]);
});
test("buildSftpHostCredentials pins the captured jump chain over edited vault hosts", () => {
const oldBastion = host({ id: "jump-1", hostname: "bastion-internal.old" });
const editedBastion = host({ id: "jump-1", hostname: "bastion-internal.edited" });
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [editedBastion],
keys: [],
identities: [],
jumpHostsOverride: [oldBastion],
});
assert.equal(credentials.jumpHosts?.[0]?.hostname, "bastion-internal.old");
assert.equal(credentials.jumpHosts?.[0]?.hostId, "jump-1");
});
test("buildSftpHostCredentials re-resolves the live chain without a pinned snapshot", () => {
const editedBastion = host({ id: "jump-1", hostname: "bastion-internal.edited" });
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [editedBastion],
keys: [],
identities: [],
});
assert.equal(credentials.jumpHosts?.[0]?.hostname, "bastion-internal.edited");
});
@@ -38,7 +38,17 @@ export const buildSftpHostCredentials = ({
identities,
knownHosts,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
jumpHostsOverride,
}: UseSftpHostCredentialsParams & {
host: Host;
/**
* Connect-time jump-host snapshots. When set, the chain resolves from these
* pinned Host objects instead of the live `hosts` array, so leases opened
* after a bastion edit keep connecting through the chain the pane's session
* dialed — private hostnames can resolve differently per bastion.
*/
jumpHostsOverride?: Host[];
}): NetcattySSHOptions => {
const globalTerminalSettings = { ...FALLBACK_TERMINAL_SETTINGS, ...(terminalSettings ?? {}) };
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
@@ -57,12 +67,21 @@ export const buildSftpHostCredentials = ({
? resolveProxyConfigAuth(host.proxyConfig, identities)
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds.map((hostId) => {
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
if (!jumpHost) {
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
}
const resolveChainHost = (hostId: string): Host => {
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
if (!jumpHost) {
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
}
return jumpHost;
};
// A non-empty pinned chain snapshot wins over live hostChain resolution.
const chainHosts: Host[] | null = jumpHostsOverride && jumpHostsOverride.length > 0
? jumpHostsOverride
: host.hostChain?.hostIds && host.hostChain.hostIds.length > 0
? host.hostChain.hostIds.map(resolveChainHost)
: null;
if (chainHosts) {
jumpHosts = chainHosts.map((jumpHost, index) => {
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
}
@@ -72,8 +91,6 @@ export const buildSftpHostCredentials = ({
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
}
return jumpHost;
}).map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
@@ -5,6 +5,7 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import type { Host } from "../../../domain/models";
import type { SftpPane } from "./types";
type StartOptions = {
@@ -83,6 +84,160 @@ function installGlobals(netty: Record<string, unknown>) {
};
}
for (const pinned of [false, true]) {
for (const direction of ["download", "upload"] as const) {
test(`${pinned ? "existing" : "new"} dual-pane ${direction} preserves its endpoint after host edits`, async () => {
const acquired: string[] = [];
const started: StartOptions[] = [];
const restore = installGlobals({
statSftp: async () => direction === "download"
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
: null,
statLocal: async () => direction === "upload"
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
: null,
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: options.transferId,
transferred: options.totalBytes ?? 0, totalBytes: options.totalBytes ?? 0, lifecycleEpoch: 0,
});
return {};
},
});
// These pane callbacks stay stable in the app while the saved hosts change.
const getActivePane = (side: "left" | "right") => makePane(side);
const getPaneByConnectionId = () => null;
const getTabByConnectionId = () => null;
const updateTab = () => undefined;
const sftpSessionsRef = { current: new Map([["remote-conn", "browse-session"]]) };
const pinnedHost = { id: "host-1", hostname: "old-endpoint" } as Host;
const pinnedChain = [{ id: "jump-1", hostname: "old-bastion" } as Host];
const acquiredChains: string[] = [];
const acquire = (savedEndpoint: string) => async (
_hostId: string, _jobId: string, connectHost?: Host, connectChain?: Host[],
) => {
const endpoint = connectHost?.hostname ?? savedEndpoint;
acquired.push(endpoint);
acquiredChains.push(connectChain?.map((hop) => hop.hostname).join(" -> ") ?? "unpinned");
return { sftpId: endpoint, poolKey: endpoint, release: () => undefined, discard: () => undefined };
};
const original = acquire("old-endpoint");
const refreshed = acquire("new-endpoint");
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe({ acquireSession }: { acquireSession: typeof original }) {
ops = useSftpTransfers({
ownerId: `refresh-host-${direction}`,
getActivePane, getPaneByConnectionId, getTabByConnectionId, updateTab,
refresh: async () => undefined, clearCacheForConnection: () => undefined,
handleSessionError: () => undefined, sftpSessionsRef,
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => [],
acquireTransferSession: acquireSession,
resolveConnectedHost: () => pinned ? pinnedHost : null,
resolveConnectedChain: pinned ? () => pinnedChain : undefined,
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe, { acquireSession: original })); });
await act(async () => { renderer!.update(React.createElement(Probe, { acquireSession: refreshed })); });
await act(async () => {
await ops!.startTransfer([{ name: "aops_dns_view_demo.sh", isDirectory: false }],
direction === "download" ? "left" : "right", direction === "download" ? "right" : "left");
});
assert.equal(acquired.length, 2, "both the work lease and file lease must use the pane endpoint");
assert.ok(acquired.every((endpoint) => endpoint === (pinned ? "old-endpoint" : "new-endpoint")), JSON.stringify(acquired));
if (pinned) {
assert.ok(
acquiredChains.every((chain) => chain === "old-bastion"),
`the pinned jump chain must ride along with the pinned endpoint: ${JSON.stringify(acquiredChains)}`,
);
} else {
assert.ok(
acquiredChains.every((chain) => chain === "unpinned"),
`no chain may attach when the connect host is unresolved: ${JSON.stringify(acquiredChains)}`,
);
}
assert.equal(started.length, 1);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
}
}
for (const direction of ["download", "upload"] as const) {
test(`dual-pane ${direction} started during a reconnect falls back to the pane endpoint, not the incoming tab host`, async () => {
const acquired: string[] = [];
const started: StartOptions[] = [];
const restore = installGlobals({
statSftp: async () => direction === "download"
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
: null,
statLocal: async () => direction === "upload"
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
: null,
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: options.transferId,
transferred: options.totalBytes ?? 0, totalBytes: options.totalBytes ?? 0, lifecycleEpoch: 0,
});
return {};
},
});
const getActivePane = (side: "left" | "right") => makePane(side);
const getPaneByConnectionId = () => null;
const getTabByConnectionId = () => null;
const updateTab = () => undefined;
const sftpSessionsRef = { current: new Map([["remote-conn", "browse-session"]]) };
// connect() has already recorded the incoming endpoint for this tab id
// while the pane still shows the old connection (reconnect in flight).
const incomingHost = { id: "host-2", hostname: "reconnect-endpoint" } as Host;
const acquire = (savedEndpoint: string) => async (_hostId: string, _jobId: string, connectHost?: Host) => {
const endpoint = connectHost?.hostname ?? savedEndpoint;
acquired.push(endpoint);
return { sftpId: endpoint, poolKey: endpoint, release: () => undefined, discard: () => undefined };
};
const acquireSession = acquire("saved-endpoint");
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: `reconnect-host-${direction}`,
getActivePane, getPaneByConnectionId, getTabByConnectionId, updateTab,
refresh: async () => undefined, clearCacheForConnection: () => undefined,
handleSessionError: () => undefined, sftpSessionsRef,
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => [],
acquireTransferSession: acquireSession,
resolveConnectedHost: () => incomingHost,
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer([{ name: "aops_dns_view_demo.sh", isDirectory: false }],
direction === "download" ? "left" : "right", direction === "download" ? "right" : "left");
});
assert.equal(acquired.length, 2, "both the work lease and file lease must be acquired");
assert.ok(
acquired.every((endpoint) => endpoint === "saved-endpoint"),
`the incoming endpoint must not capture the transfer: ${JSON.stringify(acquired)}`,
);
assert.equal(started.length, 1);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
}
for (const liveStatAvailable of [true, false]) {
test(
liveStatAvailable
+131 -8
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction
import {
FileConflict,
FileConflictAction,
Host,
SftpFilenameEncoding,
TransferDirection,
TransferStatus,
@@ -25,7 +26,10 @@ import { logger } from "../../../lib/logger";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { SftpPane } from "./types";
import { isMissingStatError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import {
useSftpDirectoryTransferOps,
type TransferConnectHosts,
} from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
@@ -78,6 +82,78 @@ const STAT_SFTP_TIMEOUT_MS = 10_000;
export const PREFLIGHT_STAT_CONCURRENCY = 8;
export const PREFLIGHT_STAT_MAX_FILES = 128;
// A "local" or missing resolved host means the dedicated pool should fall
// back to the saved vault host; only a concrete Host pins the endpoint.
const toConnectHost = (host: Host | "local" | null | undefined): Host | undefined =>
host && host !== "local" ? host : undefined;
// Stable identity of a pinned proxy transport. Two endpoints sharing
// id/hostname/port/username can still dial through different proxies after an
// edit; remote DNS behind each proxy may resolve the same hostname to a
// different server, so a same-host `cp` pinned to the source route could write
// on the wrong one.
const proxyIdentity = (p: Host["proxyConfig"]): string =>
p
? JSON.stringify([p.type, p.host, p.port, p.command ?? "", p.identityId ?? "", p.username ?? "", p.password ?? ""])
: "";
// Stable identity of a pinned jump chain. buildCacheKey omits the chain, so
// two panes on the same saved host can share a cache key while pinning
// different bastion routes (e.g. one connected before a jump-host edit, one
// after) — those routes may reach different servers. Compare identities, not
// just cache keys, before allowing any same-host optimization. proxyConfig
// must be part of the identity: buildSftpHostCredentials forwards it as the
// hop's proxy transport, so two hops sharing id/hostname/port/username can
// still dial through different proxies after an edit.
const chainIdentity = (chain: Host[] | undefined): string =>
(chain ?? []).map(h => `${h.id}@${h.hostname}:${h.port ?? ""}:${h.username}:${proxyIdentity(h.proxyConfig)}`).join("→");
// Only pin a concrete endpoint while the pane's live connection still belongs
// to it. connect() records the incoming tab host before it awaits closure of
// the old session and replaces the pane's connection, so during that window a
// transfer must not capture endpoint B while its pane snapshot, task paths,
// and plan still belong to endpoint A; the pool then falls back to the saved
// vault host for the pane's own hostId (mirrors resolveSftpReconnectHost's
// tab-host-vs-connection guard).
const toConnectHostForPane = (
pane: SftpPane,
host: Host | "local" | null | undefined,
): Host | undefined => {
const connection = pane.connection;
if (!connection || connection.isLocal) return undefined;
return host && host !== "local" && host.id === connection.hostId ? host : undefined;
};
// Snapshot the panes' connect-time endpoints. Called before any preflight
// await: tab ids survive reconnect, so resolving this map later can return a
// different endpoint than the one the pane snapshot, task paths, and transfer
// plan belong to.
const captureConnectHosts = (
resolveConnectedHost: UseSftpTransfersParams["resolveConnectedHost"],
resolveConnectedChain: UseSftpTransfersParams["resolveConnectedChain"],
...panes: SftpPane[]
): TransferConnectHosts => {
const captureEndpoint = (
pane: SftpPane | undefined,
): { host?: Host; chain?: Host[] } => {
if (!pane) return {};
const host = toConnectHostForPane(pane, resolveConnectedHost?.(pane.id));
if (!host) return {};
// Pin only the chain captured with this tab's connection; a later edit of
// its bastion hosts must not redirect retained transfers (private
// hostnames can resolve differently behind each bastion).
return { host, chain: resolveConnectedChain?.(pane.id) ?? undefined };
};
const source = captureEndpoint(panes[0]);
const target = captureEndpoint(panes[1]);
return {
source: source.host,
sourceChain: source.chain,
target: target.host,
targetChain: target.chain,
};
};
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
if (latched) ref.current.add(taskId);
@@ -187,6 +263,8 @@ export const useSftpTransfers = ({
listRemoteFiles,
handleSessionError,
acquireTransferSession,
resolveConnectedHost,
resolveConnectedChain,
}: UseSftpTransfersParams): UseSftpTransfersResult => {
const { t } = useI18n();
const tRef = useRef(t);
@@ -476,6 +554,8 @@ export const useSftpTransfers = ({
sourcePane: SftpPane,
targetPane: SftpPane,
targetSide: "left" | "right",
/** Connect-time endpoint snapshot captured before any preflight await. */
connectHostsOverride?: TransferConnectHosts,
): Promise<TransferStatus> => {
if (cancelledTasksRef.current.has(task.id)) {
return "cancelled";
@@ -534,7 +614,7 @@ export const useSftpTransfers = ({
let walkStatus: TransferStatus = "transferring";
await transferRuntime.runWalk(task.id, async () => {
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask);
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask, connectHostsOverride);
});
return walkStatus;
});
@@ -546,6 +626,8 @@ export const useSftpTransfers = ({
targetPane: SftpPane,
targetSide: "left" | "right",
updateTask: (updates: Partial<TransferTask>) => void,
/** Connect-time endpoint snapshot. Undefined re-resolves the live map. */
connectHostsOverride?: TransferConnectHosts,
): Promise<TransferStatus> => {
// Initialize encoding early to avoid temporal dead zone issues
@@ -597,13 +679,29 @@ export const useSftpTransfers = ({
&& sourceCacheKey === targetCacheKey
);
// Keep the pane's connect-time endpoint even when its saved host is edited.
// startTransfer snapshots both hosts before its preflight await so a
// reconnect (same tab id, same host id, different endpoint) pending when
// processing starts cannot redirect the transfer away from the endpoint
// its panes, paths, and plan were captured against.
const resolvedConnectHosts = connectHostsOverride
?? captureConnectHosts(resolveConnectedHost, resolveConnectedChain, sourcePane, targetPane);
const connectHosts: TransferConnectHosts = {
source: toConnectHost(resolvedConnectHosts.source),
sourceChain: resolvedConnectHosts.sourceChain,
target: toConnectHost(resolvedConnectHosts.target),
targetChain: resolvedConnectHosts.targetChain,
};
try {
// Prefer dedicated work leases for remote ends so folder walks/listings
// survive browse park and tab hide (pool holders, not panel map ids).
if (!sourcePane.connection?.isLocal) {
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
if (acquireTransferSession && sourceHostId) {
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
sourceWorkLease = await acquireTransferSession(
sourceHostId, `${task.id}:work-source`, connectHosts.source, connectHosts.sourceChain,
);
sourceSftpId = sourceWorkLease.sftpId;
}
if (!sourceSftpId) {
@@ -616,7 +714,9 @@ export const useSftpTransfers = ({
if (!targetPane.connection?.isLocal) {
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
if (acquireTransferSession && targetHostId) {
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
targetWorkLease = await acquireTransferSession(
targetHostId, `${task.id}:work-target`, connectHosts.target, connectHosts.targetChain,
);
targetSftpId = targetWorkLease.sftpId;
}
if (!targetSftpId) {
@@ -625,7 +725,20 @@ export const useSftpTransfers = ({
}
}
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
// The cache-key equality above omits the jump chain. When the two pinned
// chains differ (a bastion edit between the panes' connect times), the
// "same" saved host may resolve to different servers behind each bastion;
// a same-host `cp` would then write on the source-side server. Treat the
// transfer as cross-host unless the pinned routes match (both undefined =
// both direct, which is equal).
const sameHostPinnedChains = chainIdentity(connectHosts.sourceChain) === chainIdentity(connectHosts.targetChain);
// buildCacheKey also omits the endpoint's own top-level proxyConfig, and a
// proxy edit between the panes' connect times changes the transport (and
// possibly the resolved server) without moving the cache key. Compare the
// pinned top-level proxy identities too; undefined on both sides means
// both dial directly, which is equal.
const sameHostPinnedProxies = proxyIdentity(connectHosts.source?.proxyConfig) === proxyIdentity(connectHosts.target?.proxyConfig);
const sameHost = sameHostEndpoints && sameHostPinnedChains && sameHostPinnedProxies && !!sourceSftpId && !!targetSftpId;
// Returns the discovered stat so callers that need the size (e.g. the
// overwrite conflict dialog) can await this instead of reading stale
@@ -896,7 +1009,7 @@ export const useSftpTransfers = ({
: t,
),
);
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
return processTransfer(updatedTask, sourcePane, targetPane, targetSide, connectHostsOverride);
}
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
@@ -978,6 +1091,7 @@ export const useSftpTransfers = ({
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
0, false, undefined, undefined, connectHosts,
);
if (dirErrors > 0) {
@@ -1025,6 +1139,7 @@ export const useSftpTransfers = ({
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
connectHosts,
);
}
@@ -1166,6 +1281,12 @@ export const useSftpTransfers = ({
if (!sourcePane?.connection || !targetPane?.connection) return [];
// Capture the connect-time endpoints before the preflight stat await
// below: reconnecting either pane to a different endpoint under the same
// host id while stats are pending must not redirect the transfer — the
// pane snapshot, task paths, and plan all belong to this endpoint.
const connectHosts = captureConnectHosts(resolveConnectedHost, resolveConnectedChain, sourcePane, targetPane);
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
@@ -1393,7 +1514,7 @@ export const useSftpTransfers = ({
}
const results = await Promise.all(runnableTasks.map(async (task): Promise<TransferResult> => {
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
const status = await processTransfer(task, sourcePane, targetPane, targetSide, connectHosts);
return {
id: task.id,
fileName: task.fileName,
@@ -1404,8 +1525,10 @@ export const useSftpTransfers = ({
return [...skippedResults, ...results];
},
// Saved host edits replace acquisition; new transfers must not retain the old endpoint.
// Already-running attempts keep the leases they acquired.
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab],
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab, acquireTransferSession, resolveConnectedHost],
);
const cancelTransfer = useCallback(
@@ -1,5 +1,5 @@
import type { MutableRefObject } from "react";
import type { FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import type { Host, FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import type { SftpPane } from "./types";
import type { AcquireTransferSessionFn } from "./transferDirectoryOps";
@@ -21,6 +21,14 @@ export interface UseSftpTransfersParams {
handleSessionError: (side: "left" | "right", error: Error) => void;
/** FileZilla-style dedicated transfer sessions (1–2 per host). */
acquireTransferSession?: AcquireTransferSessionFn;
resolveConnectedHost?: (tabId: string) => Host | "local" | null;
/**
* Jump-host chain snapshots captured when the tab connected. Lets retained
* transfers keep dialing the connect-time bastion chain after a chain host
* is edited (private hostnames can resolve differently per bastion);
* undefined = chain must be re-resolved from the live vault.
*/
resolveConnectedChain?: (tabId: string) => Host[] | null;
}
export interface UseSftpTransfersResult {
+21 -6
View File
@@ -191,6 +191,10 @@ export const useSftpState = (
// Full Host used when each tab connected (includes session-time overrides).
// Tab ids are stable across reconnect; connection ids are not.
const connectedHostByTabIdRef = useRef<Map<string, Host | "local">>(new Map());
// Resolved jump-host chain captured at connect time, keyed by tab id.
// Retained transfers dial through this pinned chain even when one of the
// chain's bastion hosts is edited later.
const connectedHostChainByTabIdRef = useRef<Map<string, Host[]>>(new Map());
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
const connectRequestByTabIdRef = useRef<Map<string, symbol>>(new Map());
@@ -227,6 +231,7 @@ export const useSftpState = (
connectedHostByTabIdRef.current.set(resolvedTabId, host);
} else {
connectedHostByTabIdRef.current.delete(resolvedTabId);
connectedHostChainByTabIdRef.current.delete(resolvedTabId);
}
}, [leftTabsRef, rightTabsRef]);
@@ -240,6 +245,7 @@ export const useSftpState = (
connectRequests: connectRequestByTabIdRef.current,
connectInFlight: connectInFlightRef.current,
connectedHosts: connectedHostByTabIdRef.current,
connectedHostChains: connectedHostChainByTabIdRef.current,
closeTab: (liveSide) => closeTab(liveSide, tabId),
releaseConnection: async () => {
if (!pane?.connection) return;
@@ -318,7 +324,7 @@ export const useSftpState = (
const resolveBrowseSourceSessionId = options?.resolveBrowseSourceSessionId;
const openPoolSftpSession = useCallback(
async (host: Host) => {
async (host: Host, jumpHosts?: Host[]) => {
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
// not repeated. Transport leases keep the shared conn alive after the
// terminal tab closes until the transfer SFTP lease is returned.
@@ -337,7 +343,7 @@ export const useSftpState = (
knownHosts: transferKnownHosts,
terminalSettings: transferTerminalSettings,
},
{ dedicated: false, sourceSessionId },
{ dedicated: false, sourceSessionId, jumpHosts },
);
} catch (err) {
logger.debug(
@@ -361,14 +367,14 @@ export const useSftpState = (
knownHosts: transferKnownHosts,
terminalSettings: transferTerminalSettings,
},
{ dedicated: false },
{ dedicated: false, jumpHosts },
);
},
[hosts, identities, keys, resolveTransferSourceSessionId, transferKnownHosts, transferTerminalSettings],
);
const acquireTransferSession = useCallback(
async (hostId: string, transferId: string, connectHost?: Host) => {
async (hostId: string, transferId: string, connectHost?: Host, jumpHosts?: Host[]) => {
// Prefer the connect-time Host (terminal session overrides) so pooled
// uploads open the same endpoint as the browse tab, not the vault entry.
const host = connectHost && connectHost.id === hostId
@@ -382,7 +388,11 @@ export const useSftpState = (
`Transfer connect host id mismatch: expected ${hostId}, got ${connectHost.id}`,
);
}
const poolKey = await transferPoolKeyCache.get(host, () => ({
// Chain-pinned acquisitions key the pool-key cache by the chain snapshot
// (stable array captured at connect) so they cannot collide with the
// vault-resolved entry for the same host object in the WeakMap.
const cacheKey = jumpHosts?.length ? jumpHosts : host;
const poolKey = await transferPoolKeyCache.get(cacheKey, () => ({
hostId: host.id,
hostname: host.hostname,
port: host.port,
@@ -396,9 +406,10 @@ export const useSftpState = (
identities,
knownHosts: transferKnownHosts,
terminalSettings: transferTerminalSettings,
jumpHostsOverride: jumpHosts,
}),
}));
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host));
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host, jumpHosts));
},
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
);
@@ -443,6 +454,7 @@ export const useSftpState = (
lastConnectedHostRef,
connectionCacheKeyMapRef,
connectedHostByTabIdRef,
connectedHostChainByTabIdRef,
connectInFlightRef,
connectRequestByTabIdRef,
makeCacheKey,
@@ -557,6 +569,8 @@ export const useSftpState = (
listRemoteFiles,
handleSessionError,
acquireTransferSession,
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
resolveConnectedChain: (tabId) => connectedHostChainByTabIdRef.current.get(tabId) ?? null,
});
const {
@@ -621,6 +635,7 @@ export const useSftpState = (
});
},
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
resolveConnectedChain: (tabId) => connectedHostChainByTabIdRef.current.get(tabId) ?? null,
acquireTransferSession,
clearDirCacheEntry,
useCompressedUpload: options?.useCompressedUpload,