mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3aadd07d4 | ||
|
|
cc1fd85ac5 | ||
|
|
610e62a8da | ||
|
|
2fec54b600 | ||
|
|
e4d262021d | ||
|
|
3e6f056a1c | ||
|
|
a547d0408f | ||
|
|
47e402c4e3 | ||
|
|
94afcbb3f9 | ||
|
|
56b7a2ae19 | ||
|
|
a6102a53ed | ||
|
|
4ddde35ccc | ||
|
|
136099f53a | ||
|
|
3d0c8d1845 | ||
|
|
52137618bc | ||
|
|
01cd7f9519 | ||
|
|
b12cf0a174 | ||
|
|
ea5352d648 | ||
|
|
4697dc53e1 | ||
|
|
1c996b3cce | ||
|
|
fe692382c6 | ||
|
|
d41d39dd38 | ||
|
|
50fc6e60cd | ||
|
|
21f906152f | ||
|
|
2521b5eaae | ||
|
|
49c22ff3a7 | ||
|
|
388c374a44 | ||
|
|
0f6e0c14fd | ||
|
|
8c6d15de90 | ||
|
|
e18eaf3b58 | ||
|
|
ba61443b96 | ||
|
|
6c1944d1c0 | ||
|
|
cff4aa1492 | ||
|
|
da60255993 | ||
|
|
3be24c4fa7 | ||
|
|
519e07e38b | ||
|
|
778dddb7d2 | ||
|
|
422d9d0a7b | ||
|
|
4ec328a2c3 | ||
|
|
963d1c1a64 | ||
|
|
efb664f4a8 | ||
|
|
748d838824 | ||
|
|
210711ffe9 | ||
|
|
27a7f0f66e | ||
|
|
bc7fdddc3c | ||
|
|
f459426d95 | ||
|
|
375ec93a8b | ||
|
|
cdeff7bc24 | ||
|
|
d38177d7dd | ||
|
|
53e38a3f4c | ||
|
|
3992ba7a06 | ||
|
|
8a97365448 | ||
|
|
c048f8c4b5 | ||
|
|
0eefc20be5 | ||
|
|
23a403f078 | ||
|
|
30f94c5f31 | ||
|
|
ca2c354638 |
No files matched your search
@@ -251,6 +251,21 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Test Windows repeat-download file identity
|
||||
if: matrix.name == 'windows'
|
||||
shell: pwsh
|
||||
run: node --test --test-name-pattern 'Windows local stats distinguish a replaced file' electron/bridges/localFsBridge.test.cjs
|
||||
|
||||
- name: Test Windows repeat downloads over SFTP
|
||||
if: matrix.name == 'windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
NETCATTY_SFTP_LIVE: '1'
|
||||
SFTP_LIVE_QUICK: '1'
|
||||
SFTP_LIVE_MIB: '2'
|
||||
SFTP_LIVE_FILES: '1'
|
||||
run: node scripts/sftp-transfer-resume.live.test.cjs
|
||||
|
||||
- name: Test macOS Option column selection
|
||||
if: matrix.name == 'macos'
|
||||
run: npm run test:xterm-macos-selection
|
||||
|
||||
@@ -666,6 +666,9 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.skipUnchanged.enable': 'Skip unchanged files',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Skip files whose size and modification time already match the destination (rsync-style). Disabled when replacing a folder into a staging path.',
|
||||
|
||||
'settings.sftp.quickDownload.enable': 'Replace repeat downloads',
|
||||
'settings.sftp.quickDownload.enableDesc': 'After you choose where to save a remote file, downloading that same file again in this SFTP view replaces the exact saved copy without another prompt. First, other, folder, and batch downloads still ask where to save.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
|
||||
@@ -665,6 +665,9 @@ export const esVaultMessages: Messages = {
|
||||
'settings.sftp.skipUnchanged.enable': 'Omitir archivos sin cambios',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Omite los archivos cuyo tamaño y hora de modificación ya coinciden con el destino (estilo rsync). Deshabilitado al reemplazar una carpeta en una ruta de staging.',
|
||||
|
||||
'settings.sftp.quickDownload.enable': 'Reemplazar descargas repetidas',
|
||||
'settings.sftp.quickDownload.enableDesc': 'Tras elegir dónde guardar un archivo remoto, descargar ese mismo archivo de nuevo en esta vista SFTP reemplaza la copia elegida sin otra confirmación. La primera descarga, otros archivos, carpetas y lotes siguen pidiendo un destino.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Buscar hosts o pestañas',
|
||||
'qs.jumpTo': 'Ir a',
|
||||
|
||||
@@ -703,6 +703,9 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.skipUnchanged.enable': 'Пропускать неизменённые файлы',
|
||||
'settings.sftp.skipUnchanged.enableDesc': 'Пропускать файлы, у которых размер и время изменения уже совпадают с назначением (в стиле rsync). Отключается при замене папки во временный путь.',
|
||||
|
||||
'settings.sftp.quickDownload.enable': 'Заменять повторные загрузки',
|
||||
'settings.sftp.quickDownload.enableDesc': 'После выбора места для удалённого файла его повторная загрузка в этом окне SFTP заменит выбранную копию без нового подтверждения. Первая загрузка, другие файлы, папки и группы файлов по-прежнему требуют выбора места.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
|
||||
@@ -219,6 +219,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.skipUnchanged.enable': '跳过未改文件',
|
||||
'settings.sftp.skipUnchanged.enableDesc': '当目标文件的大小与修改时间已与源一致时跳过传输(类似 rsync)。替换到暂存路径时自动禁用。',
|
||||
|
||||
'settings.sftp.quickDownload.enable': '重复下载时直接替换',
|
||||
'settings.sftp.quickDownload.enableDesc': '选定远端文件的保存位置后,在当前 SFTP 视图再次下载同一文件,会直接替换上次选定的完整路径,不再确认。首次、其他文件、文件夹和批量下载仍需选择位置。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
|
||||
@@ -219,6 +219,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sftp.skipUnchanged.enable': '跳過未改檔案',
|
||||
'settings.sftp.skipUnchanged.enableDesc': '當目標檔案的大小與修改時間已與來源一致時跳過傳輸(類似 rsync)。替換到暫存路徑時自動停用。',
|
||||
|
||||
'settings.sftp.quickDownload.enable': '重複下載時直接取代',
|
||||
'settings.sftp.quickDownload.enableDesc': '選定遠端檔案的儲存位置後,在目前 SFTP 檢視再次下載同一檔案,會直接取代上次選定的完整路徑,不再確認。首次、其他檔案、資料夾及批次下載仍需選擇位置。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '終端主題',
|
||||
'settings.terminal.themeModal.title': '選擇主題',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_QUICK_DOWNLOAD,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
@@ -102,6 +103,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpQuickDownload: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
@@ -151,6 +153,7 @@ export function useSettingsIpcSync({
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpQuickDownload,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
@@ -302,6 +305,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && typeof value === 'boolean') {
|
||||
setSftpFollowTerminalCwd((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_QUICK_DOWNLOAD && typeof value === 'boolean') {
|
||||
setSftpQuickDownload((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
|
||||
if (value === 'list' || value === 'tree') {
|
||||
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
|
||||
@@ -378,6 +384,7 @@ export function useSettingsIpcSync({
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpQuickDownload,
|
||||
setSftpDefaultViewMode,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_QUICK_DOWNLOAD,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
@@ -89,6 +90,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpSkipUnchanged: boolean;
|
||||
sftpQuickDownload: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
@@ -142,6 +144,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpSkipUnchanged: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpQuickDownload: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
@@ -188,7 +191,7 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpQuickDownload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
@@ -197,7 +200,7 @@ export function useSettingsStorageSync({
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpQuickDownload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
@@ -212,7 +215,7 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpQuickDownload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
@@ -222,7 +225,7 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpQuickDownload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
@@ -408,6 +411,13 @@ export function useSettingsStorageSync({
|
||||
setSftpSkipUnchanged(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP quick download setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_QUICK_DOWNLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpQuickDownload) {
|
||||
setSftpQuickDownload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -596,6 +606,7 @@ export function useSettingsStorageSync({
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpAutoSync,
|
||||
setSftpQuickDownload,
|
||||
setSftpDefaultViewMode,
|
||||
setSftpDoubleClickBehavior,
|
||||
setSftpShowHiddenFiles,
|
||||
|
||||
@@ -39,6 +39,22 @@ const host = (id: string, label: string, hostname = label): Host => ({
|
||||
protocol: "ssh",
|
||||
} as Host);
|
||||
|
||||
test("repeat download refuses hard reconnect when its original route cannot be checked", async () => {
|
||||
const task: TransferTask = {
|
||||
id: "guarded-repeat", fileName: "file.bin",
|
||||
sourcePath: "/remote/file.bin", targetPath: "/local/file.bin",
|
||||
sourceConnectionId: "old-sftp", sourceHostId: "h1", sourceHostLabel: "box",
|
||||
targetConnectionId: "local", direction: "download", status: "interrupted",
|
||||
totalBytes: 100, transferredBytes: 20, speed: 0, startTime: 1,
|
||||
isDirectory: false, requireOriginalSourceForResume: true,
|
||||
};
|
||||
const result = await resumeTransferWithDedicatedSession(task, {
|
||||
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
|
||||
});
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error ?? "", /source cannot be verified/);
|
||||
});
|
||||
|
||||
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
|
||||
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
|
||||
for (const ownerChange of retainedStatus === "interrupted" || retainedStatus === undefined ? ["same", "active", "completed"] : ["same"]) {
|
||||
@@ -792,6 +808,16 @@ test("stat-less SCP download resume keeps saved progress instead of restarting",
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
reconnectRequired: true,
|
||||
expectedLocalTarget: {
|
||||
parentRealPath: "/local",
|
||||
parentIdentity: "1:2",
|
||||
parentBirthtimeNs: "100",
|
||||
targetIdentity: "1:3",
|
||||
targetBirthtimeNs: "200",
|
||||
targetCtimeNs: "201",
|
||||
targetMtimeNs: "202",
|
||||
targetSha256: "a".repeat(64),
|
||||
},
|
||||
}, {
|
||||
hosts: [host("h1", "box", "1.2.3.4")],
|
||||
keys: [],
|
||||
@@ -803,6 +829,10 @@ test("stat-less SCP download resume keeps saved progress instead of restarting",
|
||||
// the download resumes from byte 20 instead of silently restarting.
|
||||
assert.equal(startOptions?.checkpointBytes, 20);
|
||||
assert.equal(startOptions?.totalBytes, 100);
|
||||
assert.deepEqual(startOptions?.expectedLocalTarget, {
|
||||
parentRealPath: "/local", parentIdentity: "1:2", parentBirthtimeNs: "100",
|
||||
targetIdentity: "1:3", targetBirthtimeNs: "200", targetCtimeNs: "201", targetMtimeNs: "202", targetSha256: "a".repeat(64),
|
||||
});
|
||||
} finally {
|
||||
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
|
||||
@@ -428,6 +428,12 @@ export async function resumeTransferWithDedicatedSession(
|
||||
onProgress?: (progress: DedicatedResumeProgress) => void,
|
||||
options?: DedicatedResumeOptions,
|
||||
): Promise<DedicatedResumeResult> {
|
||||
// The original source may have used a session-only proxy or jump route.
|
||||
// Hard reconnect has only the vault host, so it cannot establish that the
|
||||
// replacement bytes come from the source whose target was remembered.
|
||||
if (task.requireOriginalSourceForResume) {
|
||||
return { success: false, error: "Repeat download source cannot be verified after reconnect; start a new download" };
|
||||
}
|
||||
if (task.isDirectory) {
|
||||
return resumeDirectoryWithDedicatedSession(task, deps, onProgress, options);
|
||||
}
|
||||
@@ -710,6 +716,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
downloadCheckpointBytes: task.downloadCheckpointBytes,
|
||||
uploadCheckpointBytes: task.uploadCheckpointBytes,
|
||||
sourceFingerprint: task.sourceFingerprint,
|
||||
expectedLocalTarget: task.expectedLocalTarget,
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,89 @@ import { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { transferRuntime } from "./transferRuntime";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { releaseTransferPauseTree } from "./transferPauseLatch";
|
||||
import type { Host } from "../../../domain/models";
|
||||
|
||||
test("direct download opens both pooled reads through the tab's connected host", async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const connectedHost = { id: "host", hostname: "same.example", proxyConfig: { type: "socks5", host: "proxy-b", port: 1080 } } as Host;
|
||||
const seenHosts: Array<Host | undefined> = [];
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
startStreamTransfer: async (options: { transferId: string }) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
} };
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "direct-connected-host-owner", getActivePane: () => null,
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => ({ side: "left", tabId: "tab-1", pane: {} as never }),
|
||||
resolveConnectedHost: () => connectedHost,
|
||||
getTransferRouteKeyForHost: async () => "route-new",
|
||||
acquireTransferSession: async (_hostId, _transferId, host) => {
|
||||
seenHosts.push(host);
|
||||
return { poolKey: "connected-host", sftpId: `pooled-${seenHosts.length}`, release: () => undefined, discard: () => undefined };
|
||||
},
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
assert.equal(await ops!.downloadToLocal({
|
||||
fileName: "file.bin", sourcePath: "/remote/file.bin", targetPath: "/local/file.bin",
|
||||
sftpId: "browse", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Host",
|
||||
isDirectory: false, totalBytes: 1,
|
||||
}), "completed");
|
||||
});
|
||||
assert.equal(seenHosts.length, 2);
|
||||
assert.ok(seenHosts.every((host) => host === connectedHost));
|
||||
await act(async () => {
|
||||
assert.equal(await ops!.downloadToLocal({
|
||||
fileName: "other.bin", sourcePath: "/remote/other.bin", targetPath: "/local/other.bin",
|
||||
expectedLocalTarget: {
|
||||
parentRealPath: "/local", parentIdentity: "1:2", parentBirthtimeNs: "100",
|
||||
targetIdentity: "1:3", targetBirthtimeNs: "200", targetCtimeNs: "201", targetMtimeNs: "202",
|
||||
targetSha256: "a".repeat(64),
|
||||
},
|
||||
expectedSourceEndpointKey: "route-old",
|
||||
sftpId: "browse", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Host",
|
||||
isDirectory: false, totalBytes: 1,
|
||||
}), "failed");
|
||||
});
|
||||
assert.equal(seenHosts.length, 2, "route mismatch must fail before opening another transfer connection");
|
||||
await act(async () => {
|
||||
assert.equal(await ops!.downloadToLocal({
|
||||
fileName: "first.bin", sourcePath: "/remote/first.bin", targetPath: "/local/first.bin",
|
||||
expectedSourceEndpointKey: "route-old",
|
||||
sftpId: "browse", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Host",
|
||||
isDirectory: false, totalBytes: 1,
|
||||
}), "failed");
|
||||
});
|
||||
assert.equal(seenHosts.length, 2, "a first Save As route mismatch must not start a transfer");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const task of sftpTransferCenterStore.getOwnerTasks("direct-connected-host-owner")) {
|
||||
sftpTransferCenterStore.dismiss(task.id);
|
||||
}
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
|
||||
for (const closePanel of [false, true]) {
|
||||
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
|
||||
|
||||
@@ -12,6 +12,16 @@ test("download tasks retain the remote host needed for reconnecting after the or
|
||||
fileName: "archive.bin",
|
||||
sourcePath: "/remote/archive.bin",
|
||||
targetPath: "/local/archive.bin",
|
||||
expectedLocalTarget: {
|
||||
parentRealPath: "/local",
|
||||
parentIdentity: "1:2",
|
||||
parentBirthtimeNs: "100",
|
||||
targetIdentity: "1:3",
|
||||
targetBirthtimeNs: "200",
|
||||
targetCtimeNs: "201",
|
||||
targetMtimeNs: "202",
|
||||
targetSha256: "a".repeat(64),
|
||||
},
|
||||
sourceConnectionId: "connection-1",
|
||||
sourceHostId: "host-1",
|
||||
sourceHostLabel: "Production",
|
||||
@@ -24,9 +34,50 @@ test("download tasks retain the remote host needed for reconnecting after the or
|
||||
assert.equal(task.targetConnectionId, "local");
|
||||
assert.equal(task.resumable, true);
|
||||
assert.equal(task.status, "queued");
|
||||
assert.equal(task.expectedLocalTarget?.targetIdentity, "1:3");
|
||||
assert.equal(task.requireOriginalSourceForResume, true);
|
||||
assert.equal(task.phase, undefined);
|
||||
});
|
||||
|
||||
test("route-bound download tasks refuse dedicated resume even without a remembered target", () => {
|
||||
// First Save As through a session-only proxy/jump route validates the route
|
||||
// but has no remembered target yet; hard reconnect could not verify that
|
||||
// the replacement bytes still come from that route's server.
|
||||
const task = createDirectDownloadTransferTask({
|
||||
id: "download-2",
|
||||
fileName: "archive.bin",
|
||||
sourcePath: "/remote/archive.bin",
|
||||
targetPath: "/local/archive.bin",
|
||||
expectedSourceEndpointKey: "route-key-1",
|
||||
sourceConnectionId: "connection-1",
|
||||
sourceHostId: "host-1",
|
||||
sourceHostLabel: "Production",
|
||||
totalBytes: 128,
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
assert.equal(task.expectedLocalTarget, undefined);
|
||||
assert.equal(task.requireOriginalSourceForResume, true);
|
||||
// Persisted so a retry can revalidate the live route against this key.
|
||||
assert.equal(task.expectedSourceEndpointKey, "route-key-1");
|
||||
});
|
||||
|
||||
test("plain download tasks without a remembered target or route stay hard-resumable", () => {
|
||||
const task = createDirectDownloadTransferTask({
|
||||
id: "download-3",
|
||||
fileName: "archive.bin",
|
||||
sourcePath: "/remote/archive.bin",
|
||||
targetPath: "/local/archive.bin",
|
||||
sourceConnectionId: "connection-1",
|
||||
sourceHostId: "host-1",
|
||||
sourceHostLabel: "Production",
|
||||
totalBytes: 128,
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
assert.equal(task.requireOriginalSourceForResume, false);
|
||||
});
|
||||
|
||||
test("directory download final status stays cancelled when parent was cancelled mid-tree", () => {
|
||||
// transferDirectory counts cancelled children as errors; parent cancel must win.
|
||||
const resolved = resolveDirectDirectoryDownloadFinalStatus({
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { LocalDownloadTargetExpectation, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
|
||||
export interface DirectDownloadTransferTaskInput {
|
||||
id: string;
|
||||
fileName: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
expectedLocalTarget?: LocalDownloadTargetExpectation;
|
||||
/** Connect-time route key (proxy/jump) that identified the browsed source. */
|
||||
expectedSourceEndpointKey?: string;
|
||||
sourceConnectionId: string;
|
||||
sourceHostId: string;
|
||||
sourceHostLabel: string;
|
||||
@@ -21,6 +24,17 @@ export function createDirectDownloadTransferTask(
|
||||
originalFileName: input.fileName,
|
||||
sourcePath: input.sourcePath,
|
||||
targetPath: input.targetPath,
|
||||
expectedLocalTarget: input.expectedLocalTarget,
|
||||
// A remembered target is safe only when the exact connect-time route that
|
||||
// identified the source can be re-established, so a task that was
|
||||
// validated against a session-only proxy/jump route must never be
|
||||
// hard-reconnected from the vault host alone: the replacement bytes could
|
||||
// come from a different server's identical path (Codex P1 on PR #3516).
|
||||
requireOriginalSourceForResume:
|
||||
!!input.expectedLocalTarget || !!input.expectedSourceEndpointKey,
|
||||
// Persisted so a retry can revalidate the live route against the exact
|
||||
// connect-time route that identified the source (Codex P1 on PR #3516).
|
||||
expectedSourceEndpointKey: input.expectedSourceEndpointKey,
|
||||
sourceConnectionId: input.sourceConnectionId,
|
||||
targetConnectionId: "local",
|
||||
sourceHostId: input.sourceHostId,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_SFTP_QUICK_DOWNLOAD,
|
||||
resolveSftpQuickDownloadEnabled,
|
||||
} from "./quickDownloadPreference";
|
||||
|
||||
test("defaults quick download to disabled", () => {
|
||||
assert.equal(resolveSftpQuickDownloadEnabled(null), DEFAULT_SFTP_QUICK_DOWNLOAD);
|
||||
assert.equal(resolveSftpQuickDownloadEnabled(undefined), DEFAULT_SFTP_QUICK_DOWNLOAD);
|
||||
assert.equal(DEFAULT_SFTP_QUICK_DOWNLOAD, false);
|
||||
});
|
||||
|
||||
test("keeps an explicit quick download preference", () => {
|
||||
assert.equal(resolveSftpQuickDownloadEnabled(true), true);
|
||||
assert.equal(resolveSftpQuickDownloadEnabled(false), false);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
STORAGE_KEY_SFTP_QUICK_DOWNLOAD,
|
||||
} from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/** Off by default: keep the Save As dialog unless the user opts in. */
|
||||
export const DEFAULT_SFTP_QUICK_DOWNLOAD = false;
|
||||
|
||||
export function resolveSftpQuickDownloadEnabled(
|
||||
stored: boolean | null | undefined,
|
||||
): boolean {
|
||||
return stored == null ? DEFAULT_SFTP_QUICK_DOWNLOAD : stored;
|
||||
}
|
||||
|
||||
export function readSftpQuickDownloadEnabled(): boolean {
|
||||
return resolveSftpQuickDownloadEnabled(
|
||||
localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_QUICK_DOWNLOAD),
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,8 @@ export interface SamePanePasteFile {
|
||||
|
||||
/** Filesystem identity of a directory (stat dev/ino). */
|
||||
export interface SamePanePasteIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
dev: string | number;
|
||||
ino: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,14 +158,14 @@ export const resolveSamePanePasteAction = async (params: {
|
||||
try {
|
||||
const identity = await statIdentity(path);
|
||||
if (!identity || identity.dev === undefined || identity.ino === undefined) return null;
|
||||
if (identity.dev === 0 && identity.ino === 0) return null;
|
||||
if (String(identity.dev) === "0" && String(identity.ino) === "0") return null;
|
||||
return identity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const sameIdentity = (a: SamePanePasteIdentity, b: SamePanePasteIdentity): boolean =>
|
||||
a.dev === b.dev && a.ino === b.ino;
|
||||
String(a.dev) === String(b.dev) && String(a.ino) === String(b.ino);
|
||||
|
||||
if (params.operation === "cut") {
|
||||
const [targetIdentity, sourceIdentity] = await Promise.all([
|
||||
|
||||
@@ -115,7 +115,8 @@ export function useSftpTransferConflictOps() {
|
||||
bridge.statLocal(sourceParent), bridge.statLocal(targetParent),
|
||||
]);
|
||||
return sourceStat.dev !== undefined && sourceStat.ino !== undefined
|
||||
&& sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino;
|
||||
&& String(sourceStat.dev) === String(targetStat.dev)
|
||||
&& String(sourceStat.ino) === String(targetStat.ino);
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTransferPoolKey,
|
||||
buildTransferRouteKey,
|
||||
createTransferConnectionPool,
|
||||
createTransferPoolKeyCache,
|
||||
DEFAULT_MAX_IDLE_TRANSFER_CONNECTIONS,
|
||||
@@ -121,6 +122,7 @@ test("buildTransferPoolKey separates every transport security identity", async (
|
||||
variant({ keepaliveInterval: 60 }),
|
||||
variant({ algorithmOverrides: { kex: ["diffie-hellman-group14-sha256"] } }),
|
||||
variant({ jumpHosts: [{ hostname: "jump-b", port: 22, username: "jump" }] }),
|
||||
variant({ proxy: { type: "socks5", host: "proxy-b", port: 1080 } as NetcattySSHOptions["proxy"] }),
|
||||
variant({ verifyHostKeys: false }),
|
||||
variant({ knownHosts: [{
|
||||
id: "known-b",
|
||||
@@ -137,6 +139,123 @@ test("buildTransferPoolKey separates every transport security identity", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTransferRouteKey persists without credential-derived material", async () => {
|
||||
const connectionOptions = {
|
||||
hostname: "target.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
password: "secret-a",
|
||||
privateKey: "PRIVATE KEY A",
|
||||
passphrase: "passphrase-a",
|
||||
certificate: "CERT A",
|
||||
sudoAutofillPassword: "sudo-a",
|
||||
keyId: "key-1",
|
||||
jumpHosts: [{
|
||||
hostname: "jump-a", port: 22, username: "jump",
|
||||
password: "jump-secret-a", privateKey: "JUMP KEY A", keyId: "jump-key-1",
|
||||
}],
|
||||
proxy: {
|
||||
type: "command", host: "proxy-a", port: 1080, username: "pu",
|
||||
password: "pp", command: "proxy-command-secret-a",
|
||||
},
|
||||
} as unknown as NetcattySSHOptions;
|
||||
const base = {
|
||||
hostId: "h1",
|
||||
hostname: "target.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
connectionOptions,
|
||||
};
|
||||
const route = await buildTransferRouteKey(base);
|
||||
assert.match(route, /^host:h1\|ep:target\.example:22:root:ssh:nosudo\|route:[a-f0-9]{64}$/);
|
||||
// Secret rotations must not change the persistable route identity: the
|
||||
// digest must stay useless as an offline verifier for credential guesses.
|
||||
const secretVariants: NetcattySSHOptions[] = [
|
||||
{ ...connectionOptions, password: "secret-b" },
|
||||
{ ...connectionOptions, privateKey: "PRIVATE KEY B", passphrase: "passphrase-b" },
|
||||
{ ...connectionOptions, certificate: "CERT B", sudoAutofillPassword: "sudo-b" },
|
||||
{
|
||||
...connectionOptions,
|
||||
jumpHosts: [{
|
||||
hostname: "jump-a", port: 22, username: "jump",
|
||||
password: "other", keyId: "jump-key-1",
|
||||
}],
|
||||
},
|
||||
{
|
||||
...connectionOptions,
|
||||
proxy: {
|
||||
type: "command", host: "proxy-a", port: 1080, username: "pu",
|
||||
password: "other", command: "proxy-command-secret-a",
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const connectionOptions of secretVariants) {
|
||||
assert.equal(await buildTransferRouteKey({ ...base, connectionOptions }), route);
|
||||
}
|
||||
// Non-secret route structure still changes the identity.
|
||||
const structuralVariants: NetcattySSHOptions[] = [
|
||||
{ ...connectionOptions, keyId: "key-2" },
|
||||
{ ...connectionOptions, jumpHosts: [{ ...connectionOptions.jumpHosts![0], hostname: "jump-b" }] },
|
||||
{ ...connectionOptions, proxy: { ...connectionOptions.proxy!, host: "proxy-b" } },
|
||||
];
|
||||
for (const connectionOptions of structuralVariants) {
|
||||
assert.notEqual(
|
||||
await buildTransferRouteKey({ ...base, connectionOptions }),
|
||||
route,
|
||||
"non-secret route change must change the route identity",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildTransferRouteKey distinguishes command proxies for the same target", async () => {
|
||||
const connectionOptions = (command: string) => ({
|
||||
hostname: "target.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
password: "secret-a",
|
||||
proxy: {
|
||||
type: "command", host: "", port: 0,
|
||||
username: "pu", password: "pp", command,
|
||||
},
|
||||
} as unknown as NetcattySSHOptions);
|
||||
const base = {
|
||||
hostId: "h1",
|
||||
hostname: "target.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
connectionOptions: connectionOptions("ssh -J bastion-a target.example"),
|
||||
};
|
||||
const route = await buildTransferRouteKey(base);
|
||||
assert.match(route, /^host:h1\|ep:target\.example:22:root:ssh:nosudo\|route:[a-f0-9]{64}$/);
|
||||
// The same command keeps a stable identity for the session.
|
||||
assert.equal(await buildTransferRouteKey(base), route);
|
||||
// Command proxies normalize to an empty host and zero port, so switching
|
||||
// between commands that reach different bastions must change the route
|
||||
// identity; otherwise the quick-download guard would accept replacement
|
||||
// bytes read through a different proxy route (Codex P1 on PR #3516).
|
||||
const otherBastion = {
|
||||
...base,
|
||||
connectionOptions: connectionOptions("ssh -J bastion-b target.example"),
|
||||
};
|
||||
assert.notEqual(await buildTransferRouteKey(otherBastion), route);
|
||||
// Proxy credential rotations must not change the identity, and the
|
||||
// persisted digest must not be a pure function of the command: it is
|
||||
// keyed under a session-scoped secret that never reaches storage, so a
|
||||
// leaked route key is useless as an offline credential verifier.
|
||||
const rotatedProxySecret = {
|
||||
...base,
|
||||
connectionOptions: connectionOptions("ssh -J bastion-a target.example"),
|
||||
};
|
||||
(rotatedProxySecret.connectionOptions as { proxy: { password: string } }).proxy.password = "other";
|
||||
assert.equal(await buildTransferRouteKey(rotatedProxySecret), route);
|
||||
const commandLine = "ssh -J bastion-a target.example";
|
||||
const unsaltedDigest = Array.from(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(commandLine))),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
assert.ok(!route.includes(unsaltedDigest), "command digest must be session-keyed");
|
||||
});
|
||||
|
||||
test("pool opens at most maxPerHost channels and multiplexes when busy", async () => {
|
||||
let opens = 0;
|
||||
const closed: string[] = [];
|
||||
|
||||
@@ -533,31 +533,128 @@ export function resetSharedTransferConnectionPoolForTests(): void {
|
||||
sharedPool = null;
|
||||
}
|
||||
|
||||
/** Deterministic serializer shared by the pool key and the route identity. */
|
||||
const stableSerialize = (value: unknown): string => {
|
||||
if (value === undefined) return '"__undefined__"';
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => key !== "sessionId")
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`).join(",")}}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Secret fields never contribute to the persisted route identity
|
||||
* (`buildTransferRouteKey`). Their digests could act as offline verifiers for
|
||||
* credential guesses if the route identity leaks through localStorage-backed
|
||||
* transfer history (Codex P2 on PR #3516). Non-secret identity references
|
||||
* (auth method, keyId, keySource, identity file paths) stay in so a
|
||||
* credential *switch* still changes the route identity.
|
||||
*/
|
||||
const TRANSFER_ROUTE_SECRET_KEYS: ReadonlySet<string> = new Set([
|
||||
"password",
|
||||
"privateKey",
|
||||
"passphrase",
|
||||
"certificate",
|
||||
"sudoAutofillPassword",
|
||||
]);
|
||||
|
||||
/**
|
||||
* `command` cannot simply be dropped: command proxies normalize to an empty
|
||||
* `host` and zero `port`, so without it every command proxy for the same
|
||||
* target produced the same persisted route key, and switching a pane between
|
||||
* commands that reach different bastions or backends still passed the
|
||||
* quick-download route guard — the remembered local file could be replaced
|
||||
* with bytes from a different server (Codex P1 on PR #3516). The
|
||||
* `ProxyCommand` line itself is secret (it routinely embeds credentials), so
|
||||
* an unsalted digest would act as an offline credential verifier once the
|
||||
* route key lands in localStorage-backed transfer history. Digest command
|
||||
* payloads under a key that stays in memory for the current renderer
|
||||
* session instead: routes remain distinguishable within the session, while
|
||||
* the persisted digest is useless offline and intentionally fails closed
|
||||
* (the route guard rejects the transfer) after a restart.
|
||||
*/
|
||||
const routeCommandDigestKey = Array.from(
|
||||
crypto.getRandomValues(new Uint8Array(32)),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
|
||||
const routeCommandDigests = new Map<string, Promise<string>>();
|
||||
|
||||
const routeCommandDigest = (command: string): Promise<string> => {
|
||||
let digest = routeCommandDigests.get(command);
|
||||
if (!digest) {
|
||||
digest = sha256Hex(`${routeCommandDigestKey}:${command}`);
|
||||
routeCommandDigests.set(command, digest);
|
||||
}
|
||||
return digest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deterministic serializer for the persisted route identity: secret fields
|
||||
* are dropped, while command-proxy payloads are replaced by session-keyed
|
||||
* digests so different proxy commands still produce different route keys
|
||||
* (Codex P1 on PR #3516).
|
||||
*/
|
||||
const stableRouteSerialize = async (value: unknown): Promise<string> => {
|
||||
if (value === undefined) return '"__undefined__"';
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
const items = await Promise.all(value.map(stableRouteSerialize));
|
||||
return `[${items.join(",")}]`;
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => key !== "sessionId" && !TRANSFER_ROUTE_SECRET_KEYS.has(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(async ([key, item]) => `${JSON.stringify(key)}:${
|
||||
key === "command" && typeof item === "string"
|
||||
? await routeCommandDigest(item)
|
||||
: await stableRouteSerialize(item)
|
||||
}`),
|
||||
);
|
||||
return `{${entries.join(",")}}`;
|
||||
};
|
||||
|
||||
const transferIdentityBase = (input: TransferPoolKeyInput): string => {
|
||||
const port = input.port || 22;
|
||||
const user = input.username || "root";
|
||||
const protocol = input.protocol || "ssh";
|
||||
const sudo = input.sftpSudo ? "sudo" : "nosudo";
|
||||
const ep = `${input.hostname}:${port}:${user}:${protocol}:${sudo}`;
|
||||
return input.hostId ? `host:${input.hostId}|ep:${ep}` : `ep:${ep}`;
|
||||
};
|
||||
|
||||
const sha256Hex = async (value: string): Promise<string> => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
export async function buildTransferPoolKey(input: TransferPoolKeyInput): Promise<string> {
|
||||
const stableSerialize = (value: unknown): string => {
|
||||
if (value === undefined) return '"__undefined__"';
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => key !== "sessionId")
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`).join(",")}}`;
|
||||
};
|
||||
// Include endpoint identity whenever hostname is known so session-time
|
||||
// hostname/port/username overrides do not share a pool with the vault host.
|
||||
if (input.hostname) {
|
||||
const port = input.port || 22;
|
||||
const user = input.username || "root";
|
||||
const protocol = input.protocol || "ssh";
|
||||
const sudo = input.sftpSudo ? "sudo" : "nosudo";
|
||||
const ep = `${input.hostname}:${port}:${user}:${protocol}:${sudo}`;
|
||||
const base = input.hostId ? `host:${input.hostId}|ep:${ep}` : `ep:${ep}`;
|
||||
const base = transferIdentityBase(input);
|
||||
if (!input.connectionOptions) return base;
|
||||
const encoded = new TextEncoder().encode(stableSerialize(input.connectionOptions));
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
||||
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${base}|identity:${fingerprint}`;
|
||||
return `${base}|identity:${await sha256Hex(stableSerialize(input.connectionOptions))}`;
|
||||
}
|
||||
if (input.hostId) return `host:${input.hostId}`;
|
||||
return "ep:unknown:22:root:ssh:nosudo";
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistable route identity for the download source-route guard. Same
|
||||
* endpoint base as the pool key, but the digest covers only non-secret
|
||||
* endpoint/proxy attributes (plus session-keyed command digests) so it can
|
||||
* survive in localStorage-backed transfer history without exposing a
|
||||
* credential-derived verifier (Codex P2 on PR #3516). Command digests are
|
||||
* session-keyed, so a remembered route from a previous session fails closed
|
||||
* instead of silently reusing a re-resolved proxy route.
|
||||
*/
|
||||
export async function buildTransferRouteKey(input: TransferPoolKeyInput): Promise<string> {
|
||||
if (!input.hostname) return input.hostId ? `host:${input.hostId}` : "route:unknown";
|
||||
const base = transferIdentityBase(input);
|
||||
if (!input.connectionOptions) return base;
|
||||
return `${base}|route:${await sha256Hex(await stableRouteSerialize(input.connectionOptions))}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { reconcileSupersededControls } from "./globalSftpTransferControl";
|
||||
import { runTransferAndWaitForOwner, TransferOwnerChangedError } from "./waitForTransferOwner";
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { Host, LocalPublishedFileIdentity, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import {
|
||||
accountSftpDirectoryEntries,
|
||||
claimSftpDirectoryVisit,
|
||||
@@ -268,6 +268,8 @@ export function useSftpDirectoryTransferOps({
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
onPublishedLocalFile?: (identity: LocalPublishedFileIdentity) => void | Promise<void>,
|
||||
sourceConnectHost?: Host,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -315,7 +317,7 @@ 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, sourceConnectHost);
|
||||
}
|
||||
if (acquireTransferSession && !targetIsLocal && task.targetHostId) {
|
||||
targetLease = await acquireTransferSession(task.targetHostId, task.id);
|
||||
@@ -382,6 +384,8 @@ export function useSftpDirectoryTransferOps({
|
||||
transferId: task.id,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
expectedLocalTarget: task.expectedLocalTarget,
|
||||
capturePublishedContentHash: !!onPublishedLocalFile,
|
||||
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
|
||||
sourceSftpId: effectiveSourceSftpId || undefined,
|
||||
@@ -474,7 +478,10 @@ export function useSftpDirectoryTransferOps({
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
})();
|
||||
let result: { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
let result: {
|
||||
error?: string; cancelled?: boolean; superseded?: boolean;
|
||||
publishedLocalIdentity?: LocalPublishedFileIdentity;
|
||||
} | undefined;
|
||||
try {
|
||||
result = await transferPromise;
|
||||
} finally {
|
||||
@@ -484,6 +491,14 @@ export function useSftpDirectoryTransferOps({
|
||||
if (result?.error || result?.cancelled) {
|
||||
throw new Error(result.error || "Transfer cancelled");
|
||||
}
|
||||
if (result?.publishedLocalIdentity) {
|
||||
// Await registration so the transfer only reports completion
|
||||
// after the quick-download target is usable again; a
|
||||
// fire-and-forget callback let an immediate repeat download
|
||||
// find no remembered target and open Save As (Codex P2 on
|
||||
// PR #3516).
|
||||
await onPublishedLocalFile?.(result.publishedLocalIdentity);
|
||||
}
|
||||
// Soft-drain can complete this file while folder is still latched.
|
||||
// Park before the worker loop claims another index.
|
||||
if (isPauseLatched(rootTaskId, task.id)) {
|
||||
@@ -541,6 +556,7 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
sourceConnectHost?: Host,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -698,6 +714,7 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks,
|
||||
progress,
|
||||
traversal,
|
||||
sourceConnectHost,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -925,6 +942,8 @@ export function useSftpDirectoryTransferOps({
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
undefined,
|
||||
sourceConnectHost,
|
||||
);
|
||||
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import type { LocalDownloadTargetExpectation, LocalPublishedFileIdentity, SftpFilenameEncoding } from "../../../domain/models/sftp";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { getParentPath } from "./utils";
|
||||
|
||||
type RememberedTarget = LocalDownloadTargetExpectation & {
|
||||
targetPath: string;
|
||||
};
|
||||
|
||||
const TARGET_LIMIT = 200;
|
||||
|
||||
const sourceKey = (
|
||||
endpointKey: string | null | undefined,
|
||||
sourcePath: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
): string | null => endpointKey && sourcePath
|
||||
? JSON.stringify([endpointKey, sourcePath, encoding ?? "auto"])
|
||||
: null;
|
||||
|
||||
const filesystemIdentity = (stat: SftpStatResult): string | null =>
|
||||
stat.dev !== undefined && stat.ino !== undefined ? `${stat.dev}:${stat.ino}` : null;
|
||||
|
||||
const validTimestamp = (value: string | undefined): value is string =>
|
||||
typeof value === "string" && /^[1-9]\d*$/.test(value);
|
||||
|
||||
/** Each SFTP view remembers only targets that its user selected successfully. */
|
||||
export function useSftpQuickDownloadTargets() {
|
||||
const targetsRef = useRef(new Map<string, RememberedTarget>());
|
||||
|
||||
const remember = useCallback(async (
|
||||
endpointKey: string | null | undefined,
|
||||
sourcePath: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
targetPath: string,
|
||||
published: LocalPublishedFileIdentity,
|
||||
): Promise<void> => {
|
||||
const key = sourceKey(endpointKey, sourcePath, encoding);
|
||||
if (!key) return;
|
||||
targetsRef.current.delete(key);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!targetPath || !/^[a-f0-9]{64}$/.test(published.sha256)
|
||||
|| !bridge?.statLocal || !bridge.lstatLocal || !bridge.realpathLocal) return;
|
||||
try {
|
||||
const parentPath = getParentPath(targetPath);
|
||||
const [parent, target, parentRealPath] = await Promise.all([
|
||||
bridge.statLocal(parentPath),
|
||||
bridge.lstatLocal(targetPath),
|
||||
bridge.realpathLocal(parentPath),
|
||||
]);
|
||||
if (parent?.type !== "directory" || target?.type !== "file" || !parentRealPath) return;
|
||||
const parentIdentity = filesystemIdentity(parent);
|
||||
const targetIdentity = filesystemIdentity(target);
|
||||
if (!parentIdentity || !targetIdentity
|
||||
|| target.dev !== published.dev || target.ino !== published.ino
|
||||
|| target.birthtimeNs !== published.birthtimeNs
|
||||
|| target.ctimeNs !== published.ctimeNs
|
||||
|| target.mtimeNs !== published.mtimeNs
|
||||
|| !validTimestamp(parent.birthtimeNs)
|
||||
|| !validTimestamp(target.birthtimeNs)
|
||||
|| !validTimestamp(target.ctimeNs)
|
||||
|| !validTimestamp(target.mtimeNs)) return;
|
||||
const targets = targetsRef.current;
|
||||
targets.set(key, {
|
||||
targetPath, parentRealPath,
|
||||
parentIdentity, parentBirthtimeNs: parent.birthtimeNs,
|
||||
targetIdentity, targetBirthtimeNs: target.birthtimeNs,
|
||||
targetCtimeNs: target.ctimeNs,
|
||||
targetMtimeNs: target.mtimeNs,
|
||||
targetSha256: published.sha256,
|
||||
});
|
||||
while (targets.size > TARGET_LIMIT) {
|
||||
const oldest = targets.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
targets.delete(oldest);
|
||||
}
|
||||
} catch {
|
||||
// The next download uses Save As when a selected path cannot be verified.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const findValidTarget = useCallback(async (
|
||||
endpointKey: string | null | undefined,
|
||||
sourcePath: string,
|
||||
sftpId: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
): Promise<RememberedTarget | null> => {
|
||||
const key = sourceKey(endpointKey, sourcePath, encoding);
|
||||
if (!key) return null;
|
||||
const targets = targetsRef.current;
|
||||
const remembered = targets.get(key);
|
||||
if (!remembered) return null;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.statSftp || !bridge.statLocal || !bridge.lstatLocal || !bridge.realpathLocal) return null;
|
||||
try {
|
||||
const parentPath = getParentPath(remembered.targetPath);
|
||||
const [source, parent, target, parentRealPath] = await Promise.all([
|
||||
bridge.statSftp(sftpId, sourcePath, encoding),
|
||||
bridge.statLocal(parentPath),
|
||||
bridge.lstatLocal(remembered.targetPath),
|
||||
bridge.realpathLocal(parentPath),
|
||||
]);
|
||||
if (
|
||||
source?.type !== "file"
|
||||
|| parent?.type !== "directory"
|
||||
|| target?.type !== "file"
|
||||
|| filesystemIdentity(target) !== remembered.targetIdentity
|
||||
|| target.birthtimeNs !== remembered.targetBirthtimeNs
|
||||
|| target.ctimeNs !== remembered.targetCtimeNs
|
||||
|| target.mtimeNs !== remembered.targetMtimeNs
|
||||
|| parentRealPath !== remembered.parentRealPath
|
||||
|| filesystemIdentity(parent) !== remembered.parentIdentity
|
||||
|| parent.birthtimeNs !== remembered.parentBirthtimeNs
|
||||
) {
|
||||
targets.delete(key);
|
||||
return null;
|
||||
}
|
||||
targets.delete(key);
|
||||
targets.set(key, remembered);
|
||||
return remembered;
|
||||
} catch {
|
||||
targets.delete(key);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const forget = useCallback((
|
||||
endpointKey: string | null | undefined,
|
||||
sourcePath: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
): void => {
|
||||
const key = sourceKey(endpointKey, sourcePath, encoding);
|
||||
if (key) targetsRef.current.delete(key);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({ remember, findValidTarget, forget }), [remember, findValidTarget, forget]);
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction
|
||||
import {
|
||||
FileConflict,
|
||||
FileConflictAction,
|
||||
Host,
|
||||
LocalDownloadTargetExpectation,
|
||||
LocalPublishedFileIdentity,
|
||||
SftpFilenameEncoding,
|
||||
TransferDirection,
|
||||
TransferStatus,
|
||||
@@ -163,6 +166,8 @@ export const useSftpTransfers = ({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
getTabByConnectionId,
|
||||
resolveConnectedHost,
|
||||
getTransferRouteKeyForHost,
|
||||
updateTab,
|
||||
refresh,
|
||||
clearCacheForConnection,
|
||||
@@ -275,6 +280,12 @@ export const useSftpTransfers = ({
|
||||
}, []);
|
||||
|
||||
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
||||
// Publication callbacks for quick-download targets (Codex P2 on PR #3516):
|
||||
// the generic retry re-runs processTransfer outside the downloadToLocal
|
||||
// closure that originally held the onPublishedLocalFile callback, so a
|
||||
// successful retry would otherwise replace the destination without
|
||||
// refreshing the remembered quick-download identity.
|
||||
const publicationHandlersRef = useRef<Map<string, (identity: LocalPublishedFileIdentity) => void | Promise<void>>>(new Map());
|
||||
const conflictDefaultsRef = useRef<TransferConflictDefaults>(new Map());
|
||||
const deferredConflictAttemptsRef = useRef<DeferredTransferAttemptQueue | null>(null);
|
||||
|
||||
@@ -462,6 +473,7 @@ export const useSftpTransfers = ({
|
||||
sourcePane: SftpPane,
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
sourceConnectHost?: Host,
|
||||
): Promise<TransferStatus> => {
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
return "cancelled";
|
||||
@@ -520,7 +532,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, sourceConnectHost);
|
||||
});
|
||||
return walkStatus;
|
||||
});
|
||||
@@ -532,6 +544,7 @@ export const useSftpTransfers = ({
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
updateTask: (updates: Partial<TransferTask>) => void,
|
||||
sourceConnectHost?: Host,
|
||||
): Promise<TransferStatus> => {
|
||||
|
||||
// Initialize encoding early to avoid temporal dead zone issues
|
||||
@@ -588,8 +601,14 @@ export const useSftpTransfers = ({
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
// Route-guarded direct downloads must never re-acquire their source from
|
||||
// the vault host alone: only the connect-time Host carries the session
|
||||
// proxy/jump route that identified the source (Codex P1 on PR #3516).
|
||||
if (task.requireOriginalSourceForResume && !sourceConnectHost) {
|
||||
throw new Error("Download source route cannot be verified; start a new download");
|
||||
}
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`, sourceConnectHost);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -794,12 +813,30 @@ export const useSftpTransfers = ({
|
||||
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
||||
: null;
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (duplicateTarget) {
|
||||
// Mirror the explicit Duplicate resolution: the retained
|
||||
// publication callback still remembers the original target path,
|
||||
// so after publishing the duplicate it would delete the
|
||||
// remembered association for this source and then fail its
|
||||
// identity check against the duplicate's inode, making later
|
||||
// quick downloads reopen Save As (Codex P2 on PR #3516). Drop
|
||||
// the handler; a fresh duplicate path has no remembered identity
|
||||
// to refresh.
|
||||
publicationHandlersRef.current.delete(task.id);
|
||||
}
|
||||
const updatedTask: TransferTask = {
|
||||
...task,
|
||||
...(duplicateTarget
|
||||
? {
|
||||
fileName: duplicateTarget.fileName,
|
||||
targetPath: duplicateTarget.targetPath,
|
||||
// The duplicate writes to a fresh path, so the remembered
|
||||
// identity of the original target no longer applies;
|
||||
// assertExpectedLocalDownloadTarget would reject the new
|
||||
// path against the old file's identity (Codex P2 on PR
|
||||
// #3516). The source-route guard (expectedSourceEndpointKey)
|
||||
// is retained.
|
||||
expectedLocalTarget: undefined,
|
||||
}
|
||||
: null),
|
||||
skipConflictCheck: true,
|
||||
@@ -812,7 +849,9 @@ export const useSftpTransfers = ({
|
||||
: t,
|
||||
),
|
||||
);
|
||||
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
|
||||
// Carry the verified source route into the continuation attempt —
|
||||
// the route guard rejects guarded repeat downloads without it.
|
||||
return processTransfer(updatedTask, sourcePane, targetPane, targetSide, sourceConnectHost);
|
||||
}
|
||||
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
@@ -939,6 +978,9 @@ export const useSftpTransfers = ({
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
// Quick-download retry: refresh the remembered target identity when
|
||||
// this attempt publishes the destination (Codex P2 on PR #3516).
|
||||
publicationHandlersRef.current.get(task.id),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1315,6 +1357,32 @@ export const useSftpTransfers = ({
|
||||
});
|
||||
}, [setTransfers]);
|
||||
|
||||
// Route-guarded direct downloads (Codex P1 on PR #3516): the vault hostId
|
||||
// alone cannot prove the bytes still come from the connect-time proxy/jump
|
||||
// route that identified the source. Re-resolve the live route and verify it
|
||||
// against the persisted endpoint key before every attempt — including
|
||||
// conflict continuations, whose verified host cannot be carried across the
|
||||
// UI await (Codex P2 on PR #3516). Returns undefined when the route cannot
|
||||
// be verified; callers must not fall back to the vault host.
|
||||
const resolveVerifiedSourceConnectHost = useCallback(
|
||||
async (task: TransferTask): Promise<Host | undefined> => {
|
||||
if (!task.requireOriginalSourceForResume) return undefined;
|
||||
const expectedKey = task.expectedSourceEndpointKey;
|
||||
const sourceTab = expectedKey ? getTabByConnectionId(task.sourceConnectionId) : undefined;
|
||||
const connectedHost = sourceTab && expectedKey
|
||||
? resolveConnectedHost?.(sourceTab.tabId)
|
||||
: undefined;
|
||||
const actualKey = connectedHost && connectedHost !== "local" && connectedHost.id === task.sourceHostId
|
||||
? await getTransferRouteKeyForHost?.(connectedHost)
|
||||
: undefined;
|
||||
if (!connectedHost || connectedHost === "local" || !expectedKey || !actualKey || actualKey !== expectedKey) {
|
||||
return undefined;
|
||||
}
|
||||
return connectedHost;
|
||||
},
|
||||
[getTabByConnectionId, resolveConnectedHost, getTransferRouteKeyForHost],
|
||||
);
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
// Prefer the live owner list; fall back to the center store (drag-drop
|
||||
@@ -1373,6 +1441,23 @@ export const useSftpTransfers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Route-guarded direct downloads (Codex P1 on PR #3516): the generic
|
||||
// retry below re-acquires the source lease from the vault hostId alone,
|
||||
// which cannot prove the bytes still come from the connect-time
|
||||
// proxy/jump route that identified the source. Revalidate the live
|
||||
// route against the persisted key and pin the retry lease to it.
|
||||
let retrySourceConnectHost: Host | undefined;
|
||||
if (task.requireOriginalSourceForResume) {
|
||||
retrySourceConnectHost = await resolveVerifiedSourceConnectHost(task);
|
||||
if (!retrySourceConnectHost) {
|
||||
notify.warning(
|
||||
"Repeat download source cannot be verified; start a new download",
|
||||
"SFTP",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await cleanupTaskArtifacts(task);
|
||||
|
||||
const retriedTask: TransferTask = {
|
||||
@@ -1407,6 +1492,14 @@ export const useSftpTransfers = ({
|
||||
completionHandlersRef.current.set(retriedTask.id, completionHandler);
|
||||
completionHandlersRef.current.delete(transferId);
|
||||
}
|
||||
// Carry the quick-download publication callback into the retried task so
|
||||
// a successful retry refreshes the remembered target identity instead of
|
||||
// leaving the old inode/hash remembered (Codex P2 on PR #3516).
|
||||
const publicationHandler = publicationHandlersRef.current.get(transferId);
|
||||
if (publicationHandler) {
|
||||
publicationHandlersRef.current.set(retriedTask.id, publicationHandler);
|
||||
publicationHandlersRef.current.delete(transferId);
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
@@ -1415,10 +1508,11 @@ export const useSftpTransfers = ({
|
||||
: t,
|
||||
),
|
||||
);
|
||||
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
|
||||
const retryStatus = await processTransfer(retriedTask, sourcePane, targetPane, targetSide, retrySourceConnectHost);
|
||||
if (retryStatus === "completed") publicationHandlersRef.current.delete(retriedTask.id);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
[acquireTransferSession, cleanupTaskArtifacts, ownerId, resolveTaskEndpoints, setTransfers, sftpSessionsRef],
|
||||
[acquireTransferSession, cleanupTaskArtifacts, getTabByConnectionId, getTransferRouteKeyForHost, ownerId, resolveConnectedHost, resolveTaskEndpoints, resolveVerifiedSourceConnectHost, setTransfers, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const clearCompletedTransfers = useCallback(() => {
|
||||
@@ -1426,6 +1520,15 @@ export const useSftpTransfers = ({
|
||||
const unfinishedParents = new Set(
|
||||
prev.filter((t) => !["completed", "cancelled", "failed"].includes(t.status)).map((t) => t.id),
|
||||
);
|
||||
// Cleared rows can no longer be retried, so their retained
|
||||
// quick-download publication callbacks are unreachable state (Codex P2
|
||||
// on PR #3516).
|
||||
for (const t of prev) {
|
||||
if (t.parentTaskId && unfinishedParents.has(t.parentTaskId)) continue;
|
||||
if (t.status === "completed" || t.status === "cancelled") {
|
||||
publicationHandlersRef.current.delete(t.id);
|
||||
}
|
||||
}
|
||||
// Keep non-compacted terminal exceptions of unfinished directory parents;
|
||||
// compacted completions already live in the bounded parent checkpoint.
|
||||
return prev.filter((t) => {
|
||||
@@ -1438,12 +1541,17 @@ export const useSftpTransfers = ({
|
||||
const dismissTransfer = useCallback((transferId: string) => {
|
||||
const task = transfersRef.current.find((candidate) => candidate.id === transferId);
|
||||
if (task) void cleanupTaskArtifacts(task);
|
||||
publicationHandlersRef.current.delete(transferId);
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== transferId && t.parentTaskId !== transferId));
|
||||
}, [cleanupTaskArtifacts, setTransfers]);
|
||||
|
||||
const dismissTransfers = useCallback((prunedTasks: readonly TransferTask[]) => {
|
||||
if (prunedTasks.length === 0) return;
|
||||
const removing = new Set(prunedTasks.map((task) => task.id));
|
||||
// Dropped history rows can no longer be retried, so their retained
|
||||
// quick-download publication callbacks are unreachable state (Codex P2
|
||||
// on PR #3516).
|
||||
for (const id of removing) publicationHandlersRef.current.delete(id);
|
||||
const artifactTasks = prunedTasks.filter((task) => task.status !== "completed");
|
||||
// This callback is used only for automatic history eviction. Completed
|
||||
// streams already cleaned their staging files; sending one cleanup IPC per
|
||||
@@ -1564,6 +1672,15 @@ export const useSftpTransfers = ({
|
||||
const affectedConflict = affectedConflictById.get(affectedTask.id);
|
||||
|
||||
if (action === "duplicate") {
|
||||
// The duplicate publishes at a fresh path, but the retained
|
||||
// publication callback still remembers the original target path:
|
||||
// after publishing the duplicate it would delete the remembered
|
||||
// association for this source and then fail its identity check
|
||||
// against the duplicate's inode, so later quick downloads
|
||||
// unexpectedly reopen Save As (Codex P2 on PR #3516). Drop the
|
||||
// handler; there is no remembered identity to refresh for a fresh
|
||||
// duplicate path.
|
||||
publicationHandlersRef.current.delete(affectedTask.id);
|
||||
const endpoints = resolveTaskEndpoints(affectedTask);
|
||||
if (!endpoints) continue;
|
||||
const targetSftpId = endpoints.targetPane.connection?.isLocal
|
||||
@@ -1578,6 +1695,12 @@ export const useSftpTransfers = ({
|
||||
...affectedTask,
|
||||
fileName: duplicateTarget.fileName,
|
||||
targetPath: duplicateTarget.targetPath,
|
||||
// The duplicate writes to a fresh path, so the remembered identity
|
||||
// of the original target no longer applies;
|
||||
// assertExpectedLocalDownloadTarget would reject the new path
|
||||
// against the old file's identity (Codex P2 on PR #3516). The
|
||||
// source-route guard (expectedSourceEndpointKey) is retained.
|
||||
expectedLocalTarget: undefined,
|
||||
skipConflictCheck: true,
|
||||
};
|
||||
} else if (action === "replace") {
|
||||
@@ -1715,11 +1838,18 @@ export const useSftpTransfers = ({
|
||||
async () => {
|
||||
const attempt = resolveCurrentAttempt();
|
||||
if (!attempt) return;
|
||||
// The verified connect-time host of the interrupted attempt is
|
||||
// stack-local and cannot survive the conflict UI await. Re-resolve
|
||||
// and re-verify it for the resumed attempt; if verification now
|
||||
// fails, the route guard inside processTransfer fails the row
|
||||
// instead of silently re-acquiring from the vault host (Codex P2).
|
||||
const verifiedSourceConnectHost = await resolveVerifiedSourceConnectHost(attempt.current);
|
||||
await processTransfer(
|
||||
attempt.current,
|
||||
attempt.endpoints.sourcePane,
|
||||
attempt.endpoints.targetPane,
|
||||
attempt.endpoints.targetSide,
|
||||
verifiedSourceConnectHost,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1734,6 +1864,7 @@ export const useSftpTransfers = ({
|
||||
markBatchStopped,
|
||||
ownerId,
|
||||
resolveTaskEndpoints,
|
||||
resolveVerifiedSourceConnectHost,
|
||||
sftpSessionsRef,
|
||||
],
|
||||
);
|
||||
@@ -1750,6 +1881,9 @@ export const useSftpTransfers = ({
|
||||
fileName: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
expectedLocalTarget?: LocalDownloadTargetExpectation;
|
||||
expectedSourceEndpointKey?: string;
|
||||
onPublishedLocalFile?: (identity: LocalPublishedFileIdentity) => void | Promise<void>;
|
||||
sftpId: string;
|
||||
connectionId: string;
|
||||
sourceHostId: string;
|
||||
@@ -1782,6 +1916,8 @@ export const useSftpTransfers = ({
|
||||
fileName: params.fileName,
|
||||
sourcePath: params.sourcePath,
|
||||
targetPath: params.targetPath,
|
||||
expectedLocalTarget: params.expectedLocalTarget,
|
||||
expectedSourceEndpointKey: params.expectedSourceEndpointKey,
|
||||
sourceConnectionId: params.connectionId,
|
||||
sourceHostId: params.sourceHostId,
|
||||
sourceHostLabel: params.sourceHostLabel,
|
||||
@@ -1817,6 +1953,10 @@ export const useSftpTransfers = ({
|
||||
};
|
||||
const executeDownload = async (): Promise<TransferStatus> => {
|
||||
const sourceEncoding = params.sourceEncoding ?? "auto";
|
||||
const sourceTab = getTabByConnectionId(params.connectionId);
|
||||
const connectedHost = sourceTab && resolveConnectedHost?.(sourceTab.tabId);
|
||||
const sourceConnectHost = connectedHost && connectedHost !== "local"
|
||||
&& connectedHost.id === params.sourceHostId ? connectedHost : undefined;
|
||||
// Mutable counter to track child failures outside React state,
|
||||
// so the final status check doesn't depend on render timing.
|
||||
let childFailureCount = 0;
|
||||
@@ -1826,10 +1966,25 @@ export const useSftpTransfers = ({
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
// A remembered target is safe only when the transfer can use the
|
||||
// exact connect-time route that identified the browsed source.
|
||||
if (params.expectedLocalTarget && (!sourceConnectHost || !params.expectedSourceEndpointKey)) {
|
||||
throw new Error("Download source connection changed; choose the destination again");
|
||||
}
|
||||
if (params.expectedSourceEndpointKey) {
|
||||
// Compare against the persistable route identity, not the pool
|
||||
// key: the persisted expected key must never be a credential
|
||||
// digest (Codex P2 on PR #3516).
|
||||
const actualKey = sourceConnectHost && await getTransferRouteKeyForHost?.(sourceConnectHost);
|
||||
if (!actualKey || actualKey !== params.expectedSourceEndpointKey) {
|
||||
throw new Error("Download source route changed; choose the destination again");
|
||||
}
|
||||
}
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
sourceConnectHost,
|
||||
);
|
||||
workingSourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
@@ -1850,6 +2005,9 @@ export const useSftpTransfers = ({
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
undefined,
|
||||
undefined,
|
||||
sourceConnectHost,
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
@@ -1861,6 +2019,9 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
"auto",
|
||||
task.id,
|
||||
false,
|
||||
params.onPublishedLocalFile,
|
||||
sourceConnectHost,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1906,16 +2067,26 @@ export const useSftpTransfers = ({
|
||||
sourceWorkLease = null;
|
||||
}
|
||||
};
|
||||
let result: TransferStatus = "failed";
|
||||
// Holder object: TS narrowing keeps `let result` at its initializer
|
||||
// type even though the walk callback reassigns it.
|
||||
const attempt = { status: "failed" as TransferStatus };
|
||||
// Keep the publication callback reachable for the generic retry: it
|
||||
// re-runs processTransfer with a fresh task id and needs this exact
|
||||
// closure to refresh the remembered quick-download identity (Codex P2
|
||||
// on PR #3516). Terminal success removes it; failure retains it.
|
||||
if (params.onPublishedLocalFile) {
|
||||
publicationHandlersRef.current.set(task.id, params.onPublishedLocalFile);
|
||||
}
|
||||
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
|
||||
transferRuntime.runWalk(task.id, async () => {
|
||||
result = await executeDownload();
|
||||
attempt.status = await executeDownload();
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
if (attempt.status === "completed") publicationHandlersRef.current.delete(task.id);
|
||||
return attempt.status;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sftpSessionsRef, acquireTransferSession],
|
||||
[sftpSessionsRef, acquireTransferSession, getTabByConnectionId, resolveConnectedHost, getTransferRouteKeyForHost],
|
||||
);
|
||||
|
||||
// Publish only on owner change / mount. Do NOT re-publish on every `transfers`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { FileConflict, FileConflictAction, Host, LocalDownloadTargetExpectation, LocalPublishedFileIdentity, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { AcquireTransferSessionFn } from "./transferDirectoryOps";
|
||||
|
||||
@@ -11,6 +11,14 @@ export interface UseSftpTransfersParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
|
||||
getTabByConnectionId: (connectionId: string) => { side: "left" | "right"; tabId: string; pane: SftpPane } | null;
|
||||
resolveConnectedHost?: (tabId: string) => Host | "local" | null;
|
||||
/**
|
||||
* Persistable source-route identity derived only from non-secret
|
||||
* endpoint/proxy attributes (Codex P2 on PR #3516). Used for the
|
||||
* expectedSourceEndpointKey guard; connection pooling keeps using the
|
||||
* full pool key inside useSftpState.
|
||||
*/
|
||||
getTransferRouteKeyForHost?: (host: Host) => Promise<string>;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
@@ -43,6 +51,9 @@ export interface UseSftpTransfersResult {
|
||||
fileName: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
expectedLocalTarget?: LocalDownloadTargetExpectation;
|
||||
expectedSourceEndpointKey?: string;
|
||||
onPublishedLocalFile?: (identity: LocalPublishedFileIdentity) => void | Promise<void>;
|
||||
sftpId: string;
|
||||
connectionId: string;
|
||||
sourceHostId: string;
|
||||
|
||||
@@ -30,6 +30,18 @@ test("directory targets cannot escape the selected local root", () => {
|
||||
".. ",
|
||||
". ",
|
||||
"safe.txt:alternate-stream",
|
||||
"question?.txt",
|
||||
"star*.txt",
|
||||
"quote\".txt",
|
||||
"less<greater>.txt",
|
||||
"pipe|.txt",
|
||||
"control\u0001.txt",
|
||||
// Win32 reserved device names reject downloads that would target a
|
||||
// device path instead of a regular file.
|
||||
"NUL",
|
||||
"CON.txt",
|
||||
"COM1",
|
||||
"aux.tar.gz",
|
||||
]) {
|
||||
assert.throws(
|
||||
() => joinTransferTargetPath(windowsRoot, relativePath),
|
||||
@@ -37,6 +49,13 @@ test("directory targets cannot escape the selected local root", () => {
|
||||
relativePath,
|
||||
);
|
||||
}
|
||||
// Reserved device names are only a Win32 hazard; POSIX destinations keep
|
||||
// them as ordinary filenames.
|
||||
assert.equal(
|
||||
joinTransferTargetPath("/srv/downloads", "NUL"),
|
||||
"/srv/downloads/NUL",
|
||||
);
|
||||
assert.equal(joinTransferTargetPath("/srv/downloads", "question?.txt"), "/srv/downloads/question?.txt");
|
||||
assert.equal(
|
||||
joinTransferTargetPath(windowsRoot, "nested/report.txt"),
|
||||
"C:\\Users\\alice\\Downloads\\folder\\nested\\report.txt",
|
||||
|
||||
@@ -249,6 +249,27 @@ export const joinPath = (base: string, name: string): string => {
|
||||
return `${base.replace(/\/+$/, "")}/${name}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Win32 reserved device names: a filename like "NUL", "CON.txt", or "COM1"
|
||||
* resolves to a device path instead of a regular file, so a download queued
|
||||
* with such a name can never produce the expected local file. POSIX
|
||||
* destinations are unaffected (these are just ordinary names there).
|
||||
*/
|
||||
const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
"COM¹", "COM²", "COM³",
|
||||
"LPT¹", "LPT²", "LPT³",
|
||||
]);
|
||||
|
||||
const isWindowsReservedDeviceName = (part: string): boolean => {
|
||||
// Win32 keeps the device meaning even with an extension ("CON.txt");
|
||||
// trailing dots/spaces are rejected separately below.
|
||||
const stem = part.split(".", 1)[0];
|
||||
return WINDOWS_RESERVED_DEVICE_NAMES.has(stem.toUpperCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Join a discovered directory entry to a transfer target without allowing a
|
||||
* server-controlled filename to escape the user-selected destination root.
|
||||
@@ -285,7 +306,10 @@ export function joinTransferTargetPath(
|
||||
if (parts.some((part) => (
|
||||
part.includes("\\")
|
||||
|| part.includes(":")
|
||||
|| /[<>"|?*]/.test(part)
|
||||
|| Array.from(part).some((char) => char.charCodeAt(0) < 32)
|
||||
|| /[. ]$/.test(part)
|
||||
|| isWindowsReservedDeviceName(part)
|
||||
))) return unsafe();
|
||||
}
|
||||
if (windowsBase) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import type { LocalPublishedFileIdentity, TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
/** The displaced invocation may fail its walk, but no longer owns the child row. */
|
||||
@@ -6,7 +6,12 @@ export class TransferOwnerChangedError extends Error {}
|
||||
|
||||
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
type StreamResult = {
|
||||
error?: string;
|
||||
cancelled?: boolean;
|
||||
superseded?: boolean;
|
||||
publishedLocalIdentity?: LocalPublishedFileIdentity;
|
||||
} | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
export async function runTransferAndWaitForOwner(
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_QUICK_DOWNLOAD,
|
||||
STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS,
|
||||
STORAGE_KEY_SFTP_TRANSFER_POOL_IDLE_TTL_MS,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -111,6 +112,9 @@ import {
|
||||
resolveSftpTransferConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
} from './sftp/transferConcurrency';
|
||||
import {
|
||||
resolveSftpQuickDownloadEnabled,
|
||||
} from './sftp/quickDownloadPreference';
|
||||
import { resolveSshTransportIdleTtlMs } from '../../infrastructure/config/sshTransportIdleTtl';
|
||||
import {
|
||||
DEFAULT_ACCENT_MODE,
|
||||
@@ -484,6 +488,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const [sftpSkipUnchanged, setSftpSkipUnchanged] = useState<boolean>(() =>
|
||||
resolveSftpSkipUnchangedEnabled(() => localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED)),
|
||||
);
|
||||
const [sftpQuickDownload, setSftpQuickDownloadState] = useState<boolean>(() =>
|
||||
resolveSftpQuickDownloadEnabled(localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_QUICK_DOWNLOAD)),
|
||||
);
|
||||
|
||||
const [sshTransportIdleTtlMs, setSshTransportIdleTtlMsState] = useState<number>(() => {
|
||||
// Prefer the new SSH transport key; if absent (upgrade), migrate the legacy
|
||||
@@ -1017,6 +1024,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
const storedSkipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
|
||||
if (storedSkipUnchanged != null) setSftpSkipUnchanged(storedSkipUnchanged);
|
||||
const storedQuickDownload = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_QUICK_DOWNLOAD);
|
||||
if (storedQuickDownload != null) setSftpQuickDownloadState(storedQuickDownload);
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
@@ -1228,6 +1237,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpQuickDownload: setSftpQuickDownloadState,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
@@ -1288,7 +1298,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpQuickDownload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
@@ -1298,7 +1308,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpQuickDownload: setSftpQuickDownloadState, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
@@ -1576,6 +1586,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SKIP_UNCHANGED, sftpSkipUnchanged);
|
||||
}, [sftpSkipUnchanged, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP quick download setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_QUICK_DOWNLOAD, sftpQuickDownload);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_QUICK_DOWNLOAD, sftpQuickDownload);
|
||||
}, [sftpQuickDownload, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-open sidebar setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
|
||||
@@ -2124,6 +2141,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpUseCompressedUpload,
|
||||
sftpSkipUnchanged,
|
||||
setSftpSkipUnchanged,
|
||||
sftpQuickDownload,
|
||||
setSftpQuickDownload: setSftpQuickDownloadState,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
@@ -2219,7 +2238,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpQuickDownload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
|
||||
import {
|
||||
buildTransferRouteKey,
|
||||
createTransferPoolKeyCache,
|
||||
getSharedTransferConnectionPool,
|
||||
} from "./sftp/transferConnectionPool";
|
||||
@@ -314,6 +315,13 @@ export const useSftpState = (
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[hosts, identities, keys, transferKnownHosts, transferTerminalSettings],
|
||||
);
|
||||
// Route identity cache: same scope as the pool key cache but the digest
|
||||
// covers only non-secret attributes, so it is safe to persist (see below).
|
||||
const transferRouteKeyCache = useMemo(
|
||||
() => createTransferPoolKeyCache(buildTransferRouteKey),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[hosts, identities, keys, transferKnownHosts, transferTerminalSettings],
|
||||
);
|
||||
const resolveTransferSourceSessionId = options?.resolveTransferSourceSessionId;
|
||||
const resolveBrowseSourceSessionId = options?.resolveBrowseSourceSessionId;
|
||||
|
||||
@@ -367,22 +375,8 @@ export const useSftpState = (
|
||||
[hosts, identities, keys, resolveTransferSourceSessionId, transferKnownHosts, transferTerminalSettings],
|
||||
);
|
||||
|
||||
const acquireTransferSession = useCallback(
|
||||
async (hostId: string, transferId: string, connectHost?: 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
|
||||
? connectHost
|
||||
: hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!host) {
|
||||
throw new Error(`Host not found for transfer session: ${hostId}`);
|
||||
}
|
||||
if (connectHost && connectHost.id !== hostId) {
|
||||
throw new Error(
|
||||
`Transfer connect host id mismatch: expected ${hostId}, got ${connectHost.id}`,
|
||||
);
|
||||
}
|
||||
const poolKey = await transferPoolKeyCache.get(host, () => ({
|
||||
const getTransferPoolKeyForHost = useCallback(
|
||||
(host: Host) => transferPoolKeyCache.get(host, () => ({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
@@ -397,10 +391,64 @@ export const useSftpState = (
|
||||
knownHosts: transferKnownHosts,
|
||||
terminalSettings: transferTerminalSettings,
|
||||
}),
|
||||
}));
|
||||
})),
|
||||
[hosts, identities, keys, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
||||
);
|
||||
|
||||
// The quick-download source must have the same identity as the pooled
|
||||
// connection that will actually read its bytes, including proxy/jump route.
|
||||
// The persisted guard key is a route identity derived only from non-secret
|
||||
// endpoint/proxy attributes plus session-keyed proxy-command digests: a
|
||||
// digest of resolved credentials could act as an offline verifier for
|
||||
// password guesses once transfer history lands in localStorage (Codex P2
|
||||
// on PR #3516), and a dropped proxy command would collapse every command
|
||||
// proxy for the same target onto one route (Codex P1 on PR #3516).
|
||||
const getTransferRouteKeyForHost = useCallback(
|
||||
(host: Host) => transferRouteKeyCache.get(host, () => ({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
protocol: host.protocol,
|
||||
sftpSudo: host.sftpSudo,
|
||||
connectionOptions: buildSftpHostCredentials({
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
knownHosts: transferKnownHosts,
|
||||
terminalSettings: transferTerminalSettings,
|
||||
}),
|
||||
})),
|
||||
[hosts, identities, keys, transferKnownHosts, transferRouteKeyCache, transferTerminalSettings],
|
||||
);
|
||||
|
||||
const getDownloadEndpointKey = useCallback(async (connectionId: string) => {
|
||||
const tab = getTabByConnectionId(connectionId);
|
||||
const host = tab && connectedHostByTabIdRef.current.get(tab.tabId);
|
||||
if (!host || host === "local" || host.id !== tab?.pane.connection?.hostId) return null;
|
||||
return getTransferRouteKeyForHost(host);
|
||||
}, [getTabByConnectionId, getTransferRouteKeyForHost]);
|
||||
|
||||
const acquireTransferSession = useCallback(
|
||||
async (hostId: string, transferId: string, connectHost?: Host) => {
|
||||
// Prefer the connect-time Host (terminal session overrides) so pooled
|
||||
// downloads/uploads open the same endpoint as the browse tab.
|
||||
const host = connectHost && connectHost.id === hostId
|
||||
? connectHost
|
||||
: hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!host) {
|
||||
throw new Error(`Host not found for transfer session: ${hostId}`);
|
||||
}
|
||||
if (connectHost && connectHost.id !== hostId) {
|
||||
throw new Error(
|
||||
`Transfer connect host id mismatch: expected ${hostId}, got ${connectHost.id}`,
|
||||
);
|
||||
}
|
||||
const poolKey = await getTransferPoolKeyForHost(host);
|
||||
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host));
|
||||
},
|
||||
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
||||
[getTransferPoolKeyForHost, hosts, openPoolSftpSession],
|
||||
);
|
||||
|
||||
/** True after browse channels were soft-closed while this owner stayed mounted. */
|
||||
@@ -547,6 +595,8 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
getTabByConnectionId,
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
getTransferRouteKeyForHost,
|
||||
updateTab,
|
||||
refresh,
|
||||
clearCacheForConnection,
|
||||
@@ -810,6 +860,7 @@ export const useSftpState = (
|
||||
resolveConflict: resolveAnyConflict,
|
||||
getSftpIdForConnection,
|
||||
getConnectionCacheKey,
|
||||
getDownloadEndpointKey,
|
||||
setLastConnectedHost,
|
||||
reportSessionError: handleSessionError,
|
||||
rejectHostKeyVerification,
|
||||
@@ -892,6 +943,7 @@ export const useSftpState = (
|
||||
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
getConnectionCacheKey: (...args: Parameters<typeof getConnectionCacheKey>) => methodsRef.current.getConnectionCacheKey(...args),
|
||||
getDownloadEndpointKey: (...args: Parameters<typeof getDownloadEndpointKey>) => methodsRef.current.getDownloadEndpointKey(...args),
|
||||
setLastConnectedHost: (...args: Parameters<typeof setLastConnectedHost>) => methodsRef.current.setLastConnectedHost(...args),
|
||||
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
|
||||
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_QUICK_DOWNLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -316,6 +317,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_SKIP_UNCHANGED,
|
||||
STORAGE_KEY_SFTP_QUICK_DOWNLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -539,6 +541,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const skipUnchanged = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED);
|
||||
if (skipUnchanged != null) settings.sftpSkipUnchanged = skipUnchanged;
|
||||
const quickDownload = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_QUICK_DOWNLOAD);
|
||||
if (quickDownload != null) settings.sftpQuickDownload = quickDownload;
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
@@ -815,6 +819,7 @@ async function applySyncableSettings(
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpSkipUnchanged != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_SKIP_UNCHANGED, settings.sftpSkipUnchanged);
|
||||
if (settings.sftpQuickDownload != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_QUICK_DOWNLOAD, settings.sftpQuickDownload);
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
|
||||
if (settings.sftpDefaultViewMode != null) {
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function SettingsFileAssociationsTab() {
|
||||
sftpUseCompressedUpload, setSftpUseCompressedUpload,
|
||||
|
||||
sftpSkipUnchanged, setSftpSkipUnchanged,
|
||||
sftpQuickDownload, setSftpQuickDownload,
|
||||
sftpAutoOpenSidebar, setSftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd, setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode, setSftpDefaultViewMode,
|
||||
@@ -230,6 +231,12 @@ export default function SettingsFileAssociationsTab() {
|
||||
>
|
||||
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.sftp.quickDownload.enable')}
|
||||
description={t('settings.sftp.quickDownload.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpQuickDownload} onChange={setSftpQuickDownload} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.ssh.transportIdleTtl')}
|
||||
description={t('settings.ssh.transportIdleTtl.desc')}
|
||||
|
||||
@@ -400,8 +400,11 @@ test("same-pane cut into a bind-mount alias of the source folder is blocked", as
|
||||
// realpath cannot see through bind mounts: /mnt/alias is bind-mounted to /a,
|
||||
// so both sides resolve to themselves and only stat identities match.
|
||||
const resolver = (path: string) => Promise.resolve(path);
|
||||
const largeDeviceId = "9007199254740993";
|
||||
const statIdentity = (path: string) => Promise.resolve(
|
||||
path === "/a" || path === "/mnt/alias" ? { dev: 42, ino: 7 } : { dev: 42, ino: 9 },
|
||||
path === "/a" || path === "/mnt/alias"
|
||||
? { dev: largeDeviceId, ino: "9007199254740995" }
|
||||
: { dev: largeDeviceId, ino: "9007199254740997" },
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { LocalDownloadTargetExpectation } from "../../../domain/models";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { STORAGE_KEY_SFTP_QUICK_DOWNLOAD } from "../../../infrastructure/config/storageKeys";
|
||||
import { useSftpViewFileOps } from "./useSftpViewFileOps";
|
||||
|
||||
const file = (name: string, type: SftpFileEntry["type"] = "file"): SftpFileEntry => ({
|
||||
name, type, size: 3, lastModified: 0, sizeFormatted: "", lastModifiedFormatted: "",
|
||||
});
|
||||
|
||||
test("quick download reuses only the exact target selected for the same remote file", async () => {
|
||||
const oldWindow = globalThis.window;
|
||||
const oldStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const oldAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const storage = new Map<string, string>([[STORAGE_KEY_SFTP_QUICK_DOWNLOAD, "true"]]);
|
||||
const deviceId = "9007199254740993";
|
||||
const existingFiles = new Map<string, "file" | "directory" | "symlink">();
|
||||
const fileInodes = new Map<string, number>();
|
||||
const fileBirthtimes = new Map<string, string>();
|
||||
const fileCtimes = new Map<string, string>();
|
||||
const fileMtimes = new Map<string, string>();
|
||||
let nextFileInode = 1000;
|
||||
let replaceAfterPublication = false;
|
||||
let modifyAfterPublication = false;
|
||||
const existingDirectories = new Set(["/downloads", "/batch"]);
|
||||
const realParents = new Map([["/downloads", "/downloads"], ["/batch", "/batch"]]);
|
||||
const parentInodes = new Map([["/downloads", 100], ["/batch", 200]]);
|
||||
const remoteTypes = new Map<string, "file" | "directory">([["/remote/folder", "directory"]]);
|
||||
const downloads: Array<{
|
||||
sourcePath: string; targetPath: string; isDirectory: boolean;
|
||||
expectedLocalTarget?: LocalDownloadTargetExpectation;
|
||||
expectedSourceEndpointKey?: string;
|
||||
}> = [];
|
||||
const savePaths = [
|
||||
"/downloads/renamed.txt",
|
||||
"/downloads/other.txt",
|
||||
"/downloads/reselected.txt",
|
||||
"/downloads/type-reselected.txt",
|
||||
"/downloads/moved-reselected.txt",
|
||||
"/downloads/parent-restored.txt",
|
||||
"/downloads/mount-reselected.txt",
|
||||
"/downloads/other-endpoint.txt",
|
||||
"/downloads/disabled.txt",
|
||||
"/downloads/re-enabled.txt",
|
||||
"/downloads/replaced-reselected.txt",
|
||||
"/downloads/reused-inode-reselected.txt",
|
||||
"/downloads/post-publication-reselected.txt",
|
||||
"/downloads/in-place-reselected.txt",
|
||||
];
|
||||
let saveCalls = 0;
|
||||
let directoryCalls = 0;
|
||||
let endpointKey = "host-1:server-a:22:ssh::user:sftp";
|
||||
const connection = { id: "conn-1", hostId: "host-1", hostLabel: "Host", currentPath: "/remote", isLocal: false };
|
||||
const pane = { connection, filenameEncoding: "auto" };
|
||||
const sftpRef = { current: {
|
||||
leftPane: pane, rightPane: pane,
|
||||
getDownloadEndpointKey: async () => endpointKey,
|
||||
joinPath: (parent: string, name: string) => `${parent}/${name}`,
|
||||
downloadToLocal: async (params: {
|
||||
sourcePath: string; targetPath: string; isDirectory: boolean;
|
||||
expectedLocalTarget?: LocalDownloadTargetExpectation;
|
||||
expectedSourceEndpointKey?: string;
|
||||
onPublishedLocalFile?: (identity: { dev: string; ino: string; birthtimeNs: string; ctimeNs: string; mtimeNs: string; sha256: string }) => void | Promise<void>;
|
||||
}) => {
|
||||
downloads.push(params);
|
||||
existingFiles.set(params.targetPath, params.isDirectory ? "directory" : "file");
|
||||
if (!params.isDirectory) {
|
||||
const inode = nextFileInode++;
|
||||
fileInodes.set(params.targetPath, inode);
|
||||
fileBirthtimes.set(params.targetPath, String(inode * 1000));
|
||||
fileCtimes.set(params.targetPath, String(inode * 1000));
|
||||
fileMtimes.set(params.targetPath, String(inode * 1000));
|
||||
// Mirror the real pipeline: a downloadToLocal caller must observe
|
||||
// completion only after the publication callback resolves.
|
||||
await params.onPublishedLocalFile?.({ dev: deviceId, ino: String(inode), birthtimeNs: String(inode * 1000),
|
||||
ctimeNs: String(inode * 1000), mtimeNs: String(inode * 1000), sha256: "a".repeat(64) });
|
||||
if (modifyAfterPublication) {
|
||||
modifyAfterPublication = false;
|
||||
fileCtimes.set(params.targetPath, String(inode * 1000 + 1));
|
||||
fileMtimes.set(params.targetPath, String(inode * 1000 + 1));
|
||||
}
|
||||
if (replaceAfterPublication) {
|
||||
replaceAfterPublication = false;
|
||||
fileInodes.set(params.targetPath, 555);
|
||||
fileBirthtimes.set(params.targetPath, "555000");
|
||||
}
|
||||
}
|
||||
return "completed";
|
||||
},
|
||||
} as unknown as SftpStateApi };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
statLocal: async (path: string) => {
|
||||
if (!existingDirectories.has(path)) throw new Error("ENOENT");
|
||||
return { type: "directory", dev: deviceId, ino: String(parentInodes.get(path)), birthtimeNs: String(parentInodes.get(path)! * 1000) };
|
||||
},
|
||||
lstatLocal: async (path: string) => {
|
||||
const type = existingFiles.get(path);
|
||||
if (!type) throw new Error("ENOENT");
|
||||
return { type, dev: deviceId, ino: String(fileInodes.get(path)),
|
||||
birthtimeNs: fileBirthtimes.get(path), ctimeNs: fileCtimes.get(path), mtimeNs: fileMtimes.get(path) };
|
||||
},
|
||||
realpathLocal: async (path: string) => {
|
||||
const real = realParents.get(path);
|
||||
if (!real) throw new Error("ENOENT");
|
||||
return real;
|
||||
},
|
||||
statSftp: async (_id: string, path: string) => ({ type: remoteTypes.get(path) ?? "file" }),
|
||||
} };
|
||||
|
||||
let ops: ReturnType<typeof useSftpViewFileOps> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpViewFileOps({
|
||||
sftpRef,
|
||||
behaviorRef: { current: "" },
|
||||
autoSyncRef: { current: false },
|
||||
getOpenerForFileRef: { current: () => null },
|
||||
setOpenerForExtension: () => undefined,
|
||||
t: (key) => key,
|
||||
showSaveDialog: async () => savePaths[saveCalls++] ?? null,
|
||||
selectDirectory: async () => { directoryCalls++; return "/batch"; },
|
||||
getSftpIdForConnection: () => "sftp-1",
|
||||
statSftp: async (_id, path) => ({
|
||||
name: path.split("/").at(-1) ?? "",
|
||||
type: remoteTypes.get(path) ?? "file",
|
||||
size: 3,
|
||||
lastModified: 0,
|
||||
}),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const single = ops!.onDownloadFileLeft as unknown as (entry: SftpFileEntry, path?: string) => Promise<void>;
|
||||
const batch = ops!.onDownloadFilesLeft as unknown as (entries: SftpFileEntry[]) => Promise<void>;
|
||||
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 1);
|
||||
assert.deepEqual(downloads.slice(0, 2).map((entry) => entry.targetPath), [
|
||||
"/downloads/renamed.txt", "/downloads/renamed.txt",
|
||||
]);
|
||||
assert.equal(downloads[0].expectedLocalTarget, undefined);
|
||||
assert.equal(downloads[0].expectedSourceEndpointKey, endpointKey);
|
||||
assert.equal(downloads[1].expectedLocalTarget?.targetIdentity, `${deviceId}:1000`);
|
||||
assert.equal(downloads[1].expectedLocalTarget?.targetBirthtimeNs, "1000000");
|
||||
assert.equal(downloads[2].expectedLocalTarget?.targetIdentity, `${deviceId}:1001`);
|
||||
|
||||
await act(async () => { await single(file("report.txt"), "/other/report.txt"); });
|
||||
assert.equal(saveCalls, 2, "another remote source still opens Save As");
|
||||
|
||||
existingFiles.delete("/downloads/renamed.txt");
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 3, "a deleted target returns to Save As");
|
||||
|
||||
existingFiles.set("/downloads/reselected.txt", "directory");
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 4, "a target that changed type returns to Save As");
|
||||
|
||||
realParents.set("/downloads", "/different-mount");
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 5, "a redirected parent returns to Save As");
|
||||
|
||||
realParents.set("/downloads", "/downloads");
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 6);
|
||||
parentInodes.set("/downloads", 101);
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 7, "a changed mount beneath the same path returns to Save As");
|
||||
|
||||
await act(async () => { await single(file("folder", "directory")); });
|
||||
await act(async () => { await batch([file("a"), file("b")]); });
|
||||
assert.equal(directoryCalls, 2, "folder and batch downloads still pick a directory");
|
||||
assert.equal(downloads.at(-1)?.targetPath, "/batch/b");
|
||||
|
||||
endpointKey = "host-1:server-b:22:ssh::user:sftp";
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 8, "another endpoint with the same host ID still opens Save As");
|
||||
endpointKey = "host-1:server-a:22:ssh::user:sftp";
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 8, "the original endpoint retains only its own target");
|
||||
assert.equal(downloads.at(-1)?.targetPath, "/downloads/mount-reselected.txt");
|
||||
|
||||
storage.set(STORAGE_KEY_SFTP_QUICK_DOWNLOAD, "false");
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 9, "turning off the option restores Save As");
|
||||
storage.set(STORAGE_KEY_SFTP_QUICK_DOWNLOAD, "true");
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 10, "the old target is forgotten while the option is disabled");
|
||||
assert.equal(downloads.at(-1)?.targetPath, "/downloads/re-enabled.txt");
|
||||
|
||||
fileInodes.set("/downloads/re-enabled.txt", 99999);
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 11, "a different file at the remembered path returns to Save As");
|
||||
assert.equal(downloads.at(-1)?.targetPath, "/downloads/replaced-reselected.txt");
|
||||
|
||||
const reusedPath = "/downloads/replaced-reselected.txt";
|
||||
fileBirthtimes.set(reusedPath, "9999999");
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 12, "a new file reusing the same inode still opens Save As");
|
||||
assert.equal(downloads.at(-1)?.targetPath, "/downloads/reused-inode-reselected.txt");
|
||||
|
||||
replaceAfterPublication = true;
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 12, "the published file initially uses the remembered destination");
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 13, "a file replaced after publication is never remembered");
|
||||
assert.equal(downloads.at(-1)?.targetPath, "/downloads/post-publication-reselected.txt");
|
||||
modifyAfterPublication = true;
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
await act(async () => { await single(file("report.txt")); });
|
||||
assert.equal(saveCalls, 14, "an in-place edit after publication is not remembered");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
(globalThis as { window?: unknown }).window = oldWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = oldStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = oldAct;
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { TransferStatus } from "../../../domain/models";
|
||||
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
|
||||
import { getParentPath, joinTransferTargetPath } from "../../../application/state/sftp/utils";
|
||||
import { readSftpQuickDownloadEnabled } from "../../../application/state/sftp/quickDownloadPreference";
|
||||
import { useSftpQuickDownloadTargets } from "../../../application/state/sftp/useSftpQuickDownloadTargets";
|
||||
import {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
runBoundedConcurrency,
|
||||
@@ -21,6 +23,7 @@ import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
|
||||
|
||||
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
|
||||
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
|
||||
|
||||
/**
|
||||
* Multi-select roots each start their own interleaved folder walk / session
|
||||
* work. Bound them so many selected directories cannot stampede the scheduler.
|
||||
@@ -40,6 +43,7 @@ export const useSftpViewFileOps = ({
|
||||
statSftp,
|
||||
listSftp,
|
||||
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
|
||||
const quickDownloadTargets = useSftpQuickDownloadTargets();
|
||||
const [permissionsState, setPermissionsState] = useState<{
|
||||
file: SftpFileEntry;
|
||||
side: "left" | "right";
|
||||
@@ -435,7 +439,7 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
// For remote SFTP files/directories, use transfer-center downloads
|
||||
// (dedicated pool sessions via downloadToLocal).
|
||||
if (!showSaveDialog || !getSftpIdForConnection) {
|
||||
if (!getSftpIdForConnection || !showSaveDialog) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
@@ -462,7 +466,6 @@ export const useSftpViewFileOps = ({
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
|
||||
if (!selectedDirectory) return;
|
||||
|
||||
@@ -475,7 +478,8 @@ export const useSftpViewFileOps = ({
|
||||
if (!selectedSnapshot.isDirectory) {
|
||||
throw new Error("Remote source changed while choosing the download target");
|
||||
}
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const targetPath = joinTransferTargetPath(selectedDirectory, file.name);
|
||||
|
||||
|
||||
try {
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
@@ -505,7 +509,20 @@ export const useSftpViewFileOps = ({
|
||||
|
||||
return;
|
||||
}
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
// Only an explicit opt-in and this exact remote source may reuse the
|
||||
// full path chosen earlier in Save As, including a renamed basename.
|
||||
const quickDownloadEnabled = readSftpQuickDownloadEnabled();
|
||||
const endpointKey = await sftpRef.current.getDownloadEndpointKey?.(pane.connection.id) ?? null;
|
||||
if (!quickDownloadEnabled) {
|
||||
quickDownloadTargets.forget(endpointKey, resolvedFullPath, pane.filenameEncoding);
|
||||
}
|
||||
const rememberedTarget = quickDownloadEnabled
|
||||
? await quickDownloadTargets.findValidTarget(
|
||||
endpointKey, resolvedFullPath, sftpId, pane.filenameEncoding,
|
||||
)
|
||||
: null;
|
||||
const targetPath = rememberedTarget?.targetPath ?? await showSaveDialog(file.name);
|
||||
|
||||
if (!targetPath) return;
|
||||
|
||||
const selectedSnapshot = await resolveDownloadSourceSnapshot(
|
||||
@@ -522,6 +539,32 @@ export const useSftpViewFileOps = ({
|
||||
fileName: file.name,
|
||||
sourcePath: resolvedFullPath,
|
||||
targetPath,
|
||||
expectedLocalTarget: rememberedTarget ? {
|
||||
parentRealPath: rememberedTarget.parentRealPath,
|
||||
parentIdentity: rememberedTarget.parentIdentity,
|
||||
parentBirthtimeNs: rememberedTarget.parentBirthtimeNs,
|
||||
targetIdentity: rememberedTarget.targetIdentity,
|
||||
targetBirthtimeNs: rememberedTarget.targetBirthtimeNs,
|
||||
targetCtimeNs: rememberedTarget.targetCtimeNs,
|
||||
targetMtimeNs: rememberedTarget.targetMtimeNs,
|
||||
targetSha256: rememberedTarget.targetSha256,
|
||||
} : undefined,
|
||||
expectedSourceEndpointKey: quickDownloadEnabled ? endpointKey ?? undefined : undefined,
|
||||
// Remember at publication time, not after downloadToLocal returns:
|
||||
// the generic retry re-runs this exact callback through a fresh task
|
||||
// lifecycle, and a successful retry must refresh the remembered
|
||||
// inode/hash too (Codex P2 on PR #3516).
|
||||
onPublishedLocalFile: quickDownloadEnabled
|
||||
? (identity) =>
|
||||
// Return the registration promise so completion is reported
|
||||
// only after the remembered target is usable again; a
|
||||
// fire-and-forget callback let an immediate repeat download
|
||||
// find no remembered target and open Save As (Codex P2 on
|
||||
// PR #3516).
|
||||
quickDownloadTargets.remember(
|
||||
endpointKey, resolvedFullPath, pane.filenameEncoding, targetPath, identity,
|
||||
)
|
||||
: undefined,
|
||||
sftpId,
|
||||
connectionId: pane.connection.id,
|
||||
sourceHostId: pane.connection.hostId,
|
||||
@@ -531,6 +574,10 @@ export const useSftpViewFileOps = ({
|
||||
totalBytes: selectedSnapshot.size,
|
||||
});
|
||||
if (status === "completed") {
|
||||
if (quickDownloadEnabled && !readSftpQuickDownloadEnabled()) {
|
||||
// Quick download was disabled while this transfer ran.
|
||||
quickDownloadTargets.forget(endpointKey, resolvedFullPath, pane.filenameEncoding);
|
||||
}
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} else if (status === "failed") {
|
||||
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
|
||||
@@ -550,8 +597,10 @@ export const useSftpViewFileOps = ({
|
||||
showSaveDialog,
|
||||
selectDirectory,
|
||||
getSftpIdForConnection,
|
||||
quickDownloadTargets,
|
||||
statSftp,
|
||||
listSftp,
|
||||
|
||||
],
|
||||
);
|
||||
|
||||
@@ -638,7 +687,7 @@ export const useSftpViewFileOps = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectDirectory || !getSftpIdForConnection) {
|
||||
if (!getSftpIdForConnection || !selectDirectory) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
@@ -661,7 +710,7 @@ export const useSftpViewFileOps = ({
|
||||
async (file, index) => {
|
||||
try {
|
||||
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
const targetPath = joinFsPath(selectedDirectory, file.name);
|
||||
const targetPath = joinTransferTargetPath(selectedDirectory, file.name);
|
||||
// Re-stat each root: the listed entries can be stale after
|
||||
// terminal-side delete + recreate without a refresh.
|
||||
const sourceSnapshot = await resolveDownloadSourceSnapshot(
|
||||
@@ -673,6 +722,7 @@ export const useSftpViewFileOps = ({
|
||||
);
|
||||
const isDirectory = sourceSnapshot.isDirectory;
|
||||
|
||||
|
||||
const status = await sftpRef.current.downloadToLocal({
|
||||
fileName: file.name,
|
||||
sourcePath,
|
||||
|
||||
@@ -16,7 +16,9 @@ const file = (
|
||||
test("single and batch downloads route by current remote type and size", async () => {
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null };
|
||||
|
||||
const current = new Map<string, SftpStatResult>([
|
||||
["/remote/smaller", { name: "smaller", size: 50_000, sizeKnown: true, type: "file", lastModified: 0 }],
|
||||
@@ -121,5 +123,6 @@ test("single and batch downloads route by current remote type and size", async (
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
}
|
||||
});
|
||||
@@ -47,6 +47,28 @@ export type TransferOrigin = 'manual' | 'drag-drop' | 'editor-sync' | 'agent' |
|
||||
export type TransferPhase = 'scanning' | 'compressing' | 'uploading' | 'transferring' | 'extracting' | 'verifying';
|
||||
export type TransferControlKind = 'stream' | 'compressed-upload';
|
||||
|
||||
/** Original local file selected for an opt-in repeat download. */
|
||||
export interface LocalDownloadTargetExpectation {
|
||||
parentRealPath: string;
|
||||
parentIdentity: string;
|
||||
parentBirthtimeNs: string;
|
||||
targetIdentity: string;
|
||||
targetBirthtimeNs: string;
|
||||
targetCtimeNs: string;
|
||||
targetMtimeNs: string;
|
||||
targetSha256: string;
|
||||
}
|
||||
|
||||
/** Identity of the file actually published by a completed local download. */
|
||||
export interface LocalPublishedFileIdentity {
|
||||
dev: string;
|
||||
ino: string;
|
||||
birthtimeNs: string;
|
||||
ctimeNs: string;
|
||||
mtimeNs: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface DirectoryResumeCheckpoint {
|
||||
/** Version 1 used a full SHA-256 digest for every appended entry. Version 2
|
||||
* keeps the SHA-256 compression state so adding another fixed-width identity
|
||||
@@ -67,6 +89,12 @@ export interface TransferTask {
|
||||
originalFileName?: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
expectedLocalTarget?: LocalDownloadTargetExpectation;
|
||||
/** A remembered download target must not reconnect through an unverified route. */
|
||||
requireOriginalSourceForResume?: boolean;
|
||||
/** Connect-time route key (proxy/jump) that identified the download source.
|
||||
* Persisted so a retry can prove the bytes still come from the same route. */
|
||||
expectedSourceEndpointKey?: string;
|
||||
sourceConnectionId: string;
|
||||
targetConnectionId: string;
|
||||
targetHostId?: string;
|
||||
|
||||
@@ -123,6 +123,54 @@ test("restoring a paused task also marks it interrupted after restart", () => {
|
||||
assert.equal(restored.tasks[0]?.lifecycleEpoch, undefined);
|
||||
});
|
||||
|
||||
test("remembered local target identity survives transfer persistence", () => {
|
||||
const original = {
|
||||
...task("guarded-download", "transferring", 1),
|
||||
direction: "download" as const,
|
||||
expectedLocalTarget: {
|
||||
parentRealPath: "/target",
|
||||
parentIdentity: "1:2",
|
||||
parentBirthtimeNs: "100",
|
||||
targetIdentity: "1:3",
|
||||
targetBirthtimeNs: "200",
|
||||
targetCtimeNs: "201",
|
||||
targetMtimeNs: "202",
|
||||
targetSha256: "a".repeat(64),
|
||||
},
|
||||
};
|
||||
const restored = deserializeSftpTransferCenter(serializeSftpTransferCenter([original]));
|
||||
assert.equal(restored.tasks[0]?.status, "interrupted");
|
||||
assert.deepEqual(restored.tasks[0]?.expectedLocalTarget, original.expectedLocalTarget);
|
||||
assert.equal(restored.tasks[0]?.requireOriginalSourceForResume, true);
|
||||
});
|
||||
|
||||
test("malformed saved target identity fails closed", () => {
|
||||
const restored = deserializeSftpTransferCenter(JSON.stringify({
|
||||
version: 1,
|
||||
tasks: [{ ...task("guarded-download", "paused", 1), expectedLocalTarget: { targetIdentity: "1:3" } }],
|
||||
}));
|
||||
assert.deepEqual(restored.tasks[0]?.expectedLocalTarget, {
|
||||
parentRealPath: "", parentIdentity: "", parentBirthtimeNs: "",
|
||||
targetIdentity: "", targetBirthtimeNs: "", targetCtimeNs: "", targetMtimeNs: "", targetSha256: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("connect-time source route key survives persistence and drops corrupt values", () => {
|
||||
const original = {
|
||||
...task("routed-download", "transferring", 1),
|
||||
direction: "download" as const,
|
||||
expectedSourceEndpointKey: "route-key-1",
|
||||
};
|
||||
const restored = deserializeSftpTransferCenter(serializeSftpTransferCenter([original]));
|
||||
assert.equal(restored.tasks[0]?.expectedSourceEndpointKey, "route-key-1");
|
||||
|
||||
const corrupt = deserializeSftpTransferCenter(JSON.stringify({
|
||||
version: 1,
|
||||
tasks: [{ ...task("routed-download", "paused", 1), expectedSourceEndpointKey: 42 }],
|
||||
}));
|
||||
assert.equal(corrupt.tasks[0]?.expectedSourceEndpointKey, undefined);
|
||||
});
|
||||
|
||||
test("restoring terminal tasks strips stale conflict payloads (skip-without-clear legacy)", () => {
|
||||
const conflict = {
|
||||
transferId: "css-dir",
|
||||
|
||||
@@ -46,6 +46,9 @@ const SAFE_TASK_KEYS: ReadonlySet<keyof TransferTask> = new Set([
|
||||
"directoryEntryIndex",
|
||||
"directoryEntryIdentity",
|
||||
"directoryResumeCheckpoint",
|
||||
"expectedLocalTarget",
|
||||
"requireOriginalSourceForResume",
|
||||
"expectedSourceEndpointKey",
|
||||
]);
|
||||
|
||||
export function sanitizeSftpTransferTask(value: unknown): TransferTask | null {
|
||||
@@ -58,6 +61,39 @@ export function sanitizeSftpTransferTask(value: unknown): TransferTask | null {
|
||||
if (source[key] !== undefined) sanitized[key] = source[key];
|
||||
}
|
||||
const task = sanitized as unknown as TransferTask;
|
||||
if (source.expectedLocalTarget !== undefined) {
|
||||
const expected = source.expectedLocalTarget as Record<string, unknown> | null;
|
||||
if (!expected || typeof expected.parentRealPath !== "string" || !expected.parentRealPath
|
||||
|| typeof expected.parentIdentity !== "string" || !/^\d+:\d+$/.test(expected.parentIdentity)
|
||||
|| typeof expected.parentBirthtimeNs !== "string" || !/^[1-9]\d*$/.test(expected.parentBirthtimeNs)
|
||||
|| typeof expected.targetIdentity !== "string" || !/^\d+:\d+$/.test(expected.targetIdentity)
|
||||
|| typeof expected.targetBirthtimeNs !== "string" || !/^[1-9]\d*$/.test(expected.targetBirthtimeNs)
|
||||
|| typeof expected.targetCtimeNs !== "string" || !/^[1-9]\d*$/.test(expected.targetCtimeNs)
|
||||
|| typeof expected.targetMtimeNs !== "string" || !/^[1-9]\d*$/.test(expected.targetMtimeNs)
|
||||
|| typeof expected.targetSha256 !== "string" || !/^[a-f0-9]{64}$/.test(expected.targetSha256)) {
|
||||
// An incomplete saved guard must never become an unguarded overwrite.
|
||||
task.expectedLocalTarget = {
|
||||
parentRealPath: "", parentIdentity: "", parentBirthtimeNs: "",
|
||||
targetIdentity: "", targetBirthtimeNs: "", targetCtimeNs: "", targetMtimeNs: "", targetSha256: "",
|
||||
};
|
||||
} else {
|
||||
task.expectedLocalTarget = {
|
||||
parentRealPath: expected.parentRealPath,
|
||||
parentIdentity: expected.parentIdentity,
|
||||
parentBirthtimeNs: expected.parentBirthtimeNs,
|
||||
targetIdentity: expected.targetIdentity,
|
||||
targetBirthtimeNs: expected.targetBirthtimeNs,
|
||||
targetCtimeNs: expected.targetCtimeNs,
|
||||
targetMtimeNs: expected.targetMtimeNs,
|
||||
targetSha256: expected.targetSha256,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (task.expectedLocalTarget) task.requireOriginalSourceForResume = true;
|
||||
// A corrupt or empty route key must not pass as a verified route later.
|
||||
if (typeof task.expectedSourceEndpointKey !== "string" || !task.expectedSourceEndpointKey) {
|
||||
task.expectedSourceEndpointKey = undefined;
|
||||
}
|
||||
if (!Number.isSafeInteger(task.directoryEntryIndex) || (task.directoryEntryIndex ?? -1) < 0) {
|
||||
task.directoryEntryIndex = undefined;
|
||||
}
|
||||
|
||||
@@ -328,6 +328,7 @@ export interface SyncPayload {
|
||||
sftpShowHiddenFiles?: boolean;
|
||||
sftpUseCompressedUpload?: boolean;
|
||||
sftpSkipUnchanged?: boolean;
|
||||
sftpQuickDownload?: boolean;
|
||||
sftpAutoOpenSidebar?: boolean;
|
||||
sftpFollowTerminalCwd?: boolean;
|
||||
sftpDefaultViewMode?: 'list' | 'tree';
|
||||
|
||||
@@ -13,8 +13,12 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
|
||||
try {
|
||||
await fs.promises.link(source, target);
|
||||
// The hardlink shares the published inode; stat either name.
|
||||
const linkedStat = await fs.promises.lstat(source);
|
||||
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
|
||||
const linkedStat = await fs.promises.lstat(source, { bigint: true });
|
||||
return {
|
||||
dev: String(linkedStat.dev), ino: String(linkedStat.ino), size: Number(linkedStat.size),
|
||||
birthtimeNs: String(linkedStat.birthtimeNs),
|
||||
ctimeNs: String(linkedStat.ctimeNs), mtimeNs: String(linkedStat.mtimeNs),
|
||||
};
|
||||
} catch (error) {
|
||||
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
|
||||
// when the volume has no hard-link support (exFAT/FAT32); the source here
|
||||
@@ -51,12 +55,16 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
|
||||
// through the owned handle rather than a potentially replaced pathname.
|
||||
await output.chmod(stat.mode & 0o7777);
|
||||
await output.utimes(stat.atime, stat.mtime);
|
||||
const ownedStat = await output.stat();
|
||||
const targetStat = await fs.promises.lstat(target);
|
||||
const ownedStat = await output.stat({ bigint: true });
|
||||
const targetStat = await fs.promises.lstat(target, { bigint: true });
|
||||
if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) {
|
||||
throw new Error("Local download target changed during replacement");
|
||||
}
|
||||
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
|
||||
publishedIdentity = {
|
||||
dev: String(ownedStat.dev), ino: String(ownedStat.ino), size: Number(ownedStat.size),
|
||||
birthtimeNs: String(ownedStat.birthtimeNs),
|
||||
ctimeNs: String(ownedStat.ctimeNs), mtimeNs: String(ownedStat.mtimeNs),
|
||||
};
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
|
||||
@@ -30,12 +30,15 @@ test("publishLocalFileExclusive falls back to copy when hardlink fails with EISD
|
||||
});
|
||||
try {
|
||||
const identity = await publishLocalFileExclusive(source, target);
|
||||
const stat = fs.lstatSync(target);
|
||||
const stat = fs.lstatSync(target, { bigint: true });
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "hello hardlink fallback");
|
||||
assert.equal(stat.isFile(), true);
|
||||
assert.equal(identity.dev, stat.dev);
|
||||
assert.equal(identity.ino, stat.ino);
|
||||
assert.equal(identity.size, stat.size);
|
||||
assert.equal(identity.dev, String(stat.dev));
|
||||
assert.equal(identity.ino, String(stat.ino));
|
||||
assert.equal(identity.size, Number(stat.size));
|
||||
assert.equal(identity.birthtimeNs, String(stat.birthtimeNs));
|
||||
assert.equal(identity.ctimeNs, String(stat.ctimeNs));
|
||||
assert.equal(identity.mtimeNs, String(stat.mtimeNs));
|
||||
// The prepared source still exists for the caller to unlink.
|
||||
assert.equal(fs.existsSync(source), true);
|
||||
} finally {
|
||||
@@ -53,9 +56,15 @@ test("publishLocalFileExclusive still hardlinks on volumes that support it", asy
|
||||
const target = path.join(dir, "target");
|
||||
fs.writeFileSync(source, "hardlinked publish");
|
||||
const identity = await publishLocalFileExclusive(source, target);
|
||||
const [sourceStat, targetStat] = [fs.lstatSync(source), fs.lstatSync(target)];
|
||||
const [sourceStat, targetStat] = [
|
||||
fs.lstatSync(source, { bigint: true }), fs.lstatSync(target, { bigint: true }),
|
||||
];
|
||||
assert.equal(sourceStat.ino, targetStat.ino);
|
||||
assert.deepEqual(identity, { dev: targetStat.dev, ino: targetStat.ino, size: targetStat.size });
|
||||
assert.deepEqual(identity, {
|
||||
dev: String(targetStat.dev), ino: String(targetStat.ino), size: Number(targetStat.size),
|
||||
birthtimeNs: String(targetStat.birthtimeNs),
|
||||
ctimeNs: String(targetStat.ctimeNs), mtimeNs: String(targetStat.mtimeNs),
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -328,16 +328,23 @@ async function mkdirLocal(event, payload) {
|
||||
* Resume and upload sizing rely on target bytes, not the link node.
|
||||
*/
|
||||
async function statLocal(event, payload) {
|
||||
const stat = await fs.promises.stat(payload.path);
|
||||
const stat = await fs.promises.stat(payload.path, { bigint: true });
|
||||
const hasStableIdentity = stat.dev > 0n && stat.ino > 0n
|
||||
&& stat.birthtimeNs > 0n && stat.ctimeNs > 0n && stat.mtimeNs > 0n;
|
||||
return {
|
||||
name: path.basename(payload.path),
|
||||
type: stat.isDirectory() ? "directory" : "file",
|
||||
size: stat.size,
|
||||
lastModified: stat.mtime.getTime(),
|
||||
// Filesystem identity for same-pane paste guards: realpath cannot see
|
||||
// through bind mounts, but dev/ino name the same directory regardless of
|
||||
// the mount path used. Windows dev/ino are unreliable, so omit them there.
|
||||
...(process.platform === "win32" ? {} : { dev: stat.dev, ino: stat.ino }),
|
||||
size: Number(stat.size),
|
||||
lastModified: Math.round(Number(stat.mtimeNs) / 1e6),
|
||||
// Windows libuv reports the volume serial and file ID as dev/ino. Expose
|
||||
// them only when the filesystem supplies nonzero identity and timestamps;
|
||||
// unsupported volumes keep opening Save As for remembered downloads.
|
||||
...(hasStableIdentity ? {
|
||||
dev: String(stat.dev), ino: String(stat.ino),
|
||||
birthtimeNs: String(stat.birthtimeNs),
|
||||
ctimeNs: String(stat.ctimeNs),
|
||||
mtimeNs: String(stat.mtimeNs),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,14 +354,21 @@ async function statLocal(event, payload) {
|
||||
* writing through it via writeLocalFile.
|
||||
*/
|
||||
async function lstatLocal(event, payload) {
|
||||
const stat = await fs.promises.lstat(payload.path);
|
||||
const stat = await fs.promises.lstat(payload.path, { bigint: true });
|
||||
const hasStableIdentity = stat.dev > 0n && stat.ino > 0n
|
||||
&& stat.birthtimeNs > 0n && stat.ctimeNs > 0n && stat.mtimeNs > 0n;
|
||||
return {
|
||||
name: path.basename(payload.path),
|
||||
type: stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "file",
|
||||
size: stat.size,
|
||||
lastModified: stat.mtime.getTime(),
|
||||
size: Number(stat.size),
|
||||
lastModified: Math.round(Number(stat.mtimeNs) / 1e6),
|
||||
// Mirror statLocal so guards comparing identities work with either stat.
|
||||
...(process.platform === "win32" ? {} : { dev: stat.dev, ino: stat.ino }),
|
||||
...(hasStableIdentity ? {
|
||||
dev: String(stat.dev), ino: String(stat.ino),
|
||||
birthtimeNs: String(stat.birthtimeNs),
|
||||
ctimeNs: String(stat.ctimeNs),
|
||||
mtimeNs: String(stat.mtimeNs),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const managedTemp = require("./tempDirBridge.cjs");
|
||||
|
||||
const {
|
||||
collectLocalTreeEntries,
|
||||
@@ -17,6 +18,28 @@ const {
|
||||
deleteLocalFile,
|
||||
} = require("./localFsBridge.cjs");
|
||||
|
||||
test("Windows local stats distinguish a replaced file", { skip: process.platform !== "win32" }, async (t) => {
|
||||
const root = fs.mkdtempSync(`${managedTemp.getTempFilePath("windows-file-identity")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "selected.txt");
|
||||
const replacement = path.join(root, "replacement.txt");
|
||||
fs.writeFileSync(target, "original");
|
||||
const first = await lstatLocal(null, { path: target });
|
||||
const followed = await statLocal(null, { path: target });
|
||||
const parent = await statLocal(null, { path: root });
|
||||
for (const result of [first, followed, parent]) {
|
||||
for (const key of ["dev", "ino", "birthtimeNs", "ctimeNs", "mtimeNs"]) {
|
||||
assert.match(result[key] ?? "", /^[1-9]\d*$/, `${key} must identify the actual Windows file`);
|
||||
}
|
||||
}
|
||||
assert.equal(`${first.dev}:${first.ino}`, `${followed.dev}:${followed.ino}`);
|
||||
fs.writeFileSync(replacement, "different");
|
||||
fs.renameSync(replacement, target);
|
||||
const changed = await lstatLocal(null, { path: target });
|
||||
assert.notEqual(`${changed.dev}:${changed.ino}:${changed.birthtimeNs}`,
|
||||
`${first.dev}:${first.ino}:${first.birthtimeNs}`);
|
||||
});
|
||||
|
||||
test("local tree traversal defaults match the remote traversal safety budget", () => {
|
||||
assert.equal(MAX_LOCAL_TREE_DIRECTORIES, 50_000);
|
||||
assert.equal(MAX_LOCAL_TREE_ENTRIES, 200_000);
|
||||
@@ -514,6 +537,8 @@ test("statLocal follows symlinks and reports target size for resume sizing", asy
|
||||
assert.equal(result.type, "file");
|
||||
assert.equal(result.size, targetStat.size);
|
||||
assert.equal(result.lastModified, targetStat.mtime.getTime());
|
||||
assert.equal(result.dev, String(targetStat.dev));
|
||||
assert.equal(result.ino, String(targetStat.ino));
|
||||
} finally {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -541,6 +566,8 @@ test("lstatLocal classifies symlinks without following the target", async (t) =>
|
||||
assert.equal(result.type, "symlink");
|
||||
assert.equal(result.size, linkStat.size);
|
||||
assert.equal(result.lastModified, linkStat.mtime.getTime());
|
||||
assert.equal(result.dev, String(linkStat.dev));
|
||||
assert.equal(result.ino, String(linkStat.ino));
|
||||
} finally {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1037,17 +1037,379 @@ function stableLocalFileIdentity(statLike) {
|
||||
return [statLike.dev, statLike.ino, statLike.size].join(":");
|
||||
}
|
||||
|
||||
async function hashOpenLocalFile(handle, assertNotCancelled = () => {}) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
let position = 0;
|
||||
for (;;) {
|
||||
assertNotCancelled();
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
||||
if (!bytesRead) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
// Creates the small marker that proves a recovery backup beside the
|
||||
// destination was created by this app, so later repeat downloads can reuse
|
||||
// the fixed backup name without touching unrelated files. The marker is
|
||||
// created exclusively and never follows an existing entry: a foreign file
|
||||
// or symlink at the predictable marker pathname must be refused, not
|
||||
// truncated through or overwritten (Codex P2 on PR #3516).
|
||||
async function writeLocalBackupOwnerMarkerContent(markerPath, content) {
|
||||
const exclusiveFlags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL
|
||||
| (fs.constants.O_NOFOLLOW || 0);
|
||||
const handle = await fs.promises.open(markerPath, exclusiveFlags, 0o600);
|
||||
let failure = null;
|
||||
try {
|
||||
await handle.writeFile(content, "utf8");
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
// Pin the opened inode before dropping the handle: a full destination
|
||||
// volume can leave an empty or partially written marker behind, and
|
||||
// orphan recovery refuses malformed JSON, so every later repeat download
|
||||
// would fail against the permanently unrecognized marker until the user
|
||||
// removes the hidden file by hand (Codex P2 on PR #3516). Remove the
|
||||
// marker we exclusively created, bound to the opened inode so a
|
||||
// concurrent replacement at the pathname is never destroyed.
|
||||
let ownedStat = null;
|
||||
try {
|
||||
ownedStat = await handle.stat({ bigint: true });
|
||||
} catch (error) {
|
||||
failure ??= error;
|
||||
}
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
failure ??= error;
|
||||
}
|
||||
if (failure && ownedStat) {
|
||||
try {
|
||||
await removeLocalPathnameBoundToIdentity(markerPath, [{
|
||||
dev: String(ownedStat.dev), ino: String(ownedStat.ino),
|
||||
birthtimeNs: String(ownedStat.birthtimeNs),
|
||||
}]);
|
||||
} catch (cleanupError) {
|
||||
failure = new Error(`${failure.message}. ${cleanupError.message}`, { cause: failure });
|
||||
}
|
||||
}
|
||||
if (failure) throw failure;
|
||||
return ownedStat;
|
||||
}
|
||||
|
||||
async function writeLocalBackupOwnerMarker(markerPath, stat) {
|
||||
const payload = JSON.stringify({
|
||||
identity: `${stat.dev}:${stat.ino}`,
|
||||
birthtimeNs: String(stat.birthtimeNs),
|
||||
});
|
||||
try {
|
||||
return await writeLocalBackupOwnerMarkerContent(markerPath, payload);
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST" && error?.code !== "ELOOP") throw error;
|
||||
// An identical marker is already in place; leave it untouched.
|
||||
if (await localBackupOwnerMarkerMatches(markerPath, stat)) return null;
|
||||
throw new Error(
|
||||
`An unrecognized file already exists at the recovery backup marker location ${markerPath}; `
|
||||
+ "refusing to overwrite it. Remove or rename that file if it was not created by Netcatty.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function localBackupOwnerMarkerMatches(markerPath, stat) {
|
||||
let raw;
|
||||
try {
|
||||
raw = await fs.promises.readFile(markerPath, "utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const marker = JSON.parse(raw);
|
||||
return marker?.identity === `${stat.dev}:${stat.ino}`
|
||||
&& marker?.birthtimeNs === String(stat.birthtimeNs);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// An interrupted rollover can leave the owner marker beside the destination
|
||||
// after its backup has already been renamed away (the process exits between
|
||||
// the rollover rename and the marker unlink). The next repeat download sees
|
||||
// no backup at the fixed name, skips rollover entirely, and the fresh
|
||||
// exclusive marker write then fails against the stale marker, rolls the
|
||||
// replacement back, and every subsequent repeat keeps failing the same way
|
||||
// until the hidden marker is removed by hand (Codex P2 on PR #3516). Clear
|
||||
// the orphan only when it still reads as one of our marker payloads, so a
|
||||
// foreign file or symlink at the predictable pathname is never touched and
|
||||
// the later marker write keeps failing closed on it.
|
||||
async function removeOrphanedLocalBackupOwnerMarker(markerPath) {
|
||||
let stat = null;
|
||||
try {
|
||||
stat = await fs.promises.lstat(markerPath, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
if (!stat.isFile()) return;
|
||||
let raw = null;
|
||||
try {
|
||||
raw = await fs.promises.readFile(markerPath, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
let marker = null;
|
||||
try {
|
||||
marker = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const identity = marker?.identity;
|
||||
const birthtimeNs = marker?.birthtimeNs;
|
||||
if (typeof identity !== "string" || !/^\d+:\d+$/.test(identity)
|
||||
|| typeof birthtimeNs !== "string" || !/^\d+$/.test(birthtimeNs)) return;
|
||||
// Bind the deletion to the inspected inode: another process may replace
|
||||
// the pathname between the read above and the unlink, and the contents
|
||||
// validated earlier say nothing about that replacement (Codex P2 on PR
|
||||
// #3516). Fail closed — a changed pathname leaves the late entry in place
|
||||
// and the exclusive marker write below keeps refusing it.
|
||||
let currentStat = null;
|
||||
try {
|
||||
currentStat = await fs.promises.lstat(markerPath, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
if (`${currentStat.dev}:${currentStat.ino}` !== `${stat.dev}:${stat.ino}`
|
||||
|| String(currentStat.birthtimeNs) !== String(stat.birthtimeNs)
|
||||
|| currentStat.ctimeNs !== stat.ctimeNs
|
||||
|| currentStat.mtimeNs !== stat.mtimeNs) return;
|
||||
// The pathname can still be replaced after this stat. Move it aside first,
|
||||
// then remove only the inode whose marker payload was inspected above.
|
||||
await removeLocalPathnameBoundToIdentity(markerPath, [{
|
||||
dev: String(stat.dev), ino: String(stat.ino),
|
||||
birthtimeNs: String(stat.birthtimeNs),
|
||||
}]);
|
||||
}
|
||||
|
||||
// Atomically remove a pathname only while it still names one of the given
|
||||
// inodes (Codex P1/P2 on PR #3516). A stat immediately before unlink() is
|
||||
// still a separate syscall, so a concurrent atomic save can replace the
|
||||
// pathname inside that gap and a blind unlink would then delete the
|
||||
// replacement's only link. rename() to a unique private name moves whatever
|
||||
// entry currently sits at the pathname without ever destroying it; the moved
|
||||
// entry is then unlinked through its now unshared name only when it matches
|
||||
// one of the expected identities, and otherwise restored to its original
|
||||
// pathname through an exclusively created hardlink (which fails closed on an
|
||||
// occupied destination) before the private name is dropped. No step can
|
||||
// destroy an entry this process did not pin. Resolves with the moved entry's
|
||||
// identity when the pathname was vacated, or null when a foreign replacement
|
||||
// was moved aside and given its name back.
|
||||
async function removeLocalPathnameBoundToIdentity(pathname, expectedIdentities, rehomeOnCollision = true) {
|
||||
const privatePath = `${pathname}.${crypto.randomUUID().replace(/-/g, "")}.retiring`;
|
||||
try {
|
||||
await fs.promises.rename(pathname, privatePath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return { vacated: true, moved: null };
|
||||
throw error;
|
||||
}
|
||||
const movedStat = await fs.promises.lstat(privatePath, { bigint: true }).catch(() => null);
|
||||
if (!movedStat) return { vacated: true, moved: null };
|
||||
const movedIdentity = {
|
||||
dev: String(movedStat.dev), ino: String(movedStat.ino),
|
||||
birthtimeNs: String(movedStat.birthtimeNs),
|
||||
};
|
||||
const matches = expectedIdentities.some((identity) => identity
|
||||
&& `${movedIdentity.dev}:${movedIdentity.ino}` === `${identity.dev}:${identity.ino}`
|
||||
&& movedIdentity.birthtimeNs === String(identity.birthtimeNs));
|
||||
if (matches) {
|
||||
// The private name is unique to this call, so no other process can race
|
||||
// a replacement in between this stat and the unlink.
|
||||
await fs.promises.unlink(privatePath).catch(() => {});
|
||||
return { vacated: true, moved: movedIdentity };
|
||||
}
|
||||
// A concurrent replacement got moved aside: give it its original pathname
|
||||
// back through a link, which refuses an occupied destination, and only
|
||||
// drop the private name once the restore is in place. If the pathname is
|
||||
// occupied again, the moved entry stays reachable under the private name.
|
||||
try {
|
||||
await fs.promises.link(privatePath, pathname);
|
||||
} catch {
|
||||
if (!rehomeOnCollision) {
|
||||
return { vacated: false, moved: movedIdentity, quarantinedAt: privatePath };
|
||||
}
|
||||
// The original name is occupied again. Preserve the displaced entry in
|
||||
// Netcatty's managed temp directory when this volume allows a hard link.
|
||||
// A cross-volume copy would lose edits arriving through an open handle,
|
||||
// so leave the private name in place and report it instead.
|
||||
let recoveryPath = privatePath;
|
||||
const managedPath = tempDirBridge.getTempFilePath(
|
||||
`quarantined-${path.basename(pathname)}-${crypto.randomUUID()}`,
|
||||
);
|
||||
try {
|
||||
await fs.promises.link(privatePath, managedPath);
|
||||
const managedStat = await fs.promises.lstat(managedPath, { bigint: true });
|
||||
if (`${managedStat.dev}:${managedStat.ino}` !== `${movedIdentity.dev}:${movedIdentity.ino}`
|
||||
|| String(managedStat.birthtimeNs) !== movedIdentity.birthtimeNs) {
|
||||
throw new Error("Quarantined file changed before retention");
|
||||
}
|
||||
recoveryPath = managedPath;
|
||||
await removeLocalPathnameBoundToIdentity(privatePath, [movedIdentity], false);
|
||||
} catch {
|
||||
// The private name is still a reachable recovery location.
|
||||
}
|
||||
const recovery = new Error(
|
||||
`A concurrent local file could not be returned to ${pathname}. Recovery file: ${recoveryPath}`,
|
||||
);
|
||||
recovery.recoveryPath = recoveryPath;
|
||||
throw recovery;
|
||||
}
|
||||
await fs.promises.unlink(privatePath).catch(() => {});
|
||||
return { vacated: false, moved: movedIdentity };
|
||||
}
|
||||
|
||||
async function retireLocalBackupOwnerMarker(markerPath, markerStat) {
|
||||
if (!markerStat) throw new Error("Recovery backup owner marker could not be verified");
|
||||
const result = await removeLocalPathnameBoundToIdentity(markerPath, [{
|
||||
dev: String(markerStat.dev), ino: String(markerStat.ino),
|
||||
birthtimeNs: String(markerStat.birthtimeNs),
|
||||
}]);
|
||||
if (!result.vacated) {
|
||||
throw new Error(`Recovery backup owner marker changed during cleanup: ${markerPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Move the original target to its recovery-backup name without ever
|
||||
// replacing an entry that appears at that pathname. POSIX rename would
|
||||
// silently replace a file another process creates at the fixed backup
|
||||
// pathname after the existing-backup check above but before the move,
|
||||
// destroying the late arrival's only link (Codex P2 on PR #3516). link()
|
||||
// refuses an occupied destination, and the source name is dropped only
|
||||
// after the backup name is in place, so a late arrival can never be
|
||||
// replaced. Filesystems without hard-link support get no replacing-rename
|
||||
// fallback: a rename over the occupied backup pathname would destroy a
|
||||
// concurrent arrival's only link, and copy-based publication cannot
|
||||
// preserve the inode identity the caller verifies for the retained backup
|
||||
// (Codex P1 on PR #3516). Fail closed instead — the untouched target and
|
||||
// the remembered identity stay valid, so the transfer fails without
|
||||
// losing either file.
|
||||
async function publishLocalBackupExclusive(source, target) {
|
||||
const sourceStat = await fs.promises.lstat(source, { bigint: true });
|
||||
try {
|
||||
await fs.promises.link(source, target);
|
||||
} catch (error) {
|
||||
if (["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV"].includes(error?.code)) {
|
||||
throw new Error(
|
||||
`The recovery backup at ${target} cannot be published exclusively: `
|
||||
+ "this filesystem does not support hard links, so the previous file "
|
||||
+ "cannot be moved aside without risking the loss of a file another "
|
||||
+ "process writes at that pathname. The download was refused; the "
|
||||
+ "existing local file was left untouched.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
// An editor can atomically save a new version by renaming it over the
|
||||
// source pathname after the link above succeeds but before the source
|
||||
// name is dropped. The backup still references the expected old inode,
|
||||
// so the later identity checks pass, while a blind unlink would remove
|
||||
// the editor's newly installed file and silently lose the local edit
|
||||
// (Codex P1 on PR #3516). POSIX offers no unlink primitive bound to an
|
||||
// inode, and even a re-stat immediately before unlinking leaves a
|
||||
// check-to-unlink gap, so vacate the source name atomically instead:
|
||||
// rename moves whatever sits at the source pathname (the linked inode or
|
||||
// an editor's replacement) without destroying it, and the pinned inode
|
||||
// is only unlinked through its private, unshared name. A replacement is
|
||||
// given its name back: the exclusive publication of the prepared file
|
||||
// then fails closed on the occupied destination, and the backup at the
|
||||
// target pathname stays reachable for recovery.
|
||||
await removeLocalPathnameBoundToIdentity(source, [{
|
||||
dev: String(sourceStat.dev), ino: String(sourceStat.ino),
|
||||
birthtimeNs: String(sourceStat.birthtimeNs),
|
||||
}]);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return;
|
||||
// Undo the link we created so the caller's state matches its backedUp
|
||||
// flag. A stat followed by unlink can delete a late replacement at the
|
||||
// fixed backup pathname; move it aside and compare the moved inode.
|
||||
try {
|
||||
await removeLocalPathnameBoundToIdentity(target, [{
|
||||
dev: String(sourceStat.dev), ino: String(sourceStat.ino),
|
||||
birthtimeNs: String(sourceStat.birthtimeNs),
|
||||
}]);
|
||||
} catch (cleanupError) {
|
||||
throw new Error(`${error.message}. ${cleanupError.message}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertExpectedLocalDownloadTarget(requestedPath, expected, inspectedTarget) {
|
||||
if (!expected) return;
|
||||
const validIdentity = (value) => typeof value === "string" && /^\d+:\d+$/.test(value);
|
||||
const validTimestamp = (value) => typeof value === "string" && /^[1-9]\d*$/.test(value);
|
||||
if (!validIdentity(expected.targetIdentity) || !validIdentity(expected.parentIdentity)
|
||||
|| !validTimestamp(expected.targetBirthtimeNs) || !validTimestamp(expected.targetCtimeNs)
|
||||
|| !validTimestamp(expected.targetMtimeNs)
|
||||
|| typeof expected.targetSha256 !== "string" || !/^[a-f0-9]{64}$/.test(expected.targetSha256)
|
||||
|| !validTimestamp(expected.parentBirthtimeNs)
|
||||
|| typeof expected.parentRealPath !== "string" || !expected.parentRealPath) {
|
||||
throw new Error("Invalid remembered local download target identity");
|
||||
}
|
||||
const parentPath = path.dirname(requestedPath);
|
||||
const [parentStat, parentRealPath, target] = await Promise.all([
|
||||
fs.promises.stat(parentPath, { bigint: true }),
|
||||
fs.promises.realpath(parentPath),
|
||||
inspectedTarget ?? inspectLocalPromotionTarget(requestedPath),
|
||||
]);
|
||||
const parentIdentity = `${parentStat.dev}:${parentStat.ino}`;
|
||||
const targetIdentity = target.stableIdentity?.split(":").slice(0, 2).join(":");
|
||||
// Windows can return a different spelling for the same real directory
|
||||
// between sync and async realpath calls. The volume/file ID plus creation
|
||||
// time below identifies the actual parent, so a spelling mismatch alone
|
||||
// must not reject an unchanged remembered target on Windows.
|
||||
if (!parentStat.isDirectory()
|
||||
|| (process.platform !== "win32" && parentRealPath !== expected.parentRealPath)
|
||||
|| parentIdentity !== expected.parentIdentity
|
||||
|| String(parentStat.birthtimeNs) !== expected.parentBirthtimeNs
|
||||
|| targetIdentity !== expected.targetIdentity
|
||||
|| target.birthtimeNs !== expected.targetBirthtimeNs
|
||||
|| target.ctimeNs !== expected.targetCtimeNs
|
||||
|| target.mtimeNs !== expected.targetMtimeNs) {
|
||||
throw new Error("Remembered local download target changed before replacement");
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
|
||||
const assertNotCancelled = options.assertNotCancelled || (() => {});
|
||||
const token = crypto.randomUUID().replace(/-/g, "");
|
||||
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
|
||||
const readyPath = `${base}.ready`;
|
||||
const backupPath = `${base}.backup`;
|
||||
// A remembered replacement retains its verified backup after publication.
|
||||
// Using one fixed backup name makes each repeat supersede the previous
|
||||
// recovery copy instead of accumulating an unbounded series of full-size
|
||||
// hidden backups beside the destination (Codex P1 on PR #3516).
|
||||
const backupPath = options.expectedLocalTarget
|
||||
? path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty.backup`)
|
||||
: `${base}.backup`;
|
||||
const restoreProbePath = `${base}.restore-check`;
|
||||
let backedUp = false;
|
||||
let committed = false;
|
||||
let keepRecoveryFiles = false;
|
||||
let supersededBackupPath = null;
|
||||
let supersededBackupIdentity = null;
|
||||
// The fixed recovery-backup name is only reused when the file already there
|
||||
// can be proven to be the backup this app wrote earlier. The marker file
|
||||
// records the backed-up inode's identity so a repeat download refuses to
|
||||
// touch a foreign file that a user or another application placed at the
|
||||
// predictable hidden pathname (Codex P2 on PR #3516).
|
||||
const backupOwnerMarkerPath = `${backupPath}.owner`;
|
||||
let wroteOwnerMarker = false;
|
||||
let wroteOwnerMarkerStat = null;
|
||||
let preparedHandle;
|
||||
let originalHandle;
|
||||
let restoreProbeCreated = false;
|
||||
@@ -1072,6 +1434,9 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
// Keep read access to our private bytes before destination permissions
|
||||
// can remove it; the no-hardlink fallback copies through this handle.
|
||||
preparedHandle = await fs.promises.open(readyPath, "r");
|
||||
const preparedHash = options.capturePublishedContentHash
|
||||
? await hashOpenLocalFile(preparedHandle, assertNotCancelled)
|
||||
: null;
|
||||
let appliedMode = null;
|
||||
let validatedTarget;
|
||||
let stable = false;
|
||||
@@ -1079,6 +1444,11 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
validatedTarget = typeof options.validateTarget === "function"
|
||||
? await options.validateTarget()
|
||||
: undefined;
|
||||
await assertExpectedLocalDownloadTarget(
|
||||
options.requestedTargetPath || targetPath,
|
||||
options.expectedLocalTarget,
|
||||
validatedTarget,
|
||||
);
|
||||
const mode = Number.isInteger(validatedTarget?.existingMode)
|
||||
? validatedTarget.existingMode & 0o7777
|
||||
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
|
||||
@@ -1095,6 +1465,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
const expectedAbsent = validatedTarget?.targetIdentity === "missing" || validatedTarget?.targetIdentity === null;
|
||||
const expectedIdentity = validatedTarget?.stableIdentity
|
||||
|| (validatedTarget?.targetIdentity ? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":") : null);
|
||||
let verifiedBackupCtimeNs = null;
|
||||
if (!expectedAbsent) {
|
||||
try {
|
||||
originalHandle = await fs.promises.open(targetPath, "r");
|
||||
@@ -1113,18 +1484,172 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
} else if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
assertNotCancelled();
|
||||
// A previous remembered replacement retained its verified backup at
|
||||
// this fixed name. rename(targetPath, backupPath) would atomically
|
||||
// unlink that inode, so an editor still holding it open would silently
|
||||
// lose edits. Roll the old artifact to a unique name first so its
|
||||
// content stays reachable through recovery — but only after proving
|
||||
// with the owner marker that this app wrote that backup; a foreign
|
||||
// file at the predictable hidden pathname is never moved or replaced
|
||||
// (Codex P1/P2 on PR #3516).
|
||||
if (options.expectedLocalTarget) {
|
||||
let existingBackupStat = null;
|
||||
try {
|
||||
existingBackupStat = await fs.promises.lstat(backupPath, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (existingBackupStat) {
|
||||
const existingMarkerStat = await fs.promises.lstat(
|
||||
backupOwnerMarkerPath, { bigint: true },
|
||||
).catch(() => null);
|
||||
const ownsExistingBackup = await localBackupOwnerMarkerMatches(
|
||||
backupOwnerMarkerPath, existingBackupStat,
|
||||
);
|
||||
if (!existingBackupStat.isFile() || !existingMarkerStat?.isFile()
|
||||
|| !ownsExistingBackup) {
|
||||
throw new Error(
|
||||
`An unrecognized file already exists at the recovery backup location ${backupPath}; `
|
||||
+ "refusing to move or replace it. Remove or rename that file if it was not created by Netcatty.",
|
||||
);
|
||||
}
|
||||
// Retain the exact old inode in managed storage before changing
|
||||
// either the backup or destination. A cross-volume copy is only a
|
||||
// snapshot: an editor may write through its old handle after that
|
||||
// snapshot and before cleanup. Refuse this repeat if a hard link
|
||||
// into managed storage cannot be made.
|
||||
const retiredPath = tempDirBridge.getTempFilePath(
|
||||
`superseded-${path.basename(backupPath)}.${token}`,
|
||||
);
|
||||
try {
|
||||
await fs.promises.link(backupPath, retiredPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"Cannot safely retain the previous download on this filesystem; choose the destination again",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const retiredStat = await fs.promises.lstat(retiredPath, { bigint: true });
|
||||
if (`${retiredStat.dev}:${retiredStat.ino}`
|
||||
!== `${existingBackupStat.dev}:${existingBackupStat.ino}`
|
||||
|| String(retiredStat.birthtimeNs) !== String(existingBackupStat.birthtimeNs)) {
|
||||
throw new Error("The recovery backup changed before it could be retained");
|
||||
}
|
||||
supersededBackupIdentity = {
|
||||
dev: String(existingBackupStat.dev),
|
||||
ino: String(existingBackupStat.ino),
|
||||
birthtimeNs: String(existingBackupStat.birthtimeNs),
|
||||
};
|
||||
supersededBackupPath = `${backupPath}.${token}`;
|
||||
try {
|
||||
await fs.promises.rename(backupPath, supersededBackupPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
// Our own backup was removed concurrently; nothing to supersede.
|
||||
supersededBackupPath = null;
|
||||
await retireLocalBackupOwnerMarker(backupOwnerMarkerPath, existingMarkerStat);
|
||||
}
|
||||
if (supersededBackupPath) {
|
||||
// Revalidate the moved inode: a concurrent writer may have replaced
|
||||
// the backup after the marker check but before the rename above,
|
||||
// and the rename would have moved that unrelated replacement
|
||||
// aside, where post-commit retirement would remove it (Codex P1
|
||||
// on PR #3516). Compare the exact identity the marker validated.
|
||||
const movedStat = await fs.promises.lstat(supersededBackupPath, { bigint: true })
|
||||
.catch(() => null);
|
||||
if (!movedStat || !movedStat.isFile()
|
||||
|| `${movedStat.dev}:${movedStat.ino}`
|
||||
!== `${existingBackupStat.dev}:${existingBackupStat.ino}`
|
||||
|| String(movedStat.birthtimeNs) !== String(existingBackupStat.birthtimeNs)) {
|
||||
// Leave the moved artifact in place; the pre-commit rollback
|
||||
// below returns it to the fixed backup name (and fails closed
|
||||
// on the owner marker), so nothing unverifiable gets retired.
|
||||
throw new Error(
|
||||
`The recovery backup at ${backupPath} changed between validation and rollover; `
|
||||
+ "refusing to supersede it. Remove or rename that file if it was not created by Netcatty.",
|
||||
);
|
||||
}
|
||||
// Free the fixed marker pathname for the exclusive marker write
|
||||
// below; a fresh marker for the restored backup's actual identity
|
||||
// is recreated if this replacement is rolled back before the
|
||||
// commit boundary (copy-based publication gives the restored file
|
||||
// a new inode, Codex P2 on PR #3516).
|
||||
await retireLocalBackupOwnerMarker(backupOwnerMarkerPath, existingMarkerStat);
|
||||
}
|
||||
} else {
|
||||
// No backup at the fixed name, but the owner marker from an
|
||||
// interrupted rollover may still be in place. Clear it when it
|
||||
// still reads as ours, so the fresh exclusive marker write below
|
||||
// is not permanently blocked by a stale identity (Codex P2 on PR
|
||||
// #3516). A foreign entry at the marker pathname is left alone.
|
||||
await removeOrphanedLocalBackupOwnerMarker(backupOwnerMarkerPath);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
// The fixed recovery-backup name is shared across repeats, so a
|
||||
// late arrival at that pathname must never be replaced (Codex P2 on
|
||||
// PR #3516). The token-unique per-transfer backup name has no such
|
||||
// race, so it keeps the atomic single-name rename.
|
||||
if (options.expectedLocalTarget) {
|
||||
await publishLocalBackupExclusive(targetPath, backupPath);
|
||||
} else {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
}
|
||||
backedUp = true;
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw new Error(
|
||||
`An unrecognized file already exists at the recovery backup location ${backupPath}; `
|
||||
+ "refusing to replace it. Remove or rename that file if it was not created by Netcatty.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (options.expectedLocalTarget && !backedUp) {
|
||||
throw new Error("Remembered local download target disappeared during replacement");
|
||||
}
|
||||
}
|
||||
if (backedUp && expectedIdentity) {
|
||||
const stat = await fs.promises.lstat(backupPath);
|
||||
if (!stat.isFile() || stableLocalFileIdentity(stat) !== expectedIdentity) {
|
||||
if (backedUp && (expectedIdentity || options.expectedLocalTarget)) {
|
||||
const stat = await fs.promises.lstat(backupPath, { bigint: true });
|
||||
if (!stat.isFile() || (expectedIdentity && stableLocalFileIdentity(stat) !== expectedIdentity)
|
||||
|| (options.expectedLocalTarget
|
||||
&& (`${stat.dev}:${stat.ino}` !== options.expectedLocalTarget.targetIdentity
|
||||
|| String(stat.birthtimeNs) !== options.expectedLocalTarget.targetBirthtimeNs
|
||||
|| String(stat.mtimeNs) !== options.expectedLocalTarget.targetMtimeNs))) {
|
||||
throw new Error("Local download target changed during replacement");
|
||||
}
|
||||
if (options.expectedLocalTarget) {
|
||||
let backupHandle;
|
||||
try {
|
||||
const heldStat = originalHandle && await originalHandle.stat({ bigint: true });
|
||||
backupHandle = heldStat && stableLocalFileIdentity(heldStat) === stableLocalFileIdentity(stat)
|
||||
? originalHandle : await fs.promises.open(backupPath, "r");
|
||||
const before = await backupHandle.stat({ bigint: true });
|
||||
const backupHash = await hashOpenLocalFile(backupHandle, assertNotCancelled);
|
||||
const [after, pathStat] = await Promise.all([
|
||||
backupHandle.stat({ bigint: true }),
|
||||
fs.promises.lstat(backupPath, { bigint: true }),
|
||||
]);
|
||||
if (backupHash !== options.expectedLocalTarget.targetSha256
|
||||
|| stableLocalFileIdentity(before) !== stableLocalFileIdentity(after)
|
||||
|| stableLocalFileIdentity(after) !== stableLocalFileIdentity(pathStat)
|
||||
|| `${pathStat.dev}:${pathStat.ino}` !== options.expectedLocalTarget.targetIdentity
|
||||
|| before.ctimeNs !== after.ctimeNs || after.ctimeNs !== pathStat.ctimeNs
|
||||
|| before.mtimeNs !== after.mtimeNs || after.mtimeNs !== pathStat.mtimeNs) {
|
||||
throw new Error("Local download target content changed during replacement");
|
||||
}
|
||||
verifiedBackupCtimeNs = String(pathStat.ctimeNs);
|
||||
// Record this app's ownership of the fixed-name recovery backup so
|
||||
// the next repeat download can prove the file at that pathname is
|
||||
// ours before rolling it aside. Written before the commit boundary,
|
||||
// so a failure here still rolls the original target back.
|
||||
wroteOwnerMarkerStat = await writeLocalBackupOwnerMarker(backupOwnerMarkerPath, pathStat);
|
||||
wroteOwnerMarker = !!wroteOwnerMarkerStat;
|
||||
} finally {
|
||||
if (backupHandle && backupHandle !== originalHandle) await backupHandle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
assertNotCancelled();
|
||||
let publishedIdentity = null;
|
||||
@@ -1140,12 +1665,106 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
// Publication is the commit boundary. A late cancel must not perform a
|
||||
// check-then-unlink rollback against a name another process may now own.
|
||||
committed = true;
|
||||
// The previous inode already has a managed hard link created before the
|
||||
// commit boundary. Retire its old pathname only when it still identifies
|
||||
// that inode. There is no cross-volume copy-and-unlink window here.
|
||||
if (supersededBackupPath) {
|
||||
const retired = await removeLocalPathnameBoundToIdentity(
|
||||
supersededBackupPath, [supersededBackupIdentity],
|
||||
);
|
||||
if (!retired.vacated) {
|
||||
const recovery = new Error(
|
||||
`The previous recovery backup changed during retirement. Recovery file: ${supersededBackupPath}`,
|
||||
);
|
||||
recovery.recoveryFailed = true;
|
||||
recovery.remoteBackupPath = supersededBackupPath;
|
||||
throw recovery;
|
||||
}
|
||||
supersededBackupPath = null;
|
||||
}
|
||||
// Hand the published inode identity to the caller for descriptor-based
|
||||
// metadata stamping after publication.
|
||||
options.onCommit?.(publishedIdentity, localMtimePrepared);
|
||||
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
|
||||
if (backedUp && options.expectedLocalTarget) {
|
||||
let backupHandle;
|
||||
try {
|
||||
const pathBefore = await fs.promises.lstat(backupPath, { bigint: true });
|
||||
const heldStat = originalHandle && await originalHandle.stat({ bigint: true });
|
||||
backupHandle = heldStat && stableLocalFileIdentity(heldStat) === stableLocalFileIdentity(pathBefore)
|
||||
? originalHandle : await fs.promises.open(backupPath, "r");
|
||||
const before = await backupHandle.stat({ bigint: true });
|
||||
const backupHash = await hashOpenLocalFile(backupHandle);
|
||||
const [after, pathAfter] = await Promise.all([
|
||||
backupHandle.stat({ bigint: true }),
|
||||
fs.promises.lstat(backupPath, { bigint: true }),
|
||||
]);
|
||||
if (backupHash !== options.expectedLocalTarget.targetSha256
|
||||
|| String(pathBefore.ctimeNs) !== verifiedBackupCtimeNs
|
||||
|| stableLocalFileIdentity(before) !== stableLocalFileIdentity(after)
|
||||
|| stableLocalFileIdentity(after) !== stableLocalFileIdentity(pathAfter)
|
||||
|| `${pathAfter.dev}:${pathAfter.ino}` !== options.expectedLocalTarget.targetIdentity
|
||||
|| before.ctimeNs !== after.ctimeNs || after.ctimeNs !== pathAfter.ctimeNs
|
||||
|| before.mtimeNs !== after.mtimeNs || after.mtimeNs !== pathAfter.mtimeNs) {
|
||||
throw new Error("Original local file changed after publication");
|
||||
}
|
||||
} catch (error) {
|
||||
options.onCommit?.(null, localMtimePrepared);
|
||||
const recovery = new Error(`Original local file changed after publication. Recovery backup preserved: ${backupPath}`, { cause: error });
|
||||
recovery.recoveryFailed = true;
|
||||
recovery.remoteBackupPath = backupPath;
|
||||
throw recovery;
|
||||
} finally {
|
||||
if (backupHandle && backupHandle !== originalHandle) await backupHandle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
// The post-publication verification above only samples the backup; it
|
||||
// cannot atomically guard the deletion below. A writer still holding the
|
||||
// original inode open can land an edit between the final stat/hash and
|
||||
// this unlink, destroying the only remaining name for that edit. Keep the
|
||||
// verified backup instead; the next replacement of this target supersedes
|
||||
// it, and a failure path above already preserves it for recovery.
|
||||
if (backedUp && !options.expectedLocalTarget) await fs.promises.unlink(backupPath).catch(() => {});
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
await fs.promises.unlink(stagedPath).catch(() => {});
|
||||
// Removing the prepared hardlink advances ctime on the published inode.
|
||||
// Capture the final metadata only after cleanup, and refuse to remember
|
||||
// an inode whose bytes or name changed after publication.
|
||||
if (publishedIdentity) {
|
||||
const stat = await fs.promises.lstat(targetPath, { bigint: true }).catch(() => null);
|
||||
publishedIdentity = stat?.isFile()
|
||||
&& stableLocalFileIdentity(stat) === stableLocalFileIdentity(publishedIdentity)
|
||||
&& String(stat.birthtimeNs) === publishedIdentity.birthtimeNs
|
||||
&& String(stat.mtimeNs) === publishedIdentity.mtimeNs
|
||||
? { ...publishedIdentity, ctimeNs: String(stat.ctimeNs) }
|
||||
: null;
|
||||
if (publishedIdentity && preparedHash) {
|
||||
let targetHandle;
|
||||
try {
|
||||
const preparedStat = await preparedHandle.stat({ bigint: true });
|
||||
targetHandle = stableLocalFileIdentity(preparedStat) === stableLocalFileIdentity(stat)
|
||||
? preparedHandle : await fs.promises.open(targetPath, "r");
|
||||
const before = await targetHandle.stat({ bigint: true });
|
||||
const actualHash = await hashOpenLocalFile(targetHandle);
|
||||
const [after, pathStat] = await Promise.all([
|
||||
targetHandle.stat({ bigint: true }),
|
||||
fs.promises.lstat(targetPath, { bigint: true }),
|
||||
]);
|
||||
publishedIdentity = actualHash === preparedHash
|
||||
&& stableLocalFileIdentity(before) === stableLocalFileIdentity(after)
|
||||
&& stableLocalFileIdentity(after) === stableLocalFileIdentity(pathStat)
|
||||
&& before.ctimeNs === after.ctimeNs && after.ctimeNs === pathStat.ctimeNs
|
||||
&& before.mtimeNs === after.mtimeNs && after.mtimeNs === pathStat.mtimeNs
|
||||
? { ...publishedIdentity, sha256: preparedHash, ctimeNs: String(pathStat.ctimeNs) }
|
||||
: null;
|
||||
} catch {
|
||||
// Publication already committed; simply decline to remember a
|
||||
// target whose content cannot be verified.
|
||||
publishedIdentity = null;
|
||||
} finally {
|
||||
if (targetHandle && targetHandle !== preparedHandle) await targetHandle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
options.onCommit?.(publishedIdentity, localMtimePrepared);
|
||||
} catch (error) {
|
||||
if (committed) throw error;
|
||||
if (backedUp && !keepRecoveryFiles) {
|
||||
@@ -1155,13 +1774,114 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
let restoreHandle;
|
||||
if (originalHandle) {
|
||||
const [heldStat, backupStat] = await Promise.all([
|
||||
originalHandle.stat(), fs.promises.lstat(backupPath),
|
||||
originalHandle.stat({ bigint: true }), fs.promises.lstat(backupPath, { bigint: true }),
|
||||
]);
|
||||
if (stableLocalFileIdentity(heldStat) === stableLocalFileIdentity(backupStat)) restoreHandle = originalHandle;
|
||||
}
|
||||
await publishLocalFileExclusive(backupPath, targetPath, undefined, restoreHandle);
|
||||
await fs.promises.unlink(backupPath).catch(() => {});
|
||||
const restoredTargetIdentity = await publishLocalFileExclusive(
|
||||
backupPath, targetPath, undefined, restoreHandle,
|
||||
);
|
||||
// Another process can atomically replace the predictable fixed
|
||||
// backup pathname after the restore above but before the backup name
|
||||
// is dropped, and a blind unlink would then delete that unrelated
|
||||
// replacement (Codex P2 on PR #3516). Vacate the pathname atomically
|
||||
// and unlink the moved entry only while it still names the inode
|
||||
// that was published (hardlink publication) or the backup inode
|
||||
// observed just before the restore (copy-based publication); a
|
||||
// foreign replacement is given its name back instead.
|
||||
const backupBeforeRestoreStat = originalHandle
|
||||
? await originalHandle.stat({ bigint: true }).catch(() => null)
|
||||
: null;
|
||||
await removeLocalPathnameBoundToIdentity(backupPath, [
|
||||
restoredTargetIdentity,
|
||||
backupBeforeRestoreStat && {
|
||||
dev: String(backupBeforeRestoreStat.dev),
|
||||
ino: String(backupBeforeRestoreStat.ino),
|
||||
birthtimeNs: String(backupBeforeRestoreStat.birthtimeNs),
|
||||
},
|
||||
]);
|
||||
backedUp = false;
|
||||
// The original target was restored, so put the superseded recovery
|
||||
// backup back where this transfer found it, with the owner marker
|
||||
// that proves it is ours. Recreate the marker exclusively so a
|
||||
// symlink swapped in at that pathname is never followed (Codex P2).
|
||||
if (supersededBackupPath) {
|
||||
// A failed restore must not be swallowed: the marker below would
|
||||
// otherwise describe a backup that never returned to the fixed
|
||||
// name (Codex P2 on PR #3516). Publish exclusively and unlink the
|
||||
// source only after success, so a file another process created at
|
||||
// the fixed backup name after the rollback unlink is never
|
||||
// silently replaced by rename (Codex P2 on PR #3516).
|
||||
await publishLocalFileExclusive(supersededBackupPath, backupPath);
|
||||
await fs.promises.unlink(supersededBackupPath).catch(() => {});
|
||||
supersededBackupPath = null;
|
||||
if (wroteOwnerMarker) {
|
||||
// This replacement wrote its own marker for the backup it just
|
||||
// rolled back. Remove it so the saved marker can be recreated
|
||||
// exclusively instead of failing with EEXIST and leaving the
|
||||
// restored backup paired with the wrong identity (Codex P2 on
|
||||
// PR #3516).
|
||||
await retireLocalBackupOwnerMarker(backupOwnerMarkerPath, wroteOwnerMarkerStat);
|
||||
}
|
||||
// Copy-based publication (filesystems without hard links) gives the
|
||||
// restored backup a fresh inode, so restoring the saved marker
|
||||
// verbatim would pair the fixed backup name with an identity it can
|
||||
// never match again, and the next repeat download would permanently
|
||||
// reject the recovery backup until the hidden files are removed
|
||||
// (Codex P2 on PR #3516). Stat the restored file and write a marker
|
||||
// for its actual identity instead. Recreated exclusively so a
|
||||
// symlink swapped in at that pathname is never followed (Codex P2).
|
||||
const restoredBackupStat = await fs.promises.lstat(backupPath, { bigint: true })
|
||||
.catch(() => null);
|
||||
if (restoredBackupStat?.isFile()) {
|
||||
await writeLocalBackupOwnerMarker(backupOwnerMarkerPath, restoredBackupStat);
|
||||
} else {
|
||||
// Fail closed: without a matching marker the next repeat download
|
||||
// refuses to touch the restored backup instead of replacing a
|
||||
// foreign file.
|
||||
if (wroteOwnerMarker) {
|
||||
await retireLocalBackupOwnerMarker(backupOwnerMarkerPath, wroteOwnerMarkerStat);
|
||||
}
|
||||
}
|
||||
} else if (wroteOwnerMarker) {
|
||||
// The rollover marker no longer matches any retained backup.
|
||||
await retireLocalBackupOwnerMarker(backupOwnerMarkerPath, wroteOwnerMarkerStat);
|
||||
}
|
||||
} catch (restoreError) {
|
||||
keepRecoveryFiles = true;
|
||||
error.cause ??= restoreError;
|
||||
}
|
||||
} else if (!backedUp && !keepRecoveryFiles && supersededBackupPath) {
|
||||
// The remembered target disappeared before it could be backed up, so
|
||||
// there is nothing to restore over the target; put the superseded
|
||||
// recovery backup back at its fixed name instead of stranding it under
|
||||
// an undisclosed versioned name. Recreate the marker exclusively so a
|
||||
// symlink swapped in at that pathname is never followed (Codex P2).
|
||||
try {
|
||||
// A failed restore must not be swallowed: the marker below would
|
||||
// otherwise describe a backup that never returned to the fixed
|
||||
// name (Codex P2 on PR #3516). Publish exclusively and unlink the
|
||||
// source only after success, so a file another process created at
|
||||
// the fixed backup name is never silently replaced by rename
|
||||
// (Codex P2 on PR #3516).
|
||||
await publishLocalFileExclusive(supersededBackupPath, backupPath);
|
||||
await fs.promises.unlink(supersededBackupPath).catch(() => {});
|
||||
supersededBackupPath = null;
|
||||
// Copy-based publication gives the restored backup a fresh inode, so
|
||||
// a saved marker for the superseded inode would never match again
|
||||
// (Codex P2 on PR #3516). Stat the restored file and write a marker
|
||||
// for its actual identity; without one the next repeat download
|
||||
// refuses to touch the restored backup instead of replacing a
|
||||
// foreign file.
|
||||
const restoredBackupStat = await fs.promises.lstat(backupPath, { bigint: true })
|
||||
.catch(() => null);
|
||||
if (restoredBackupStat?.isFile()) {
|
||||
await writeLocalBackupOwnerMarker(backupOwnerMarkerPath, restoredBackupStat);
|
||||
} else {
|
||||
if (wroteOwnerMarker) {
|
||||
await retireLocalBackupOwnerMarker(backupOwnerMarkerPath, wroteOwnerMarkerStat);
|
||||
}
|
||||
}
|
||||
} catch (restoreError) {
|
||||
keepRecoveryFiles = true;
|
||||
error.cause ??= restoreError;
|
||||
@@ -1240,7 +1960,7 @@ async function preserveTransferredDestinationMtime(transfer, options = {}) {
|
||||
handle = await fs.promises.open(transfer.targetPath, fs.constants.O_WRONLY);
|
||||
}
|
||||
try {
|
||||
const currentStat = await handle.stat();
|
||||
const currentStat = await handle.stat({ bigint: true });
|
||||
const publishedIdentity = transfer.publishedLocalIdentity;
|
||||
const expectedIdentity = typeof publishedIdentity === "string"
|
||||
? publishedIdentity : stableLocalFileIdentity(publishedIdentity);
|
||||
@@ -1322,7 +2042,7 @@ async function inspectLocalPromotionTarget(targetPath) {
|
||||
const candidatePath = path.join(currentPath, nextPart);
|
||||
let targetLstat;
|
||||
try {
|
||||
targetLstat = await fs.promises.lstat(candidatePath);
|
||||
targetLstat = await fs.promises.lstat(candidatePath, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
return {
|
||||
@@ -1370,8 +2090,11 @@ async function inspectLocalPromotionTarget(targetPath) {
|
||||
}
|
||||
return {
|
||||
promotionTargetPath: candidatePath,
|
||||
existingMode: targetLstat.mode & 0o7777,
|
||||
existingMode: Number(targetLstat.mode) & 0o7777,
|
||||
stableIdentity: stableLocalFileIdentity(targetLstat),
|
||||
birthtimeNs: String(targetLstat.birthtimeNs),
|
||||
ctimeNs: String(targetLstat.ctimeNs),
|
||||
mtimeNs: String(targetLstat.mtimeNs),
|
||||
targetIdentity: [
|
||||
targetLstat.dev,
|
||||
targetLstat.ino,
|
||||
@@ -1382,7 +2105,7 @@ async function inspectLocalPromotionTarget(targetPath) {
|
||||
};
|
||||
}
|
||||
|
||||
const rootStat = await fs.promises.lstat(currentPath);
|
||||
const rootStat = await fs.promises.lstat(currentPath, { bigint: true });
|
||||
if (!rootStat.isFile()) {
|
||||
const error = new Error(`Local download target is not a regular file: ${currentPath}`);
|
||||
error.code = rootStat.isDirectory() ? "EISDIR" : "EINVAL";
|
||||
@@ -1390,8 +2113,10 @@ async function inspectLocalPromotionTarget(targetPath) {
|
||||
}
|
||||
return {
|
||||
promotionTargetPath: currentPath,
|
||||
existingMode: rootStat.mode & 0o7777,
|
||||
existingMode: Number(rootStat.mode) & 0o7777,
|
||||
stableIdentity: stableLocalFileIdentity(rootStat),
|
||||
birthtimeNs: String(rootStat.birthtimeNs),
|
||||
ctimeNs: String(rootStat.ctimeNs),
|
||||
targetIdentity: [
|
||||
rootStat.dev,
|
||||
rootStat.ino,
|
||||
@@ -6046,7 +6771,11 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
: encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
// SCP cannot resume, but it must still stage locally so a failed/cancelled
|
||||
// overwrite never truncates or removes the existing destination.
|
||||
const stageLocalDownload = transfer.resumable || isScpModeClient(client);
|
||||
// A guarded repeat must always stage before publication. Otherwise a
|
||||
// non-resumable caller would stream straight into the selected file and
|
||||
// bypass the remembered-target check entirely.
|
||||
const stageLocalDownload = transfer.resumable || isScpModeClient(client)
|
||||
|| Boolean(payload.expectedLocalTarget);
|
||||
let downloadTargetPath = stageLocalDownload
|
||||
? tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath))
|
||||
: targetPath;
|
||||
@@ -6130,6 +6859,9 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
await promoteLocalTransfer(downloadTargetPath, promotionTargetPath, {
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
existingMode,
|
||||
requestedTargetPath: targetPath,
|
||||
expectedLocalTarget: payload.expectedLocalTarget,
|
||||
capturePublishedContentHash: payload.capturePublishedContentHash === true,
|
||||
async validateTarget() {
|
||||
const latestTarget = await inspectLocalPromotionTarget(targetPath);
|
||||
if (latestTarget.promotionTargetPath !== promotionTargetPath) {
|
||||
@@ -6534,7 +7266,12 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
});
|
||||
sendComplete();
|
||||
|
||||
return { transferId, totalBytes: fileSize };
|
||||
return {
|
||||
transferId,
|
||||
totalBytes: fileSize,
|
||||
...(targetType === "local" && transfer.publishedLocalIdentity
|
||||
? { publishedLocalIdentity: transfer.publishedLocalIdentity } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
logTransferDiag(transfer, transfer.cancelled ? "cancelled" : "error", {
|
||||
transferred: transfer.diagLastTransferred,
|
||||
@@ -7564,6 +8301,7 @@ module.exports = {
|
||||
releaseSftpTransferSession,
|
||||
listTransferSftpIds,
|
||||
_promoteLocalTransferForTests: promoteLocalTransfer,
|
||||
_removeLocalPathnameBoundToIdentityForTests: removeLocalPathnameBoundToIdentity,
|
||||
_preserveTransferredDestinationMtimeForTests: preserveTransferredDestinationMtime,
|
||||
_restoreRemoteUploadModeBestEffortForTests: restoreRemoteUploadModeBestEffort,
|
||||
_waitForPendingWriteOpenPathGateForTests: waitForPendingWriteOpenPathGate,
|
||||
|
||||
@@ -6,6 +6,961 @@ const path = require("node:path");
|
||||
const bridge = require("./transferBridge.cjs");
|
||||
const temp = require("./tempDirBridge.cjs");
|
||||
|
||||
function rememberedExpectation(parent, target) {
|
||||
const parentStat = fs.statSync(parent, { bigint: true });
|
||||
const targetStat = fs.lstatSync(target, { bigint: true });
|
||||
return {
|
||||
parentRealPath: fs.realpathSync(parent),
|
||||
parentIdentity: `${parentStat.dev}:${parentStat.ino}`,
|
||||
parentBirthtimeNs: String(parentStat.birthtimeNs),
|
||||
targetIdentity: `${targetStat.dev}:${targetStat.ino}`,
|
||||
targetBirthtimeNs: String(targetStat.birthtimeNs),
|
||||
targetCtimeNs: String(targetStat.ctimeNs),
|
||||
targetMtimeNs: String(targetStat.mtimeNs),
|
||||
targetSha256: require("node:crypto").createHash("sha256").update(fs.readFileSync(target)).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
test("remembered download replaces the same verified local file", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-publish")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
let publishedIdentity;
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
capturePublishedContentHash: true,
|
||||
onCommit(identity) { publishedIdentity = identity; },
|
||||
});
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "download");
|
||||
const publishedStat = fs.lstatSync(target, { bigint: true });
|
||||
assert.deepEqual(publishedIdentity, {
|
||||
dev: String(publishedStat.dev), ino: String(publishedStat.ino),
|
||||
size: Number(publishedStat.size), birthtimeNs: String(publishedStat.birthtimeNs),
|
||||
ctimeNs: String(publishedStat.ctimeNs), mtimeNs: String(publishedStat.mtimeNs),
|
||||
sha256: require("node:crypto").createHash("sha256").update("download").digest("hex"),
|
||||
});
|
||||
});
|
||||
|
||||
test("verified remembered download retains its backup after publication", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-retain-backup")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "download");
|
||||
// The verification snapshot cannot guard the backup deletion: a late write
|
||||
// from a process holding the original inode open would otherwise lose its
|
||||
// only remaining name. The verified backup must therefore be retained.
|
||||
const backupName = fs.readdirSync(root).find((name) => name.endsWith(".backup"));
|
||||
assert.ok(backupName, "retain the verified backup for late writers");
|
||||
assert.equal(fs.readFileSync(path.join(root, backupName), "utf8"), "original");
|
||||
});
|
||||
|
||||
test("repeated remembered downloads supersede the previous backup", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-rolling-backup")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
// The renderer re-remembers the published target before the next repeat.
|
||||
fs.writeFileSync(staged, "third");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "third");
|
||||
const backups = fs.readdirSync(root).filter((name) => name.includes("netcatty.backup"));
|
||||
const fixed = backups.find((name) => name === ".target.netcatty.backup");
|
||||
const versioned = backups.find((name) => name.startsWith(".target.netcatty.backup.")
|
||||
&& name !== ".target.netcatty.backup.owner");
|
||||
assert.ok(fixed, "keep the verified backup of the latest replacement");
|
||||
assert.equal(fs.readFileSync(path.join(root, fixed), "utf8"), "second");
|
||||
// Once the replacement is committed, the superseded copy is dropped so
|
||||
// repeated downloads keep a bounded recovery set instead of leaving one
|
||||
// permanent full-size file per version beside the destination. The verified
|
||||
// backup at the fixed name is retained for late writers.
|
||||
assert.ok(!versioned, "remove the superseded recovery copy after commit");
|
||||
assert.equal(backups.length, 2, "keep only the bounded backup and its owner marker");
|
||||
});
|
||||
|
||||
test("failed remembered replacement restores the superseded backup", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-rollback-backup")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
// The renderer re-remembers the published target before the next repeat,
|
||||
// but the remembered digest no longer matches the bytes on disk.
|
||||
fs.writeFileSync(staged, "third");
|
||||
fs.writeFileSync(target, "tampered");
|
||||
const expectation = rememberedExpectation(root, target);
|
||||
expectation.targetSha256 = require("node:crypto").createHash("sha256").update("stale").digest("hex");
|
||||
await assert.rejects(
|
||||
bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: expectation,
|
||||
}),
|
||||
(error) => error?.message?.includes("changed during replacement"),
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "tampered", "restore the pre-transfer target bytes");
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, ".target.netcatty.backup"), "utf8"), "first",
|
||||
"put the superseded backup back at the fixed name",
|
||||
);
|
||||
const remainingBackups = fs.readdirSync(root)
|
||||
.filter((name) => name.includes("netcatty.backup") && name !== ".target.netcatty.backup.owner");
|
||||
assert.equal(remainingBackups.length, 1, "leave no orphaned versioned recovery copy");
|
||||
});
|
||||
|
||||
test("remembered download refuses to move a foreign file at the backup pathname", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-foreign-backup")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backupPath = path.join(root, ".target.netcatty.backup");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
assert.equal(fs.readFileSync(backupPath, "utf8"), "first");
|
||||
// A user or another application takes over the hidden pathname. Without a
|
||||
// matching owner marker Netcatty must neither move it aside nor replace it.
|
||||
fs.writeFileSync(backupPath, "mine");
|
||||
fs.rmSync(`${backupPath}.owner`);
|
||||
fs.writeFileSync(staged, "third");
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/refusing to move or replace/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(backupPath, "utf8"), "mine", "leave the foreign file untouched");
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "second", "leave the target untouched");
|
||||
assert.equal(
|
||||
fs.readdirSync(root).filter((name) => name.startsWith(".target.netcatty.backup.")).length, 0,
|
||||
"scatter no versioned copies of the foreign file",
|
||||
);
|
||||
});
|
||||
|
||||
test("remembered download fails closed when hard links are unavailable", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-no-hardlinks")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => {
|
||||
throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" });
|
||||
};
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
// Without hard-link support there is no exclusive publication: a
|
||||
// replacing rename() could destroy a file another process creates at the
|
||||
// fixed backup pathname inside the check-to-move window, so the
|
||||
// remembered replacement must be refused instead (Codex P1 on PR #3516).
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/cannot be published exclusively/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "first", "leave the original target untouched");
|
||||
assert.ok(!fs.existsSync(path.join(root, ".target.netcatty.backup")),
|
||||
"create no backup entry on the refused replacement");
|
||||
});
|
||||
|
||||
test("failed backup publication preserves a late replacement at its pathname", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-backup-cleanup-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backup = path.join(root, ".target.netcatty.backup");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
const rename = fs.promises.rename;
|
||||
let injected = false;
|
||||
fs.promises.rename = async (source, destination) => {
|
||||
if (!injected && source === target && destination.endsWith(".retiring")) {
|
||||
injected = true;
|
||||
const replacement = path.join(root, "foreign-backup");
|
||||
fs.writeFileSync(replacement, "foreign");
|
||||
fs.renameSync(replacement, backup);
|
||||
throw Object.assign(new Error("source retirement failed"), { code: "EACCES" });
|
||||
}
|
||||
return rename(source, destination);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = rename; });
|
||||
|
||||
await assert.rejects(
|
||||
bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/source retirement failed/,
|
||||
);
|
||||
assert.equal(injected, true);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
assert.equal(fs.readFileSync(backup, "utf8"), "foreign");
|
||||
});
|
||||
|
||||
test("a displaced local file is recovered in managed storage when its name is occupied again", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-quarantine")}-`);
|
||||
const tempDir = temp.getTempDir();
|
||||
const before = new Set(fs.readdirSync(tempDir));
|
||||
t.after(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
for (const name of fs.readdirSync(tempDir)) {
|
||||
if (!before.has(name)) fs.rmSync(path.join(tempDir, name), { force: true });
|
||||
}
|
||||
});
|
||||
const pathname = path.join(root, "backup");
|
||||
fs.writeFileSync(pathname, "displaced edit");
|
||||
const link = fs.promises.link;
|
||||
let injected = false;
|
||||
t.mock.method(fs.promises, "link", async (source, target, ...rest) => {
|
||||
if (!injected && target === pathname && String(source).endsWith(".retiring")) {
|
||||
injected = true;
|
||||
fs.writeFileSync(pathname, "newer edit");
|
||||
throw Object.assign(new Error("occupied"), { code: "EEXIST" });
|
||||
}
|
||||
return link(source, target, ...rest);
|
||||
});
|
||||
await assert.rejects(
|
||||
bridge._removeLocalPathnameBoundToIdentityForTests(pathname, [{
|
||||
dev: "0", ino: "0", birthtimeNs: "0",
|
||||
}]),
|
||||
(error) => {
|
||||
assert.equal(injected, true);
|
||||
assert.match(error.message, /Recovery file:/);
|
||||
assert.equal(fs.readFileSync(error.recoveryPath, "utf8"), "displaced edit");
|
||||
assert.equal(path.dirname(error.recoveryPath), tempDir);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(fs.readFileSync(pathname, "utf8"), "newer edit");
|
||||
});
|
||||
|
||||
test("a failed owner marker write removes its exclusively created marker", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-marker-fail")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
const open = fs.promises.open;
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await open.apply(fs.promises, args);
|
||||
if (String(args[0]).endsWith(".owner")) {
|
||||
handle.writeFile = async () => {
|
||||
throw Object.assign(new Error("destination volume full"), { code: "ENOSPC" });
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
t.after(() => { fs.promises.open = open; });
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
);
|
||||
// An empty marker whose write failed would otherwise block every later
|
||||
// repeat download against the permanently unrecognized marker (Codex P2
|
||||
// on PR #3516), so the exclusively created marker must be removed.
|
||||
assert.ok(!fs.existsSync(path.join(root, ".target.netcatty.backup.owner")),
|
||||
"remove the marker whose write failed");
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "first", "restore the original target");
|
||||
});
|
||||
|
||||
test("remembered download refuses a backup whose inode no longer matches its owner marker", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-stale-marker")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backupPath = path.join(root, ".target.netcatty.backup");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
// Swap in a different inode while keeping the previous owner marker.
|
||||
fs.renameSync(backupPath, `${backupPath}.held`);
|
||||
fs.writeFileSync(backupPath, "unrelated");
|
||||
fs.writeFileSync(staged, "third");
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/refusing to move or replace/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(backupPath, "utf8"), "unrelated", "leave the swapped-in file untouched");
|
||||
assert.equal(fs.readFileSync(`${backupPath}.held`, "utf8"), "first", "keep the real backup reachable");
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "second", "leave the target untouched");
|
||||
});
|
||||
|
||||
test("remembered download recovers an owner marker orphaned by an interrupted rollover", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-orphan-marker")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backupPath = path.join(root, ".target.netcatty.backup");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
assert.equal(fs.readFileSync(backupPath, "utf8"), "first");
|
||||
// Simulate an interrupted rollover: the process exits after renaming the
|
||||
// backup aside but before dropping its owner marker, leaving the stale
|
||||
// marker stranded beside the destination.
|
||||
fs.renameSync(backupPath, `${backupPath}.interrupted`);
|
||||
assert.ok(fs.existsSync(`${backupPath}.owner`), "marker survives the interrupted rollover");
|
||||
fs.writeFileSync(staged, "third");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "third",
|
||||
"the repeat succeeds despite the orphaned marker");
|
||||
assert.equal(fs.readFileSync(backupPath, "utf8"), "second",
|
||||
"retain the verified backup of the completed replacement");
|
||||
const marker = JSON.parse(fs.readFileSync(`${backupPath}.owner`, "utf8"));
|
||||
const backupStat = fs.lstatSync(backupPath, { bigint: true });
|
||||
assert.equal(marker.identity, `${backupStat.dev}:${backupStat.ino}`,
|
||||
"the fresh marker names the retained backup's inode");
|
||||
});
|
||||
|
||||
test("remembered download re-homes the superseded backup when the target disappears", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-vanish-target")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backupPath = path.join(root, ".target.netcatty.backup");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
fs.writeFileSync(staged, "third");
|
||||
const link = fs.promises.link;
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
fs.promises.link = async (from, to) => {
|
||||
if (from === target && String(to).endsWith(".backup")) fs.unlinkSync(target);
|
||||
return link(from, to);
|
||||
};
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/Remembered local download target disappeared/,
|
||||
);
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
assert.equal(fs.readFileSync(backupPath, "utf8"), "first",
|
||||
"put the superseded backup back at the fixed name");
|
||||
assert.equal(
|
||||
fs.readdirSync(root)
|
||||
.filter((name) => name.startsWith(".target.netcatty.backup.")
|
||||
&& name !== ".target.netcatty.backup.owner").length,
|
||||
0,
|
||||
"leave no versioned recovery copy stranded",
|
||||
);
|
||||
});
|
||||
|
||||
test("remembered download refuses to overwrite a foreign owner-marker entry", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-foreign-marker")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const markerPath = path.join(root, ".target.netcatty.backup.owner");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
// A foreign symlink occupies the predictable marker pathname before the
|
||||
// first remembered replacement. The marker write must neither truncate the
|
||||
// victim through the link nor replace the entry.
|
||||
const victim = path.join(root, "victim");
|
||||
fs.writeFileSync(victim, "mine");
|
||||
fs.symlinkSync(victim, markerPath);
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/refusing to overwrite/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(victim, "utf8"), "mine", "symlink victim untouched");
|
||||
assert.ok(fs.lstatSync(markerPath).isSymbolicLink(), "marker entry untouched");
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original", "target rolled back");
|
||||
});
|
||||
|
||||
test("remembered download refuses to truncate a foreign owner-marker file", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-foreign-marker-file")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const markerPath = path.join(root, ".target.netcatty.backup.owner");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
fs.writeFileSync(markerPath, "mine");
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/refusing to overwrite/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(markerPath, "utf8"), "mine", "foreign marker file untouched");
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original", "target rolled back");
|
||||
});
|
||||
|
||||
test("superseded recovery copy is retired into Netcatty temp storage, not destroyed", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-superseded-retire")}-`);
|
||||
const tempDir = temp.getTempDir();
|
||||
const tempBefore = new Set(fs.readdirSync(tempDir));
|
||||
t.after(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
for (const name of fs.readdirSync(tempDir)) {
|
||||
if (!tempBefore.has(name)) fs.rmSync(path.join(tempDir, name), { force: true });
|
||||
}
|
||||
});
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
fs.writeFileSync(staged, "third");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "third");
|
||||
assert.equal(
|
||||
fs.readdirSync(root).filter((name) => name.startsWith(".target.netcatty.backup.")
|
||||
&& name !== ".target.netcatty.backup" && name !== ".target.netcatty.backup.owner").length,
|
||||
0,
|
||||
"leave no versioned copy beside the destination",
|
||||
);
|
||||
// An editor may still hold the superseded inode open, so its content must
|
||||
// stay reachable through the managed temporary store after retirement.
|
||||
const retired = fs.readdirSync(tempDir)
|
||||
.filter((name) => !tempBefore.has(name) && name.includes("superseded-.target.netcatty.backup."))
|
||||
.map((name) => path.join(tempDir, name))
|
||||
.find((candidate) => {
|
||||
try { return fs.readFileSync(candidate, "utf8") === "first"; } catch { return false; }
|
||||
});
|
||||
assert.ok(retired, "superseded copy remains reachable in Netcatty temp storage");
|
||||
});
|
||||
|
||||
test("cross-filesystem backup retention refuses the repeat before changing the destination", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-superseded-exdev")}-`);
|
||||
const tempDir = temp.getTempDir();
|
||||
const tempBefore = new Set(fs.readdirSync(tempDir));
|
||||
t.after(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
for (const name of fs.readdirSync(tempDir)) {
|
||||
if (!tempBefore.has(name)) fs.rmSync(path.join(tempDir, name), { force: true });
|
||||
}
|
||||
});
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backup = path.join(root, ".target.netcatty.backup");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
fs.writeFileSync(staged, "third");
|
||||
const link = fs.promises.link;
|
||||
t.mock.method(fs.promises, "link", async (from, to, ...rest) => {
|
||||
if (from === backup && String(to).includes("superseded-")) {
|
||||
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||
}
|
||||
return link(from, to, ...rest);
|
||||
});
|
||||
await assert.rejects(
|
||||
bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/Cannot safely retain the previous download/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "second");
|
||||
assert.equal(fs.readFileSync(backup, "utf8"), "first");
|
||||
assert.equal(fs.readdirSync(root).filter((name) => name.startsWith(".target.netcatty.backup.")
|
||||
&& name !== ".target.netcatty.backup.owner").length, 0);
|
||||
assert.equal(fs.readdirSync(tempDir).filter((name) => !tempBefore.has(name)
|
||||
&& name.includes("superseded-")).length, 0);
|
||||
});
|
||||
|
||||
test("late writes to a superseded backup remain reachable through managed storage", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-superseded-late-write")}-`);
|
||||
const tempDir = temp.getTempDir();
|
||||
const tempBefore = new Set(fs.readdirSync(tempDir));
|
||||
t.after(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
for (const name of fs.readdirSync(tempDir)) {
|
||||
if (!tempBefore.has(name)) fs.rmSync(path.join(tempDir, name), { force: true });
|
||||
}
|
||||
});
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backup = path.join(root, ".target.netcatty.backup");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
const held = fs.openSync(backup, "r+");
|
||||
t.after(() => fs.closeSync(held));
|
||||
const unlink = fs.promises.unlink;
|
||||
let lateWrite = false;
|
||||
t.mock.method(fs.promises, "unlink", async (pathname) => {
|
||||
if (!lateWrite && String(pathname).includes(".target.netcatty.backup.")
|
||||
&& String(pathname).endsWith(".retiring")) {
|
||||
fs.writeSync(held, Buffer.from("LATE!"), 0, 5, 0);
|
||||
fs.fsyncSync(held);
|
||||
lateWrite = true;
|
||||
}
|
||||
return unlink(pathname);
|
||||
});
|
||||
fs.writeFileSync(staged, "third");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
assert.equal(lateWrite, true);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "third");
|
||||
assert.equal(fs.fstatSync(held).nlink, 1, "the old inode keeps its managed link");
|
||||
const retained = fs.readdirSync(tempDir)
|
||||
.filter((name) => !tempBefore.has(name) && name.includes("superseded-.target.netcatty.backup."))
|
||||
.map((name) => path.join(tempDir, name));
|
||||
assert.equal(retained.length, 1);
|
||||
assert.equal(fs.readFileSync(retained[0], "utf8"), "LATE!");
|
||||
});
|
||||
|
||||
test("failed repeat restores the superseded backup and its owner marker", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-rollback-marker")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backupPath = path.join(root, ".target.netcatty.backup");
|
||||
const markerPath = `${backupPath}.owner`;
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
const savedMarker = fs.readFileSync(markerPath, "utf8");
|
||||
fs.writeFileSync(staged, "third");
|
||||
// A concurrent writer recreates the destination pathname before
|
||||
// publication, so the exclusive publish fails with EEXIST after this
|
||||
// replacement already wrote its own owner marker for the backup it just
|
||||
// rolled back. Rollback must remove that new marker before recreating
|
||||
// the saved one, or the restored backup is paired with the wrong
|
||||
// identity and every later repeat is refused (Codex P2 on PR #3516).
|
||||
let publicationAttempts = 0;
|
||||
const link = fs.promises.link;
|
||||
t.mock.method(fs.promises, "link", async (from, to, ...rest) => {
|
||||
if (to === target && publicationAttempts++ === 0) {
|
||||
throw Object.assign(new Error("occupied"), { code: "EEXIST" });
|
||||
}
|
||||
return link(from, to, ...rest);
|
||||
});
|
||||
await assert.rejects(
|
||||
bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/changed during replacement/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "second", "restore the pre-transfer target bytes");
|
||||
assert.equal(fs.readFileSync(backupPath, "utf8"), "first", "put the superseded backup back");
|
||||
assert.equal(
|
||||
fs.readFileSync(markerPath, "utf8"),
|
||||
savedMarker,
|
||||
"restore the superseded backup's owner marker",
|
||||
);
|
||||
// With the marker pairing restored, a later repeat must succeed again.
|
||||
fs.writeFileSync(staged, "fourth");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "fourth");
|
||||
});
|
||||
|
||||
test("rollback preserves a foreign owner marker swapped in before cleanup", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-marker-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const markerPath = path.join(root, ".target.netcatty.backup.owner");
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
const link = fs.promises.link;
|
||||
let injected = false;
|
||||
t.mock.method(fs.promises, "link", async (from, to, ...rest) => {
|
||||
if (to === target && !injected) {
|
||||
injected = true;
|
||||
fs.unlinkSync(markerPath);
|
||||
fs.writeFileSync(markerPath, "foreign marker");
|
||||
throw Object.assign(new Error("occupied"), { code: "EEXIST" });
|
||||
}
|
||||
return link(from, to, ...rest);
|
||||
});
|
||||
await assert.rejects(
|
||||
bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/changed during replacement/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "first");
|
||||
assert.equal(fs.readFileSync(markerPath, "utf8"), "foreign marker");
|
||||
});
|
||||
|
||||
test("repeat preserves a foreign owner marker swapped in during rollover", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-rollover-marker-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backupPath = path.join(root, ".target.netcatty.backup");
|
||||
const markerPath = `${backupPath}.owner`;
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
fs.writeFileSync(staged, "third");
|
||||
const rename = fs.promises.rename;
|
||||
let injected = false;
|
||||
t.mock.method(fs.promises, "rename", async (from, to, ...rest) => {
|
||||
if (from === backupPath && to.startsWith(`${backupPath}.`) && !injected) {
|
||||
injected = true;
|
||||
fs.unlinkSync(markerPath);
|
||||
fs.writeFileSync(markerPath, "foreign marker");
|
||||
}
|
||||
return rename(from, to, ...rest);
|
||||
});
|
||||
await assert.rejects(
|
||||
bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/owner marker changed during cleanup/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "second");
|
||||
assert.equal(fs.readFileSync(markerPath, "utf8"), "foreign marker");
|
||||
assert.equal(fs.readFileSync(backupPath, "utf8"), "first");
|
||||
});
|
||||
|
||||
test("orphan recovery preserves a marker replaced after it is read", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-orphan-marker-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const backupPath = path.join(root, ".target.netcatty.backup");
|
||||
const markerPath = `${backupPath}.owner`;
|
||||
fs.writeFileSync(staged, "second");
|
||||
fs.writeFileSync(target, "first");
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
});
|
||||
fs.unlinkSync(backupPath);
|
||||
fs.writeFileSync(staged, "third");
|
||||
const readFile = fs.promises.readFile;
|
||||
let injected = false;
|
||||
t.mock.method(fs.promises, "readFile", async (file, ...rest) => {
|
||||
const result = await readFile(file, ...rest);
|
||||
if (file === markerPath && !injected) {
|
||||
injected = true;
|
||||
fs.unlinkSync(markerPath);
|
||||
fs.writeFileSync(markerPath, "foreign marker");
|
||||
}
|
||||
return result;
|
||||
});
|
||||
await assert.rejects(
|
||||
bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget: rememberedExpectation(root, target),
|
||||
}),
|
||||
/refusing to overwrite/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "second");
|
||||
assert.equal(fs.readFileSync(markerPath, "utf8"), "foreign marker");
|
||||
});
|
||||
|
||||
test("published file edited with restored mtime is never remembered", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("published-restored-mtime")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
const unlink = fs.promises.unlink;
|
||||
t.after(() => { fs.promises.unlink = unlink; });
|
||||
let edited = false;
|
||||
fs.promises.unlink = async (file) => {
|
||||
const result = await unlink(file);
|
||||
if (!edited && String(file).endsWith(".ready")) {
|
||||
edited = true;
|
||||
const originalMtime = fs.statSync(target).mtime;
|
||||
fs.writeFileSync(target, "modified");
|
||||
fs.utimesSync(target, originalMtime, originalMtime);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
let publishedIdentity = "unset";
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
capturePublishedContentHash: true,
|
||||
onCommit(identity) { publishedIdentity = identity; },
|
||||
});
|
||||
assert.equal(edited, true);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "modified");
|
||||
assert.equal(publishedIdentity, null);
|
||||
});
|
||||
|
||||
test("remembered download preserves an in-place edit just before moving the original", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-inplace-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
const expectedLocalTarget = rememberedExpectation(root, target);
|
||||
const link = fs.promises.link;
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
let edited = false;
|
||||
fs.promises.link = async (from, to) => {
|
||||
if (!edited && from === target && String(to).endsWith(".backup")) {
|
||||
edited = true;
|
||||
fs.writeFileSync(target, "modified"); // same inode and byte length
|
||||
// Some filesystems coalesce immediate timestamp updates. Ensure this
|
||||
// simulated external write has an observable change in file metadata.
|
||||
fs.utimesSync(target, new Date(Date.now() + 1000), new Date(Date.now() + 1000));
|
||||
}
|
||||
return link(from, to);
|
||||
};
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target, expectedLocalTarget,
|
||||
}), /Local download target changed during replacement/);
|
||||
assert.equal(edited, true);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "modified");
|
||||
});
|
||||
|
||||
test("remembered download checks backup bytes even when its mtime appears unchanged", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-backup-hash")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
const expectedLocalTarget = rememberedExpectation(root, target);
|
||||
const link = fs.promises.link;
|
||||
const lstat = fs.promises.lstat;
|
||||
t.after(() => { fs.promises.link = link; fs.promises.lstat = lstat; });
|
||||
fs.promises.link = async (from, to) => {
|
||||
if (from === target && String(to).endsWith(".backup")) {
|
||||
fs.writeFileSync(target, "modified"); // same file number and byte length
|
||||
}
|
||||
return link(from, to);
|
||||
};
|
||||
// Model an external editor that restores mtime after its write. The backup
|
||||
// metadata alone must not authorize replacing these different bytes.
|
||||
fs.promises.lstat = async (file, options) => {
|
||||
const stat = await lstat(file, options);
|
||||
if (!String(file).endsWith(".backup")) return stat;
|
||||
return new Proxy(stat, { get(value, key) {
|
||||
if (key === "mtimeNs") return BigInt(expectedLocalTarget.targetMtimeNs);
|
||||
return Reflect.get(value, key);
|
||||
} });
|
||||
};
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target, expectedLocalTarget,
|
||||
}), /Local download target content changed during replacement/);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "modified");
|
||||
});
|
||||
|
||||
test("remembered download stops if the selected file disappears at replacement", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-disappeared")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
const expectedLocalTarget = rememberedExpectation(root, target);
|
||||
const link = fs.promises.link;
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
fs.promises.link = async (from, to) => {
|
||||
if (from === target && String(to).endsWith(".backup")) fs.unlinkSync(target);
|
||||
return link(from, to);
|
||||
};
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target, expectedLocalTarget,
|
||||
}), /Remembered local download target disappeared/);
|
||||
assert.equal(fs.existsSync(target), false);
|
||||
});
|
||||
|
||||
test("remembered download keeps a backup edited after publication", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-late-edit")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
const expectedLocalTarget = rememberedExpectation(root, target);
|
||||
const link = fs.promises.link;
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
let edited = false;
|
||||
fs.promises.link = async (from, to) => {
|
||||
const result = await link(from, to);
|
||||
if (!edited && to === target && String(from).endsWith(".ready")) {
|
||||
edited = true;
|
||||
const backupName = fs.readdirSync(root).find((name) => name.endsWith(".backup"));
|
||||
assert.ok(backupName);
|
||||
fs.writeFileSync(path.join(root, backupName), "modified");
|
||||
}
|
||||
return result;
|
||||
};
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target, expectedLocalTarget,
|
||||
}), /Recovery backup preserved/);
|
||||
assert.equal(edited, true);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "download");
|
||||
const backupName = fs.readdirSync(root).find((name) => name.endsWith(".backup"));
|
||||
assert.ok(backupName);
|
||||
assert.equal(fs.readFileSync(path.join(root, backupName), "utf8"), "modified");
|
||||
});
|
||||
|
||||
test("replacement keeps large file numbers exact when checking its backup", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("large-backup-identity")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
const dev = 9007199254740993n;
|
||||
const ino = 9007199254740995n;
|
||||
const originalLstat = fs.promises.lstat;
|
||||
t.after(() => { fs.promises.lstat = originalLstat; });
|
||||
fs.promises.lstat = async (candidate, options) => {
|
||||
const stat = await originalLstat(candidate, options);
|
||||
if (!String(candidate).endsWith(".backup")) return stat;
|
||||
return new Proxy(stat, {
|
||||
get(value, key) {
|
||||
if (key === "dev") return options?.bigint ? dev : Number(dev);
|
||||
if (key === "ino") return options?.bigint ? ino : Number(ino);
|
||||
return Reflect.get(value, key);
|
||||
},
|
||||
});
|
||||
};
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
validateTarget: async () => ({
|
||||
stableIdentity: `${dev}:${ino}:${fs.statSync(target).size}`,
|
||||
existingMode: 0o644,
|
||||
targetIdentity: `${dev}:${ino}:original`,
|
||||
}),
|
||||
});
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "download");
|
||||
});
|
||||
|
||||
test("remembered download does not replace a different file created during transfer", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
const expectedLocalTarget = rememberedExpectation(root, target);
|
||||
|
||||
fs.renameSync(target, path.join(root, "moved-original"));
|
||||
fs.writeFileSync(target, "unrelated");
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget,
|
||||
}),
|
||||
/Remembered local download target changed/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "unrelated");
|
||||
assert.equal(fs.readFileSync(path.join(root, "moved-original"), "utf8"), "original");
|
||||
});
|
||||
|
||||
test("remembered download rejects a reused file number with a different creation time", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-inode")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "unrelated");
|
||||
const expectedLocalTarget = rememberedExpectation(root, target);
|
||||
expectedLocalTarget.targetBirthtimeNs = String(BigInt(expectedLocalTarget.targetBirthtimeNs) - 1n);
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget,
|
||||
}), /Remembered local download target changed/);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "unrelated");
|
||||
});
|
||||
|
||||
test("remembered download rejects a replaced parent even when the file is the same", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("remembered-parent")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const parent = path.join(root, "selected");
|
||||
const movedParent = path.join(root, "moved");
|
||||
const target = path.join(parent, "target");
|
||||
const staged = path.join(root, "staged");
|
||||
fs.mkdirSync(parent);
|
||||
fs.writeFileSync(target, "original");
|
||||
fs.writeFileSync(staged, "download");
|
||||
const expectedLocalTarget = rememberedExpectation(parent, target);
|
||||
|
||||
fs.renameSync(parent, movedParent);
|
||||
fs.mkdirSync(parent);
|
||||
fs.linkSync(path.join(movedParent, "target"), target);
|
||||
await assert.rejects(
|
||||
() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
requestedTargetPath: target,
|
||||
expectedLocalTarget,
|
||||
}),
|
||||
/Remembered local download target changed/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
for (const restore of [false, true]) {
|
||||
test(`local ${restore ? "restore" : "publish"} never overwrites a last-moment concurrent file`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-race")}-`);
|
||||
@@ -84,8 +1039,14 @@ for (const [failCopy, linkErrorCode] of [[false, "ENOTSUP"], [true, "ENOTSUP"],
|
||||
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
|
||||
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
|
||||
} else {
|
||||
await bridge._promoteLocalTransferForTests(staged, target);
|
||||
let publishedIdentity;
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
capturePublishedContentHash: true,
|
||||
onCommit(identity) { publishedIdentity = identity; },
|
||||
});
|
||||
assert.deepEqual(fs.readFileSync(target), payload);
|
||||
assert.equal(publishedIdentity?.sha256,
|
||||
require("node:crypto").createHash("sha256").update(payload).digest("hex"));
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+6
-2
@@ -190,8 +190,12 @@ declare global {
|
||||
owner?: string;
|
||||
group?: string;
|
||||
/** Local-only filesystem identity; undefined for remote SFTP stats. */
|
||||
dev?: number;
|
||||
ino?: number;
|
||||
dev?: string | number;
|
||||
ino?: string | number;
|
||||
/** Local-only nanosecond timestamps used to reject reused inode numbers. */
|
||||
birthtimeNs?: string;
|
||||
ctimeNs?: string;
|
||||
mtimeNs?: string;
|
||||
}
|
||||
|
||||
interface SftpTransferProgress {
|
||||
|
||||
@@ -231,6 +231,11 @@ export const STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY = 'netcatty_sftp_transfer_con
|
||||
export const STORAGE_KEY_SFTP_FOLDER_PRESCAN = 'netcatty_sftp_folder_prescan_v1';
|
||||
/** Skip files when target size + mtime already match the source (rsync-like). */
|
||||
export const STORAGE_KEY_SFTP_SKIP_UNCHANGED = 'netcatty_sftp_skip_unchanged_v1';
|
||||
/**
|
||||
* Opt in to reusing the exact Save As target for repeat downloads of one
|
||||
* remote file within the current SFTP view.
|
||||
*/
|
||||
export const STORAGE_KEY_SFTP_QUICK_DOWNLOAD = 'netcatty_sftp_quick_download_v1';
|
||||
/**
|
||||
* @deprecated Legacy transfer-pool idle TTL. No longer read; SSH keep-alive uses
|
||||
* STORAGE_KEY_SSH_TRANSPORT_IDLE_TTL_MS. Kept so old localStorage entries are ignored safely.
|
||||
|
||||
@@ -83,6 +83,21 @@ async function runFixture(root) {
|
||||
}));
|
||||
});
|
||||
const client = new SftpClient();
|
||||
const rememberedExpectation = (targetPath) => {
|
||||
const parent = path.dirname(targetPath);
|
||||
const parentStat = fs.statSync(parent, { bigint: true });
|
||||
const targetStat = fs.lstatSync(targetPath, { bigint: true });
|
||||
return {
|
||||
parentRealPath: fs.realpathSync(parent),
|
||||
parentIdentity: `${parentStat.dev}:${parentStat.ino}`,
|
||||
parentBirthtimeNs: String(parentStat.birthtimeNs),
|
||||
targetIdentity: `${targetStat.dev}:${targetStat.ino}`,
|
||||
targetBirthtimeNs: String(targetStat.birthtimeNs),
|
||||
targetCtimeNs: String(targetStat.ctimeNs),
|
||||
targetMtimeNs: String(targetStat.mtimeNs),
|
||||
targetSha256: crypto.createHash("sha256").update(fs.readFileSync(targetPath)).digest("hex"),
|
||||
};
|
||||
};
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
@@ -131,6 +146,34 @@ async function runFixture(root) {
|
||||
const outputDigest = crypto.createHash("sha256");
|
||||
for await (const chunk of fs.createReadStream(targetPath)) outputDigest.update(chunk);
|
||||
assert.equal(outputDigest.digest("hex"), digest);
|
||||
if (process.env.SFTP_LIVE_QUICK === "1") {
|
||||
const backupPath = path.join(root, `.${path.basename(targetPath)}.netcatty.backup`);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const expectedLocalTarget = rememberedExpectation(targetPath);
|
||||
const repeat = await bridge.startTransfer({ sender: { send() {} } }, {
|
||||
transferId: `live-repeat-${crypto.randomUUID()}`,
|
||||
sourcePath: "/source.bin", targetPath,
|
||||
sourceType: "sftp", targetType: "local", sourceSftpId: "source",
|
||||
totalBytes: bytes, expectedLocalTarget, capturePublishedContentHash: true,
|
||||
});
|
||||
assert.equal(repeat.error, undefined, repeat.error);
|
||||
assert.equal(crypto.createHash("sha256").update(fs.readFileSync(targetPath)).digest("hex"), digest);
|
||||
assert.equal(crypto.createHash("sha256").update(fs.readFileSync(backupPath)).digest("hex"), digest);
|
||||
}
|
||||
|
||||
const staleExpectation = rememberedExpectation(targetPath);
|
||||
const replacementPath = path.join(root, `replacement-${index}.bin`);
|
||||
fs.writeFileSync(replacementPath, "new local file");
|
||||
fs.renameSync(replacementPath, targetPath);
|
||||
const refused = await bridge.startTransfer({ sender: { send() {} } }, {
|
||||
transferId: `live-refuse-${crypto.randomUUID()}`,
|
||||
sourcePath: "/source.bin", targetPath,
|
||||
sourceType: "sftp", targetType: "local", sourceSftpId: "source",
|
||||
totalBytes: bytes, expectedLocalTarget: staleExpectation,
|
||||
});
|
||||
assert.match(refused.error || "", /remembered local download target changed/i);
|
||||
assert.equal(fs.readFileSync(targetPath, "utf8"), "new local file");
|
||||
}
|
||||
fs.unlinkSync(targetPath);
|
||||
return { index, bytes, elapsedMs: Math.round(performance.now() - startedAt), verificationMs: Math.round(verificationMs), ...control };
|
||||
};
|
||||
|
||||
+8
-1
@@ -63,6 +63,7 @@ declare global {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
expectedLocalTarget?: import("../../domain/models/sftp").LocalDownloadTargetExpectation;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
@@ -89,7 +90,13 @@ declare global {
|
||||
/** When true, skip main-process admission (renderer already scheduled). */
|
||||
skipAdmission?: boolean;
|
||||
}
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string; cancelled?: boolean }>;
|
||||
): Promise<{
|
||||
transferId: string;
|
||||
totalBytes?: number;
|
||||
error?: string;
|
||||
cancelled?: boolean;
|
||||
publishedLocalIdentity?: import("../../domain/models/sftp").LocalPublishedFileIdentity;
|
||||
}>;
|
||||
pauseTransfer?(transferId: string): Promise<{
|
||||
success: boolean;
|
||||
superseded?: boolean;
|
||||
|
||||
Reference in new issue
Block a user