Compare commits

...
Author SHA1 Message Date
bincxz d3aadd07d4 Remove temporary Windows identity diagnostics 2026-09-27 17:11:56 +08:00
bincxz cc1fd85ac5 Accept Windows parent aliases with matching file identity 2026-09-27 17:02:25 +08:00
bincxz 610e62a8da Log mismatched local identity fields in SFTP fixture 2026-09-27 16:54:41 +08:00
bincxz 2fec54b600 Bind recovery marker cleanup to owned file identity 2026-09-27 16:36:17 +08:00
bincxz e4d262021d test(sftp): match published file identity contract 2026-09-27 16:29:10 +08:00
bincxz 3e6f056a1c fix(sftp): retain backup inode before repeat overwrite 2026-09-27 16:27:31 +08:00
bincxz a547d0408f fix(sftp): verify Windows file identity for repeat downloads 2026-09-27 16:21:41 +08:00
bincxz 47e402c4e3 fix(sftp): preserve late backup replacement on cleanup failure 2026-09-27 16:12:51 +08:00
bincxz 94afcbb3f9 fix(sftp): stage guarded downloads even without resume 2026-09-27 16:05:43 +08:00
bincxz 56b7a2ae19 Merge main into PR #3516 and reconcile SFTP settings 2026-09-27 16:05:07 +08:00
netcatty-bot a6102a53ed fix: address Codex review on PR #3516 2026-09-25 20:59:21 +00:00
netcatty-bot 4ddde35ccc fix: address Codex review on PR #3516 2026-09-25 20:24:41 +00:00
netcatty-bot 136099f53a fix: address Codex review on PR #3516 2026-09-25 19:49:12 +00:00
netcatty-bot 3d0c8d1845 fix: address Codex review on PR #3516 2026-09-25 19:19:38 +00:00
netcatty-bot 52137618bc fix: address Codex review on PR #3516 2026-09-25 18:42:27 +00:00
netcatty-bot 01cd7f9519 fix: address Codex review on PR #3516 2026-09-25 18:17:41 +00:00
netcatty-bot b12cf0a174 fix: address Codex review on PR #3516 2026-09-25 17:42:20 +00:00
netcatty-bot ea5352d648 fix: address Codex review on PR #3516 2026-09-25 17:15:51 +00:00
netcatty-bot 4697dc53e1 fix: address Codex review on PR #3516 2026-09-25 16:44:12 +00:00
netcatty-bot 1c996b3cce fix: address Codex review on PR #3516 2026-09-25 16:03:35 +00:00
netcatty-bot fe692382c6 fix: address Codex review on PR #3516 2026-09-25 15:35:08 +00:00
netcatty-bot d41d39dd38 fix: address Codex review on PR #3516 2026-09-25 14:57:18 +00:00
netcatty-bot 50fc6e60cd fix: address Codex review on PR #3516 2026-09-25 14:12:32 +00:00
netcatty-bot 21f906152f fix: address Codex review on PR #3516 2026-09-25 13:40:41 +00:00
netcatty-bot 2521b5eaae fix: address Codex review on PR #3516 2026-09-25 13:04:30 +00:00
netcatty-bot 49c22ff3a7 fix: address Codex review on PR #3516 2026-09-25 12:19:39 +00:00
netcatty-bot 388c374a44 fix: address Codex review on PR #3516 2026-09-25 11:44:06 +00:00
netcatty-bot 0f6e0c14fd fix: address Codex review on PR #3516 2026-09-25 11:04:52 +00:00
netcatty-bot 8c6d15de90 fix: address Codex review on PR #3516 2026-09-25 10:31:47 +00:00
netcatty-bot e18eaf3b58 fix: address Codex review on PR #3516 2026-09-25 10:06:52 +00:00
netcatty-bot ba61443b96 fix: address Codex review on PR #3516 2026-09-25 09:29:13 +00:00
netcatty-bot 6c1944d1c0 fix: address Codex review on PR #3516 2026-09-25 09:00:01 +00:00
bincxz cff4aa1492 fix(sftp): preserve backup if edited after publication 2026-09-25 16:19:14 +08:00
bincxz da60255993 fix(sftp): recheck remembered backup after content verification 2026-09-25 16:12:33 +08:00
bincxz 3be24c4fa7 fix(sftp): verify remembered file content before replacement 2026-09-25 16:11:13 +08:00
bincxz 519e07e38b fix(sftp): bind first Save As download to selected source 2026-09-25 16:04:16 +08:00
bincxz 778dddb7d2 fix(sftp): verify published content before remembering target 2026-09-25 15:54:55 +08:00
bincxz 422d9d0a7b fix(sftp): recheck repeat download route at transfer start 2026-09-25 15:43:09 +08:00
bincxz 4ec328a2c3 fix(sftp): preserve concurrent local edits during repeat download 2026-09-25 15:37:38 +08:00
bincxz 963d1c1a64 fix(sftp): bind repeat downloads to full source route 2026-09-25 15:28:56 +08:00
bincxz efb664f4a8 fix(sftp): keep backup identity exact during publish 2026-09-25 15:14:31 +08:00
bincxz 748d838824 fix(sftp): preserve large local file identities 2026-09-25 15:08:27 +08:00
bincxz 210711ffe9 fix(sftp): remember only published download target 2026-09-24 18:40:44 +08:00
bincxz 27a7f0f66e fix(sftp): reject reused local file identities 2026-09-24 18:31:34 +08:00
bincxz bc7fdddc3c fix(sftp): retain guarded target across transfer resume 2026-09-24 18:21:20 +08:00
bincxz f459426d95 fix(sftp): verify remembered target at download publication 2026-09-24 18:11:12 +08:00
bincxz 375ec93a8b merge latest main into SFTP quick download draft 2026-09-24 17:51:24 +08:00
bincxz cdeff7bc24 merge main into SFTP quick download draft 2026-09-24 17:50:50 +08:00
bincxz d38177d7dd fix(sftp): verify remembered file identity before reuse 2026-09-24 17:43:02 +08:00
bincxz 53e38a3f4c fix(sftp): isolate quick targets by endpoint and clear disabled targets 2026-09-24 17:39:01 +08:00
bincxz 3992ba7a06 fix(sftp): detect changed local destination identity 2026-09-24 17:34:15 +08:00
bincxz 8a97365448 fix(sftp): scope quick download to selected file target 2026-09-24 17:28:14 +08:00
netcatty-bot c048f8c4b5 fix: address Codex review on PR #3516 2026-09-24 08:57:57 +00:00
netcatty-bot 0eefc20be5 fix: address Codex review on PR #3516 2026-09-24 08:14:27 +00:00
netcatty-bot 23a403f078 fix: address Codex review on PR #3516 2026-09-24 07:46:43 +00:00
netcatty-bot 30f94c5f31 fix: address Codex review on PR #3516 2026-09-24 07:19:34 +00:00
netcatty-bot ca2c354638 fix(#3514): automated AI fix 2026-09-24 06:39:07 +00:00
48 changed files with 3289 additions and 116 deletions

No files matched your search

+15
View File
@@ -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
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': '選擇主題',
+7
View File
@@ -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,
+15 -4
View File
@@ -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({
+15 -1
View File
@@ -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),
);
}
+4 -4
View File
@@ -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[] = [];
+116 -19
View File
@@ -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))}`;
}
+22 -3
View File
@@ -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]);
}
+180 -9
View File
@@ -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;
+19
View File
@@ -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",
+24
View File
@@ -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(
+22 -3
View File
@@ -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,
]),
+70 -18
View File
@@ -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(),
+5
View File
@@ -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')}
+4 -1
View File
@@ -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;
}
});
+57 -7
View File
@@ -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;
}
});
+28
View File
@@ -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;
+48
View File
@@ -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",
+36
View File
@@ -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;
}
+1
View File
@@ -328,6 +328,7 @@ export interface SyncPayload {
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpSkipUnchanged?: boolean;
sftpQuickDownload?: boolean;
sftpAutoOpenSidebar?: boolean;
sftpFollowTerminalCwd?: boolean;
sftpDefaultViewMode?: 'list' | 'tree';
+13 -5
View File
@@ -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 {
+15 -6
View File
@@ -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 });
}
+25 -11
View File
@@ -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),
} : {}),
};
}
+27
View File
@@ -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 });
}
+755 -17
View File
@@ -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
View File
@@ -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 {
+5
View File
@@ -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
View File
@@ -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;