mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-14 10:56:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bad2f4939 | ||
|
|
ee06726153 | ||
|
|
69de51a61d | ||
|
|
3096f86889 | ||
|
|
94f8bd9bc7 | ||
|
|
5cd42cb518 | ||
|
|
9ccb62ea07 |
No files matched your search
@@ -1220,6 +1220,23 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
}
|
||||
}
|
||||
|
||||
export function startRestoredSessionConnectionLogImpl(getCtx: AppContextGetter, session: TerminalSession) {
|
||||
const { addConnectionLog, systemInfoRef } = getCtx();
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
addConnectionLog({
|
||||
sessionId: session.id,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
protocol: session.protocol || 'ssh',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
|
||||
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
|
||||
{
|
||||
|
||||
@@ -104,7 +104,7 @@ import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { createOpenTerminalQueue } from './openTerminalQueue';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, startRestoredSessionConnectionLogImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
@@ -1895,8 +1895,18 @@ export function AppSideEffects() {
|
||||
setActiveTabId('vault');
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const restoredSessionLogsStartedRef = useRef(new Set<string>());
|
||||
// A restored tab retains its session ID, but its previous ConnectionLog is closed.
|
||||
// Start the new incarnation's log before updateSessionStatus clears restoreState.
|
||||
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
|
||||
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
const session = sessionByIdRef.current.get(sessionId);
|
||||
if (session?.restoreState === 'restored-disconnected'
|
||||
&& (status === 'connecting' || status === 'connected')
|
||||
&& !restoredSessionLogsStartedRef.current.has(sessionId)) {
|
||||
restoredSessionLogsStartedRef.current.add(sessionId);
|
||||
startRestoredSessionConnectionLogImpl(() => ({ addConnectionLog, systemInfoRef }), session);
|
||||
}
|
||||
updateSessionStatus(sessionId, status);
|
||||
if (status === 'connected') {
|
||||
const session = sessionByIdRef.current.get(sessionId);
|
||||
@@ -1904,7 +1914,7 @@ export function AppSideEffects() {
|
||||
updateHostLastConnected(session.hostId);
|
||||
}
|
||||
}
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
}, [addConnectionLog, updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
|
||||
// Functional update + identity-preserving updateHosts: only the patched
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { installBrowserLockFixture } from '../testSupport/browserLockFixture';
|
||||
import { useSessionState } from '../state/useSessionState';
|
||||
import { captureClosingTerminalSessions, useTerminalSessionCloseCapture } from '../state/terminalSessionCloseCapture';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { createConnectionLogBuffer } from '../../components/terminal/connectionLogBuffer';
|
||||
import { TerminalConnectionProgress } from '../../components/terminal/TerminalConnectionProgress';
|
||||
import { I18nProvider } from '../i18n/I18nProvider';
|
||||
import { handleTerminalDataCaptureImpl } from './AppHandlers';
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
test('explicit close persists never-connected logs; runtime remounts leave them open', async (t) => {
|
||||
const dom = new JSDOM('', { url: 'http://localhost' });
|
||||
const globals = ['window', 'localStorage', 'CustomEvent', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'];
|
||||
const previous = new Map(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
Object.defineProperties(globalThis, {
|
||||
window: { value: dom.window, configurable: true },
|
||||
localStorage: { value: dom.window.localStorage, configurable: true },
|
||||
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
|
||||
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
|
||||
});
|
||||
installBrowserLockFixture();
|
||||
const { useVaultState } = await import('../state/useVaultState');
|
||||
try {
|
||||
for (const scenario of [
|
||||
{ name: 'tab X before connected, empty buffer', status: 'connecting', data: '' },
|
||||
{ name: 'failed-dialog close, empty buffer', status: 'disconnected', data: '' },
|
||||
{ name: 'failed-dialog close preserves buffered diagnostics', status: 'disconnected', data: 'SSH diagnostic\r\n' },
|
||||
{ name: 'batch close before connected', status: 'connecting', data: '', closeMode: 'batch' },
|
||||
{ name: 'workspace close before connected', status: 'connecting', data: '', closeMode: 'workspace' },
|
||||
] as const) {
|
||||
await t.test(scenario.name, async () => {
|
||||
let vault!: ReturnType<typeof useVaultState>;
|
||||
let state!: ReturnType<typeof useSessionState>;
|
||||
let root: ReactTestRenderer | undefined;
|
||||
let sessionId!: string;
|
||||
let logId!: string;
|
||||
const captures: Promise<void>[] = [];
|
||||
const capturedData: string[] = [];
|
||||
function TerminalProbe() {
|
||||
const hasConnectedRef = useRef(false);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const buffer = useRef(createConnectionLogBuffer(10000));
|
||||
useEffect(() => {
|
||||
buffer.current.append(scenario.data);
|
||||
return () => { buffer.current.reset(); };
|
||||
}, []);
|
||||
useTerminalSessionCloseCapture({
|
||||
sessionId, enabled: true, hasConnectedRef, terminalDataCapturedRef,
|
||||
finalizeTerminalLogData: () => buffer.current.toString(),
|
||||
onTerminalDataCapture: (id, data, options) => {
|
||||
assert.equal(options.finalized, true);
|
||||
assert.ok(state.sessions.some((session) => session.id === id), 'capture starts before removal');
|
||||
terminalDataCapturedRef.current = true;
|
||||
capturedData.push(data);
|
||||
const pending = handleTerminalDataCaptureImpl(() => ({
|
||||
IS_DEV: false, connectionLogs: vault.connectionLogs, sessions: state.sessions,
|
||||
selectConnectionLogForTerminalDataCapture, updateConnectionLog: vault.updateConnectionLog,
|
||||
}), id, data);
|
||||
captures.push(pending);
|
||||
return pending;
|
||||
},
|
||||
});
|
||||
return scenario.status === 'disconnected' ? React.createElement(
|
||||
I18nProvider, { locale: 'en' }, React.createElement(TerminalConnectionProgress, {
|
||||
status: 'disconnected', error: 'Connection refused', timeLeft: 0,
|
||||
isCancelling: false, showLogs: false, progressLogs: [],
|
||||
onCancelConnect: () => {}, onCloseSession: () => state.closeSession(sessionId),
|
||||
}),
|
||||
) : null;
|
||||
}
|
||||
function Probe({ showTerminal = true }) {
|
||||
vault = useVaultState();
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return showTerminal && state.sessions.some((session) => session.id === sessionId)
|
||||
? React.createElement(TerminalProbe) : null;
|
||||
}
|
||||
const mount = async () => {
|
||||
await act(async () => { root = create(React.createElement(Probe)); });
|
||||
for (let attempt = 0; !vault.isInitialized && attempt < 50; attempt++) {
|
||||
await act(async () => { await tick(); });
|
||||
}
|
||||
assert.equal(vault.isInitialized, true);
|
||||
};
|
||||
try {
|
||||
await mount();
|
||||
await act(async () => { sessionId = state.createLocalTerminal(); });
|
||||
await act(async () => {
|
||||
state.updateSessionStatus(sessionId, scenario.status);
|
||||
const session = state.sessions.find((candidate) => candidate.id === sessionId)!;
|
||||
logId = vault.addConnectionLog({
|
||||
sessionId, hostId: session.hostId, hostLabel: session.hostLabel,
|
||||
hostname: session.hostname, username: session.username,
|
||||
protocol: session.protocol, startTime: Date.now(),
|
||||
localUsername: 'local-user', localHostname: 'local-host', saved: false,
|
||||
});
|
||||
await vault.updateConnectionLog(logId, {});
|
||||
});
|
||||
if ('closeMode' in scenario && scenario.closeMode === 'workspace') {
|
||||
let otherId!: string;
|
||||
await act(async () => { otherId = state.createLocalTerminal(); });
|
||||
await act(async () => { state.createWorkspaceFromSessions(sessionId, otherId, 'right'); });
|
||||
}
|
||||
// Recycle the renderer while retaining the session, as with a pane
|
||||
// remount. Neither disposal nor the replacement runtime may end its log.
|
||||
await act(async () => { root!.update(React.createElement(Probe, { showTerminal: false })); });
|
||||
await act(async () => { root!.update(React.createElement(Probe)); });
|
||||
assert.equal(captures.length, 0);
|
||||
assert.equal(vault.connectionLogs.find((log) => log.id === logId)?.endTime, undefined);
|
||||
|
||||
// Both UI actions converge here. This exercises the actual state
|
||||
// removal, owning-terminal hook, capture handler and vault persistence.
|
||||
await act(async () => {
|
||||
if ('closeMode' in scenario && scenario.closeMode === 'workspace') {
|
||||
state.closeWorkspace(state.workspaces[0].id);
|
||||
} else if ('closeMode' in scenario && scenario.closeMode === 'batch') {
|
||||
state.closeSessions([sessionId]);
|
||||
} else if (scenario.status === 'disconnected') {
|
||||
// Click the actual Close button rendered inside the failed dialog.
|
||||
root!.root.findByType('button').props.onClick();
|
||||
} else {
|
||||
state.closeSession(sessionId);
|
||||
}
|
||||
await Promise.all(captures);
|
||||
});
|
||||
assert.equal(state.sessions.some((session) => session.id === sessionId), false);
|
||||
assert.deepEqual(capturedData, [scenario.data]);
|
||||
await act(async () => { state.closeSession(sessionId); });
|
||||
assert.equal(captures.length, 1, 'removed runtimes must unregister their capture');
|
||||
await act(async () => { root!.unmount(); });
|
||||
root = undefined;
|
||||
await mount();
|
||||
const persisted = vault.connectionLogs.find((log) => log.id === logId)!;
|
||||
assert.ok(persisted.endTime, 'a durable endTime must exist even without terminal output');
|
||||
assert.equal(persisted.terminalData || '', scenario.data);
|
||||
} finally {
|
||||
await act(async () => { root?.unmount(); });
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dom.window.close();
|
||||
for (const key of globals) {
|
||||
const descriptor = previous.get(key);
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('close capture skips non-owning, already connected and already captured runtimes', async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
try {
|
||||
for (const scenario of [
|
||||
{ name: 'attached popup / inactive restored placeholder', enabled: false, connected: false, captured: false },
|
||||
{ name: 'connected terminal uses existing drain path', enabled: true, connected: true, captured: false },
|
||||
{ name: 'quit already captured the log', enabled: true, connected: false, captured: true },
|
||||
]) {
|
||||
await t.test(scenario.name, async () => {
|
||||
let calls = 0;
|
||||
let root!: ReactTestRenderer;
|
||||
function Probe() {
|
||||
useTerminalSessionCloseCapture({
|
||||
sessionId: 'guarded', enabled: scenario.enabled,
|
||||
hasConnectedRef: useRef(scenario.connected),
|
||||
terminalDataCapturedRef: useRef(scenario.captured),
|
||||
finalizeTerminalLogData: () => { calls++; return ''; },
|
||||
onTerminalDataCapture: () => { calls++; },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
await act(async () => { root = create(React.createElement(Probe)); });
|
||||
try {
|
||||
await act(async () => { captureClosingTerminalSessions(['guarded']); });
|
||||
assert.equal(calls, 0);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (previous) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
}
|
||||
});
|
||||
|
||||
test('both UI close paths reach session removal and Terminal registers the owning capture', () => {
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const app = read('./AppView.tsx');
|
||||
assert.match(app, /onCloseSession=\{closeSession\}/);
|
||||
assert.match(read('../../components/top-tabs/TopTabItems.tsx'), /onClick=\{\(e\) => onCloseSession\(session\.id, e\)\}/);
|
||||
const terminal = read('../../components/Terminal.tsx');
|
||||
assert.match(terminal, /useTerminalSessionCloseCapture\(\{\s*sessionId,\s*enabled: !attachExistingSession && restoreState !== "restored-disconnected",\s*hasConnectedRef,\s*terminalDataCapturedRef,\s*finalizeTerminalLogData,\s*onTerminalDataCapture: handleTerminalDataCaptureOnce,/);
|
||||
const handler = terminal.slice(terminal.indexOf('const handleCloseDisconnectedSession ='), terminal.indexOf('const handleHostKeyClose ='));
|
||||
assert.match(handler, /onCloseSession\?\.\(sessionId\)/);
|
||||
assert.match(read('../../components/TerminalLayer.tsx'), /onCloseSession\(sessionId\)/);
|
||||
assert.match(read('../../components/terminal/TerminalView.tsx'), /onCloseSession: handleCloseDisconnectedSession/);
|
||||
assert.match(read('../../components/terminal/TerminalConnectionProgress.tsx'), /onClick=\{onCloseSession\}/);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { installBrowserLockFixture } from '../testSupport/browserLockFixture';
|
||||
import { withVaultImportLock } from '../state/vaultManagedImportLock';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import type { TerminalSession } from '../../domain/models';
|
||||
import { createConnectionLogBuffer } from '../../components/terminal/connectionLogBuffer';
|
||||
import { handleTerminalDataCaptureImpl, startRestoredSessionConnectionLogImpl } from './AppHandlers';
|
||||
import { createPreloadApi } from '../../electron/preload/api.cjs';
|
||||
import { finalizeSessionLogs } from '../../electron/bridges/sessionLogShutdown.cjs';
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
test('quit persists replay before acknowledgment; restored reconnect keeps the old log and starts a new one', async () => {
|
||||
const dom = new JSDOM('', { url: 'http://localhost' });
|
||||
const globals = ['window', 'localStorage', 'CustomEvent', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'];
|
||||
const previous = new Map(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
Object.defineProperties(globalThis, {
|
||||
window: { value: dom.window, configurable: true },
|
||||
localStorage: { value: dom.window.localStorage, configurable: true },
|
||||
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
|
||||
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
|
||||
});
|
||||
installBrowserLockFixture();
|
||||
const { useVaultState } = await import('../state/useVaultState');
|
||||
let vault!: ReturnType<typeof useVaultState>;
|
||||
let root: ReactTestRenderer | undefined;
|
||||
const Probe = () => { vault = useVaultState(); return null; };
|
||||
const mount = async () => {
|
||||
await act(async () => { root = create(React.createElement(Probe)); });
|
||||
for (let attempt = 0; !vault.isInitialized && attempt < 50; attempt++) {
|
||||
await act(async () => { await tick(); });
|
||||
}
|
||||
assert.equal(vault.isInitialized, true);
|
||||
};
|
||||
const session: TerminalSession = {
|
||||
id: 'restorable-tab', hostId: 'host', hostLabel: 'Host', hostname: 'host.test',
|
||||
username: 'user', protocol: 'ssh', status: 'connected',
|
||||
};
|
||||
const systemInfoRef = { current: { username: 'local-user', hostname: 'local-host' } };
|
||||
const capture = (data: string) => handleTerminalDataCaptureImpl(() => ({
|
||||
IS_DEV: false, connectionLogs: vault.connectionLogs, sessions: [session],
|
||||
selectConnectionLogForTerminalDataCapture, updateConnectionLog: vault.updateConnectionLog,
|
||||
}), session.id, data);
|
||||
try {
|
||||
await mount();
|
||||
let oldId!: string;
|
||||
await act(async () => {
|
||||
oldId = vault.addConnectionLog({
|
||||
sessionId: session.id, hostId: session.hostId, hostLabel: session.hostLabel,
|
||||
hostname: session.hostname, username: session.username, protocol: 'ssh', startTime: 1,
|
||||
localUsername: 'local-user', localHostname: 'local-host', saved: false,
|
||||
});
|
||||
// Wait for the initial catalog write before blocking the capture write.
|
||||
await vault.updateConnectionLog(oldId, {});
|
||||
});
|
||||
const buffer = createConnectionLogBuffer(10000);
|
||||
buffer.append('output before quitting\r\n');
|
||||
const ipcMain = new EventEmitter();
|
||||
const ipcRenderer = new EventEmitter() as EventEmitter & { send: (channel: string, payload: unknown) => void };
|
||||
const webContents = { send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, payload) };
|
||||
ipcRenderer.send = (channel, payload) => ipcMain.emit(channel, { sender: webContents }, payload);
|
||||
const api = createPreloadApi({ ipcRenderer });
|
||||
const off = api.onFinalizeSessionLogs(() => capture(buffer.toString()));
|
||||
let release!: () => void;
|
||||
const blockedWrite = withVaultImportLock('connection-logs', () => new Promise<void>((resolve) => { release = resolve; }));
|
||||
await tick();
|
||||
let acknowledged = false;
|
||||
await act(async () => {
|
||||
const quit = finalizeSessionLogs(webContents, { ipcMain }).then((success: boolean) => {
|
||||
acknowledged = true;
|
||||
assert.equal(success, true);
|
||||
});
|
||||
await tick();
|
||||
assert.equal(acknowledged, false, 'main must wait for the persistence queue');
|
||||
release();
|
||||
await blockedWrite;
|
||||
await quit;
|
||||
});
|
||||
off();
|
||||
// Remounting reads durable storage. Node has no IndexedDB, so this also
|
||||
// exercises the existing localStorage replay fallback.
|
||||
await act(async () => { root?.unmount(); });
|
||||
await mount();
|
||||
const oldLog = vault.connectionLogs.find((log) => log.id === oldId)!;
|
||||
assert.ok(oldLog.endTime);
|
||||
assert.equal(oldLog.terminalData, 'output before quitting\r\n');
|
||||
await act(async () => {
|
||||
startRestoredSessionConnectionLogImpl(() => ({ addConnectionLog: vault.addConnectionLog, systemInfoRef }), {
|
||||
...session, status: 'disconnected', restoreState: 'restored-disconnected',
|
||||
});
|
||||
});
|
||||
const newLog = vault.connectionLogs.find((log) => log.id !== oldId)!;
|
||||
assert.equal(newLog.sessionId, session.id);
|
||||
assert.equal(newLog.endTime, undefined);
|
||||
assert.equal(newLog.terminalData, undefined);
|
||||
await act(async () => { await capture('output after reconnect\r\n'); });
|
||||
await act(async () => { root?.unmount(); });
|
||||
await mount();
|
||||
assert.equal(vault.connectionLogs.find((log) => log.id === oldId)?.terminalData, oldLog.terminalData);
|
||||
assert.equal(vault.connectionLogs.find((log) => log.id === oldId)?.endTime, oldLog.endTime);
|
||||
assert.equal(vault.connectionLogs.find((log) => log.id === newLog.id)?.terminalData, 'output after reconnect\r\n');
|
||||
assert.ok(vault.connectionLogs.find((log) => log.id === newLog.id)?.endTime);
|
||||
} finally {
|
||||
await act(async () => { root?.unmount(); });
|
||||
dom.window.close();
|
||||
for (const key of globals) {
|
||||
const descriptor = previous.get(key);
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('terminal quit listener drains output and awaits capture without tearing down tabs', () => {
|
||||
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
|
||||
const start = source.indexOf('onFinalizeSessionLogs?.(async () => {');
|
||||
assert.ok(start > 0);
|
||||
const listener = source.slice(start, source.indexOf('const attachHomeWebContentsIdRef', start));
|
||||
assert.match(listener, /await flushPendingTerminalWritesBeforeHibernate\(term\)/);
|
||||
assert.match(listener, /await handleTerminalDataCaptureOnce\(sessionId, finalizeTerminalLogData\(\), \{ finalized: true \}\)/);
|
||||
assert.doesNotMatch(listener, /closeSession|teardown/);
|
||||
const effects = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const handler = effects.slice(effects.indexOf('const handleSessionStatusChange ='), effects.indexOf('const handleUpdateHostFromTerminal ='));
|
||||
assert.ok(handler.indexOf('startRestoredSessionConnectionLogImpl(') < handler.indexOf('updateSessionStatus(sessionId, status)'));
|
||||
assert.match(handler, /restoredSessionLogsStartedRef\.current\.has\(sessionId\)/);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
import { buildCacheKey } from "./sharedRemoteHostCache";
|
||||
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
|
||||
@@ -999,3 +1000,86 @@ test("no-conflict local copy awaits discovery so a late stat cannot overwrite th
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of ["folder-cp", "folder-fallback", "file-cp", "different-key", "missing-key", "non-utf8"] as const) {
|
||||
test(`fresh remote copy selects a consistent strategy: ${scenario} (#4207)`, async () => {
|
||||
const isDirectory = scenario.startsWith("folder");
|
||||
const source = makePane("left");
|
||||
const target = makePane("left");
|
||||
target.connection = { ...target.connection!, id: "target-conn", currentPath: "/usr/tmp" };
|
||||
if (scenario === "non-utf8") target.filenameEncoding = "gb18030";
|
||||
const key = buildCacheKey("host-1", "debian", 22, "ssh", false, "user", "sftp");
|
||||
const keys = new Map([["remote-conn", key], ["target-conn", key]]);
|
||||
if (scenario === "different-key") keys.set("target-conn", buildCacheKey("host-2", "debian", 22, "ssh", false, "user", "sftp"));
|
||||
if (scenario === "missing-key") keys.delete("target-conn");
|
||||
const ownerId = `same-host-${scenario}-4207`;
|
||||
const streams: Array<{ resumable: boolean; sameHost?: boolean; parentTaskId?: string }> = [];
|
||||
const copies: unknown[][] = [];
|
||||
let listings = 0;
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const restore = installGlobals({
|
||||
statSftp: async (id: string) => id === "sftp-source"
|
||||
? { type: "file", size: 3626, lastModified: 1758000001000 }
|
||||
: null,
|
||||
mkdirSftp: async () => undefined,
|
||||
sameHostCopyDirectory: async (...args: unknown[]) => {
|
||||
copies.push(args);
|
||||
assert.equal(sftpTransferCenterStore.getTask(args[4] as string)?.resumable, false);
|
||||
return { success: scenario !== "folder-fallback" };
|
||||
},
|
||||
startStreamTransfer: async (options: { transferId: string; resumable: boolean; sameHost?: boolean; parentTaskId?: string }) => {
|
||||
streams.push(options);
|
||||
assert.equal(sftpTransferCenterStore.getTask(options.transferId)?.resumable, options.resumable);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: options.transferId,
|
||||
transferred: 3626, totalBytes: 3626, lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => side === "left" ? source : target,
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-source"], ["target-conn", "sftp-target"]]) },
|
||||
connectionCacheKeyMapRef: { current: keys },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => { listings++; return source.files; },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const results = await ops!.startTransfer(
|
||||
[{ name: isDirectory ? "bt_mine" : "aops_dns_view_demo.sh", isDirectory }], "left", "right",
|
||||
);
|
||||
assert.equal(results[0]?.status, "completed");
|
||||
});
|
||||
const eligible = ["folder-cp", "folder-fallback", "file-cp"].includes(scenario);
|
||||
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
assert.ok(rows.length > 0);
|
||||
assert.ok(rows.every(row => row.resumable === !eligible));
|
||||
assert.equal(copies.length, isDirectory ? 1 : 0);
|
||||
if (isDirectory) assert.deepEqual(copies[0]?.slice(0, 4), ["sftp-source", "/opt/bt_mine", "/usr/tmp/bt_mine", "auto"]);
|
||||
assert.equal(listings, scenario === "folder-fallback" ? 1 : 0);
|
||||
assert.equal(streams.length, scenario === "folder-cp" ? 0 : 1);
|
||||
if (streams.length) {
|
||||
assert.equal(streams[0].resumable, !eligible);
|
||||
assert.equal(streams[0].sameHost, !["different-key", "missing-key"].includes(scenario) || undefined);
|
||||
if (isDirectory) assert.ok(streams[0].parentTaskId);
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1348,6 +1348,17 @@ export const useSftpTransfers = ({
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
const batchId = crypto.randomUUID();
|
||||
const usesLegacyScp = sourcePane.connection.fileProtocol === "scp" || targetPane.connection.fileProtocol === "scp";
|
||||
const sourceCacheKey = connectionCacheKeyMapRef.current.get(sourcePane.connection.id);
|
||||
const targetCacheKey = connectionCacheKeyMapRef.current.get(targetPane.connection.id);
|
||||
const encodingSafeForExec = [sourcePane.filenameEncoding, targetPane.filenameEncoding]
|
||||
.every((encoding) => !encoding || encoding === "utf-8" || encoding === "auto");
|
||||
// Select the existing non-resumable cp route only for newly created copies.
|
||||
// Resume/recovery keeps its saved strategy and checkpoints. If cp fails,
|
||||
// recursive/download+upload fallback also remains non-resumable.
|
||||
const useSameHostCopy = !usesLegacyScp
|
||||
&& !sourcePane.connection.isLocal && !targetPane.connection.isLocal
|
||||
&& !!sourceCacheKey && sourceCacheKey === targetCacheKey
|
||||
&& encodingSafeForExec;
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
const skippedResults: TransferResult[] = [];
|
||||
@@ -1582,8 +1593,10 @@ export const useSftpTransfers = ({
|
||||
sourceLastModified,
|
||||
preflightStatSkipped: preflightSkippedNames.has(file.name),
|
||||
origin: "manual",
|
||||
resumable: !usesLegacyScp,
|
||||
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
|
||||
resumable: !usesLegacyScp && !useSameHostCopy,
|
||||
pauseUnavailableReason: usesLegacyScp
|
||||
? "This server uses legacy SCP; cancel and retry from the beginning instead"
|
||||
: useSameHostCopy ? "Same-host copies cannot be paused; cancel and retry from the beginning instead" : undefined,
|
||||
phase: file.isDirectory ? "scanning" : "transferring",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
import { logger } from '../../lib/logger';
|
||||
|
||||
const closeCaptures = new Map<string, Set<() => void>>();
|
||||
|
||||
/** Notify owning terminals before explicit session removal, never on runtime cleanup. */
|
||||
export function captureClosingTerminalSessions(sessionIds: readonly string[]): void {
|
||||
for (const sessionId of new Set(sessionIds)) {
|
||||
for (const capture of closeCaptures.get(sessionId) ?? []) capture();
|
||||
}
|
||||
}
|
||||
|
||||
export function useTerminalSessionCloseCapture({
|
||||
sessionId,
|
||||
enabled,
|
||||
hasConnectedRef,
|
||||
terminalDataCapturedRef,
|
||||
finalizeTerminalLogData,
|
||||
onTerminalDataCapture,
|
||||
}: {
|
||||
sessionId: string;
|
||||
enabled: boolean;
|
||||
hasConnectedRef: RefObject<boolean>;
|
||||
terminalDataCapturedRef: RefObject<boolean>;
|
||||
finalizeTerminalLogData: () => string;
|
||||
onTerminalDataCapture: (
|
||||
sessionId: string,
|
||||
data: string,
|
||||
options: { finalized: true },
|
||||
) => void | Promise<void>;
|
||||
}): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const capture = () => {
|
||||
// Connected terminals retain their existing drain/serialize close path.
|
||||
if (hasConnectedRef.current || terminalDataCapturedRef.current) return;
|
||||
try {
|
||||
// A never-connected runtime is disposed synchronously on unmount.
|
||||
// Finish its log now, even if empty, so persistence can set endTime.
|
||||
void Promise.resolve(onTerminalDataCapture(
|
||||
sessionId, finalizeTerminalLogData(), { finalized: true },
|
||||
)).catch((err: unknown) => {
|
||||
logger.warn('Failed to capture terminal log on session close', err);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to capture terminal log on session close', err);
|
||||
}
|
||||
};
|
||||
const captures = closeCaptures.get(sessionId) ?? new Set<() => void>();
|
||||
closeCaptures.set(sessionId, captures);
|
||||
captures.add(capture);
|
||||
return () => {
|
||||
captures.delete(capture);
|
||||
if (!captures.size) closeCaptures.delete(sessionId);
|
||||
};
|
||||
}, [sessionId, enabled, hasConnectedRef, terminalDataCapturedRef, finalizeTerminalLogData, onTerminalDataCapture]);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
getSessionConnectionLabel,
|
||||
} from '../../domain/sessionTabTitle';
|
||||
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
|
||||
import { captureClosingTerminalSessions } from './terminalSessionCloseCapture';
|
||||
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
|
||||
import { useVaultSnapshotField } from './vaultSnapshotStore';
|
||||
|
||||
@@ -603,6 +604,7 @@ export const useSessionState = ({
|
||||
const closedIds = sessionsRef.current
|
||||
.filter(session => session.workspaceId === workspaceId)
|
||||
.map(session => session.id);
|
||||
captureClosingTerminalSessions(closedIds);
|
||||
cleanupClosedTerminalSessions(closedIds);
|
||||
for (const sessionId of closedIds) {
|
||||
sessionPresentationStore.clearSession(sessionId);
|
||||
@@ -626,6 +628,7 @@ export const useSessionState = ({
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const closeSessions = useCallback((sessionIds: string[]) => {
|
||||
captureClosingTerminalSessions(sessionIds);
|
||||
cleanupClosedTerminalSessions(sessionIds);
|
||||
for (const sessionId of sessionIds) {
|
||||
sessionPresentationStore.clearSession(sessionId);
|
||||
|
||||
@@ -82,6 +82,7 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
import { useTerminalSessionCloseCapture } from "../application/state/terminalSessionCloseCapture";
|
||||
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
|
||||
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
|
||||
import {
|
||||
@@ -1676,6 +1677,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return captureHandler(capturedSessionId, capturedData);
|
||||
}, [finalizeTerminalLogData]);
|
||||
|
||||
useTerminalSessionCloseCapture({
|
||||
sessionId,
|
||||
enabled: !attachExistingSession && restoreState !== "restored-disconnected",
|
||||
hasConnectedRef,
|
||||
terminalDataCapturedRef,
|
||||
finalizeTerminalLogData,
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Attached popups do not own the ConnectionLog; the home terminal captures it.
|
||||
if (attachExistingSession) return;
|
||||
return netcattyBridge.get()?.onFinalizeSessionLogs?.(async () => {
|
||||
if (terminalDataCapturedRef.current || (restoreState === "restored-disconnected" && statusRef.current === "disconnected")) return;
|
||||
const term = termRef.current;
|
||||
if (term && !await flushPendingTerminalWritesBeforeHibernate(term)) {
|
||||
throw new Error("Terminal output did not drain before quit capture");
|
||||
}
|
||||
await handleTerminalDataCaptureOnce(sessionId, finalizeTerminalLogData(), { finalized: true });
|
||||
});
|
||||
}, [attachExistingSession, restoreState, sessionId, finalizeTerminalLogData, handleTerminalDataCaptureOnce]);
|
||||
|
||||
const attachHomeWebContentsIdRef = useRef<number | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,6 +9,13 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
|
||||
assert.match(source, /Toggle,\s*\n\s*Select,/);
|
||||
});
|
||||
|
||||
test("folder compression row exposes its settings search anchor", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/<SettingRow\s+anchorId="sftp-compressed-upload"\s+label=\{t\('settings\.sftp\.compressedUpload\.enable'\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
function functionBody(functionName: string): string {
|
||||
const start = source.indexOf(`const ${functionName} = useCallback`);
|
||||
assert.notEqual(start, -1, `${functionName} should exist`);
|
||||
|
||||
@@ -180,6 +180,7 @@ export default function SettingsFileAssociationsTab() {
|
||||
<SectionHeader title={t('settings.sftp.compressedUpload')} />
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="sftp-compressed-upload"
|
||||
label={t('settings.sftp.compressedUpload.enable')}
|
||||
description={t('settings.sftp.compressedUpload.enableDesc')}
|
||||
>
|
||||
@@ -231,12 +232,14 @@ export default function SettingsFileAssociationsTab() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="sftp-skip-unchanged"
|
||||
label={t('settings.sftp.skipUnchanged.enable')}
|
||||
description={t('settings.sftp.skipUnchanged.enableDesc')}
|
||||
>
|
||||
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="sftp-transport-idle-ttl"
|
||||
label={t('settings.ssh.transportIdleTtl')}
|
||||
description={t('settings.ssh.transportIdleTtl.desc')}
|
||||
>
|
||||
|
||||
@@ -75,6 +75,9 @@ test('list hook preserves local copy/move routing, remote HTML drag, selection a
|
||||
await act(async () => root.render(React.createElement(Harness, { local: false })));
|
||||
await act(async () => right.handlePaneDrop(event()));
|
||||
assert.equal(actions.at(-1)?.kind, 'copy');
|
||||
actions.length = 0;
|
||||
await act(async () => left.handleEntryDrop(entries[2], event()));
|
||||
assert.deepEqual(actions, [{ kind: 'move', args: [['/files/b.txt'], '/files/folder'] }], 'same-pane remote drag stays a move');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
clearLocalFileDrag();
|
||||
|
||||
@@ -938,9 +938,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A boot that never reached "connected" has nothing worth capturing, and
|
||||
// the teardown below awaits terminal drain plus serialization before it
|
||||
// closes anything. Cancel the pending backend boot after giving an immediate
|
||||
// Never-connected logs are captured at explicit session close, including
|
||||
// empty buffers. Runtime cleanup alone must not end an open log.
|
||||
// Cancel the pending backend boot after giving an immediate
|
||||
// remount a chance to replace this generation, instead of letting it
|
||||
// finish against a pane that is already gone. Attached popups never own
|
||||
// the backend session (skip closeSession) but still sync-dispose xterm.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { describeHostKey } = require("../electron/bridges/hostKeyVerifier.cjs");
|
||||
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("../electron/bridges/fixtures/hostCertificates.test.fixture.cjs");
|
||||
|
||||
import type { KnownHost } from "./models";
|
||||
import {
|
||||
@@ -139,6 +144,55 @@ test("fingerprintFromPublicKey returns empty string on missing input", () => {
|
||||
|
||||
// --- Migration --------------------------------------------------------------
|
||||
|
||||
for (const options of [
|
||||
{ type: "ed25519" },
|
||||
{ type: "rsa", bits: 2048 },
|
||||
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
|
||||
{ type: "ed25519", sk: true },
|
||||
{ type: "ecdsa", bits: 256, sk: true },
|
||||
]) {
|
||||
test(`renderer certificate fingerprint and migration agree with verifier: ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t, options);
|
||||
const expected = describeHostKey(first.blob).fingerprint;
|
||||
assert.equal(fingerprintFromPublicKey(first.line), expected);
|
||||
assert.equal(expected, first.publicKeyFingerprint);
|
||||
assert.equal(fingerprintFromPublicKey(renewed.line), expected);
|
||||
assert.notEqual(fingerprintFromPublicKey(different.line), expected);
|
||||
assert.equal(fingerprintFromPublicKey(first.plain.line), expected);
|
||||
|
||||
const legacy = knownHost({
|
||||
keyType: first.keyType,
|
||||
publicKey: first.line,
|
||||
fingerprint: crypto.createHash("sha256").update(first.blob).digest("base64").replace(/=+$/g, ""),
|
||||
});
|
||||
const migrated = normalizeKnownHost(legacy);
|
||||
assert.equal(migrated.fingerprint, expected);
|
||||
assert.equal(migrated.keyType, first.keyType);
|
||||
assert.equal(migrated.publicKey, first.line);
|
||||
assert.equal(normalizeKnownHost(migrated), migrated, "migration is idempotent");
|
||||
assert.equal(normalizeKnownHost({ ...legacy, fingerprint: undefined }).fingerprint, expected);
|
||||
assert.equal(normalizeKnownHost({ ...legacy, keyType: "" }).keyType, first.keyType);
|
||||
const unrelated = { ...legacy, fingerprint: "unrelated-explicit-fingerprint" };
|
||||
assert.equal(normalizeKnownHost(unrelated), unrelated);
|
||||
const opaque = { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` };
|
||||
assert.equal(normalizeKnownHost(opaque), opaque);
|
||||
const fingerprintOnly = { ...legacy, publicKey: undefined };
|
||||
assert.equal(normalizeKnownHost(fingerprintOnly), fingerprintOnly);
|
||||
});
|
||||
}
|
||||
|
||||
test("renderer fingerprints v00 certificates and falls back for unparseable certificates", () => {
|
||||
const cert = legacyCertificate("ssh-rsa", ["exponent", "modulus"]);
|
||||
assert.equal(fingerprintFromPublicKey(cert.line), describeHostKey(cert.plain).fingerprint);
|
||||
const malformed = Buffer.concat([
|
||||
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
|
||||
]);
|
||||
const line = `ssh-ed25519-cert-v01@openssh.com ${malformed.toString("base64")}`;
|
||||
const fullHash = crypto.createHash("sha256").update(malformed).digest("base64").replace(/=+$/g, "");
|
||||
assert.equal(fingerprintFromPublicKey(line), fullHash);
|
||||
assert.equal(fingerprintFromPublicKey(line), describeHostKey(malformed).fingerprint);
|
||||
});
|
||||
|
||||
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
|
||||
const rawKey = makeRawPublicKey("ssh-ed25519");
|
||||
const base64Body = rawKey.toString("base64");
|
||||
|
||||
+15
-6
@@ -1,4 +1,5 @@
|
||||
import type { KnownHost } from "./models";
|
||||
import { extractCertificatePublicKey } from "@/domain/sshPublicKey.shared.cjs";
|
||||
|
||||
const normalizeHost = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
@@ -132,7 +133,7 @@ export const fingerprintFromPublicKey = (publicKey: string | undefined | null):
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
|
||||
const bytes = base64Decode(parts[1]);
|
||||
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
|
||||
if (bytes) return stripPadding(base64Encode(sha256Bytes(extractCertificatePublicKey(bytes) || bytes)));
|
||||
}
|
||||
|
||||
return stripPadding(trimmed);
|
||||
@@ -145,9 +146,9 @@ const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): stri
|
||||
};
|
||||
|
||||
/**
|
||||
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
|
||||
* verifier can match it without falling back to the brittle re-derivation
|
||||
* path. Returns the same reference when nothing changes so callers can skip
|
||||
* Backfill missing `fingerprint` / `keyType` and migrate full-certificate
|
||||
* fingerprints to the underlying key when the certificate body is available.
|
||||
* Returns the same reference when nothing changes so callers can skip
|
||||
* persistence writes and React re-renders.
|
||||
*/
|
||||
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
|
||||
@@ -156,9 +157,17 @@ export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
|
||||
&& knownHost.keyType.length > 0
|
||||
&& knownHost.keyType !== "unknown";
|
||||
|
||||
if (hasFingerprint && hasKeyType) return knownHost;
|
||||
const parts = knownHost.publicKey?.trim().split(/\s+/) ?? [];
|
||||
const certBytes = /-cert-v0[01]@openssh\.com$/.test(parts[0]) && parts[1]
|
||||
? base64Decode(parts[1]) : null;
|
||||
const legacyCertFingerprint = certBytes && extractCertificatePublicKey(certBytes)
|
||||
? stripPadding(base64Encode(sha256Bytes(certBytes))) : "";
|
||||
const migrateCertificate = legacyCertFingerprint
|
||||
&& stripPadding((knownHost.fingerprint ?? "").replace(/^SHA256:/i, "")) === legacyCertFingerprint;
|
||||
|
||||
const derivedFingerprint = hasFingerprint
|
||||
if (hasFingerprint && hasKeyType && !migrateCertificate) return knownHost;
|
||||
|
||||
const derivedFingerprint = hasFingerprint && !migrateCertificate
|
||||
? knownHost.fingerprint!
|
||||
: fingerprintFromPublicKey(knownHost.publicKey);
|
||||
const derivedKeyType = hasKeyType
|
||||
|
||||
@@ -125,6 +125,48 @@ test("settings search finds App Lock from the zh-CN locale", () => {
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
|
||||
});
|
||||
|
||||
test("settings search finds folder compression from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["压缩", "普通传输"]],
|
||||
[tEnReal, ["compress", "tar"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-compressed-upload"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds skip unchanged files from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["跳过未改文件", "修改时间"]],
|
||||
[tEnReal, ["skip unchanged", "rsync"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-skip-unchanged"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds SSH connection keep-alive from the real locales", () => {
|
||||
const tZhReal = (key: string) => zhCN[key] ?? key;
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
for (const [t, queries] of [
|
||||
[tZhReal, ["保活时间", "重新认证"]],
|
||||
[tEnReal, ["keep-alive", "re-authentication"]],
|
||||
] as const) {
|
||||
for (const query of queries) {
|
||||
const hits = filterSettingsSearchCatalog(query, t);
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "sftp-transport-idle-ttl"), query);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("settings search finds temporary network device mode", () => {
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
const tZhTW = (key: string) => zhTW[key] ?? key;
|
||||
|
||||
@@ -671,6 +671,13 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.sftp.autoSync.enableDesc",
|
||||
sectionKey: "settings.sftp.autoSync",
|
||||
},
|
||||
{
|
||||
id: "sftp-compressed-upload",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.sftp.compressedUpload.enable",
|
||||
descriptionKey: "settings.sftp.compressedUpload.enableDesc",
|
||||
sectionKey: "settings.sftp.compressedUpload",
|
||||
},
|
||||
{
|
||||
id: "sftp-follow-terminal-cwd",
|
||||
tab: "file-associations",
|
||||
@@ -691,6 +698,20 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
labelKey: "settings.sftp.transferConcurrency",
|
||||
descriptionKey: "settings.sftp.transferConcurrency.desc",
|
||||
},
|
||||
{
|
||||
id: "sftp-skip-unchanged",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.sftp.skipUnchanged.enable",
|
||||
descriptionKey: "settings.sftp.skipUnchanged.enableDesc",
|
||||
sectionKey: "settings.sftp.transferConcurrency",
|
||||
},
|
||||
{
|
||||
id: "sftp-transport-idle-ttl",
|
||||
tab: "file-associations",
|
||||
labelKey: "settings.ssh.transportIdleTtl",
|
||||
descriptionKey: "settings.ssh.transportIdleTtl.desc",
|
||||
sectionKey: "settings.sftp.transferConcurrency",
|
||||
},
|
||||
{
|
||||
id: "sftp-default-opener",
|
||||
tab: "file-associations",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
|
||||
// Extract only the public-key wire blob for fingerprinting, never CA trust or
|
||||
// certificate validation. ssh2's getPublicSSH() preserves the full certificate,
|
||||
// and its internal base-type helper is not exported. Keep this byte-only helper
|
||||
// shared by Electron and the renderer so migration uses exactly the same bytes.
|
||||
// Layouts: OpenSSH PROTOCOL.certkeys (v00/v01) and PROTOCOL.u2f (SK).
|
||||
function extractCertificatePublicKey(blob) {
|
||||
let offset = 0;
|
||||
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
|
||||
const skip = (length) => {
|
||||
if (offset + length > blob.length) throw new Error("Truncated SSH key");
|
||||
offset += length;
|
||||
};
|
||||
const readString = () => {
|
||||
skip(4);
|
||||
const length = view.getUint32(offset - 4, false);
|
||||
const start = offset;
|
||||
skip(length);
|
||||
return blob.subarray(start, offset);
|
||||
};
|
||||
const ascii = (bytes) => String.fromCharCode(...bytes);
|
||||
|
||||
try {
|
||||
const typeBytes = readString();
|
||||
if (typeBytes.length > 128) return null;
|
||||
const match = ascii(typeBytes).match(/^(.*)-cert-v0([01])@openssh\.com$/);
|
||||
if (!match) return null;
|
||||
let baseType = match[1];
|
||||
const v01 = match[2] === "1";
|
||||
if (/^rsa-sha2-(256|512)$/.test(baseType)) baseType = "ssh-rsa";
|
||||
if (baseType === "sk-ssh-ed25519" || baseType === "sk-ecdsa-sha2-nistp256") {
|
||||
baseType += "@openssh.com";
|
||||
}
|
||||
let fields;
|
||||
switch (baseType) {
|
||||
case "ssh-rsa": fields = 2; break;
|
||||
case "ssh-dss": fields = 4; break;
|
||||
case "ssh-ed25519": fields = 1; break;
|
||||
case "sk-ssh-ed25519@openssh.com": fields = 2; break;
|
||||
case "sk-ecdsa-sha2-nistp256@openssh.com": fields = 3; break;
|
||||
case "ecdsa-sha2-nistp256":
|
||||
case "ecdsa-sha2-nistp384":
|
||||
case "ecdsa-sha2-nistp521": fields = 2; break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
// v00 put its nonce after the constraints, not before the key material.
|
||||
if (v01) readString();
|
||||
const keyStart = offset;
|
||||
for (let i = 0; i < fields; i += 1) {
|
||||
const field = readString();
|
||||
const isApplication = baseType.startsWith("sk-") && i === fields - 1;
|
||||
if (!field.length && !isApplication) return null;
|
||||
if (i === 0 && baseType.includes("ed25519") && field.length !== 32) return null;
|
||||
if (i === 0 && baseType.includes("ecdsa")) {
|
||||
const curve = baseType.replace(/^sk-/, "").replace(/@openssh\.com$/, "").replace(/^ecdsa-sha2-/, "");
|
||||
if (field.length > 8 || ascii(field) !== curve) return null;
|
||||
}
|
||||
}
|
||||
const keyEnd = offset;
|
||||
// Check the outer envelope too; truncated/unsupported certificates retain
|
||||
// their full-blob fingerprint rather than gaining an inner-key match.
|
||||
if (v01) skip(8); // serial
|
||||
skip(4); // host/user type
|
||||
readString(); // key ID
|
||||
readString(); // principals
|
||||
skip(16); // validity
|
||||
readString(); // critical options (v00 constraints)
|
||||
readString(); // extensions (v00 nonce)
|
||||
readString(); // reserved
|
||||
if (!readString().length || !readString().length || offset !== blob.length) return null;
|
||||
|
||||
const result = new Uint8Array(4 + baseType.length + keyEnd - keyStart);
|
||||
new DataView(result.buffer).setUint32(0, baseType.length, false);
|
||||
for (let i = 0; i < baseType.length; i += 1) result[4 + i] = baseType.charCodeAt(i);
|
||||
result.set(blob.subarray(keyStart, keyEnd), 4 + baseType.length);
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
exports.extractCertificatePublicKey = extractCertificatePublicKey;
|
||||
@@ -84,6 +84,7 @@ module.exports = {
|
||||
// is excluded below, so the shared TOTP runtime must be listed.
|
||||
'domain/totp.shared.cjs',
|
||||
'domain/totpPrompt.shared.cjs',
|
||||
'domain/sshPublicKey.shared.cjs',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
|
||||
@@ -281,6 +281,26 @@ test('wall deadline during authorization reports authorization failure rather th
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('idle Enter-PSSession remoting prompt allows the command to be sent', async () => {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
const prompt = '[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ';
|
||||
trackSessionIdlePrompt(session, `PowerShell 7\r\n${prompt}`);
|
||||
assert.equal(readyPrompt(session), prompt);
|
||||
|
||||
const result = await startPtyJob(pty, 'Write-Output ok', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
typedInput: true,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.stdout, 'OK');
|
||||
assert.equal(pty.writes.length, 1);
|
||||
assert.match(pty.writes[0], /\$__NCMCP_/);
|
||||
});
|
||||
|
||||
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
|
||||
@@ -50,11 +50,12 @@ function formatSyntheticEcho(command) {
|
||||
}
|
||||
|
||||
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
|
||||
// `PS /home/alice>`). Anchored so command output that merely starts with
|
||||
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
|
||||
// `PS /home/alice>`), with optional Enter-PSSession / remoting `[host]: `
|
||||
// and debug `[DBG]: ` prefixes. Anchored so command output that merely
|
||||
// starts with `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
|
||||
// `"PS >"` (which the default prompt never emits) so a script that prints
|
||||
// such a line can't trick prompt-driven shell-kind selection.
|
||||
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
|
||||
const POWERSHELL_PROMPT_PATTERN = /^(?:\[[^\]\r\n]+\]: )*PS(?:\s+\S.*)?>$/;
|
||||
|
||||
// Default cmd.exe prompt (e.g. `C:\>`, `C:\Users\alice>`, `D:\data>`).
|
||||
// Drive letter + optional path + `>`. Rejects `C: >` (space before `>`) and
|
||||
|
||||
@@ -112,6 +112,46 @@ test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alik
|
||||
assert.equal(isDefaultPowerShellPromptLine(null), false);
|
||||
});
|
||||
|
||||
test("PowerShell prompt helpers recognize remoting and debug prefixes", () => {
|
||||
for (const prompt of [
|
||||
"[192.168.1.10]: PS C:\\Users\\Administrator\\Documents>",
|
||||
"[SERVER01]: PS C:\\>",
|
||||
"[host.example.com]: PS C:\\Windows\\System32>",
|
||||
"[fe80::1]: PS C:\\>",
|
||||
"[DBG]: PS C:\\Users\\alice>",
|
||||
"[SERVER01]: [DBG]: PS C:\\>",
|
||||
]) {
|
||||
assert.equal(isDefaultPowerShellPromptLine(prompt), true, prompt);
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("PowerShell prompt helpers reject malformed remoting prefixes and look-alikes", () => {
|
||||
for (const line of [
|
||||
"[host] PS C:\\>",
|
||||
"prefix [host]: PS C:\\>",
|
||||
"[]: PS C:\\>",
|
||||
"[ho]st]: PS C:\\>",
|
||||
"[ho\rst]: PS C:\\>",
|
||||
"[ho\nst]: PS C:\\>",
|
||||
"[host]:PS C:\\>",
|
||||
"[host]: PS >",
|
||||
"PS >",
|
||||
"PSO>",
|
||||
"ZIPS>",
|
||||
"",
|
||||
null,
|
||||
]) {
|
||||
assert.equal(isDefaultPowerShellPromptLine(line), false, String(line));
|
||||
assert.equal(extractTrailingIdlePrompt(line), "", String(line));
|
||||
}
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt preserves trailing space on a remoting prompt", () => {
|
||||
const prompt = "[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ";
|
||||
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt);
|
||||
});
|
||||
|
||||
test("extracts a trailing cmd.exe idle prompt", () => {
|
||||
// Windows OpenSSH default shell is cmd.exe; without capturing `C:\...>`
|
||||
// AI exec cannot select the cmd wrapper when shellKind is still unset.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
let sshKeygenSkip = false;
|
||||
try {
|
||||
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") sshKeygenSkip = "ssh-keygen is not installed";
|
||||
}
|
||||
|
||||
const sshString = (value) => {
|
||||
const body = Buffer.from(value);
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(body.length);
|
||||
return Buffer.concat([length, body]);
|
||||
};
|
||||
|
||||
// Real local certificates, with no server/network or security-key device.
|
||||
function mintHostCertificates(t, { type = "ed25519", bits, sk = false, user = false } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(__dirname, ".hostcert-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const keygen = (...args) => execFileSync("ssh-keygen", ["-q", ...args], { stdio: "pipe" });
|
||||
const ca = path.join(dir, "ca");
|
||||
keygen("-t", "ed25519", "-N", "", "-f", ca);
|
||||
const readKey = (file) => {
|
||||
const line = fs.readFileSync(file, "utf8").trim();
|
||||
const [keyType, body] = line.split(/\s+/);
|
||||
return { line, keyType, blob: Buffer.from(body, "base64") };
|
||||
};
|
||||
const mint = (name, validity, serial) => {
|
||||
const host = path.join(dir, name);
|
||||
if (!fs.existsSync(host)) {
|
||||
keygen("-t", type, ...(bits ? ["-b", String(bits)] : []), "-N", "", "-f", host);
|
||||
if (sk) {
|
||||
const plain = readKey(`${host}.pub`);
|
||||
const keyMaterial = plain.blob.subarray(4 + plain.keyType.length);
|
||||
const skType = `sk-${plain.keyType}@openssh.com`;
|
||||
const blob = Buffer.concat([sshString(skType), keyMaterial, sshString("ssh:netcatty-test")]);
|
||||
fs.writeFileSync(`${host}.pub`, `${skType} ${blob.toString("base64")}\n`);
|
||||
}
|
||||
}
|
||||
keygen("-s", ca, "-I", "netcatty-hostcert", ...(user ? [] : ["-h"]),
|
||||
"-n", "127.0.0.1", "-V", validity, "-z", String(serial), `${host}.pub`);
|
||||
const cert = readKey(`${host}-cert.pub`);
|
||||
const listing = execFileSync("ssh-keygen", ["-L", "-f", `${host}-cert.pub`], { encoding: "utf8" });
|
||||
const publicKeyFingerprint = listing.match(/Public key: .* SHA256:(\S+)/)?.[1];
|
||||
if (!publicKeyFingerprint) throw new Error("Missing ssh-keygen certificate fingerprint");
|
||||
return { ...cert, publicKeyFingerprint, plain: readKey(`${host}.pub`) };
|
||||
};
|
||||
return {
|
||||
first: mint("host", "20250101:20260101", 1),
|
||||
renewed: mint("host", "20260101:20270101", 2),
|
||||
different: mint("other", "20260101:20270101", 3),
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy v00 is no longer emitted by ssh-keygen. This follows the original
|
||||
// PROTOCOL.certkeys layout: key material first, nonce after constraints.
|
||||
function legacyCertificate(keyType, keyFields) {
|
||||
const blob = Buffer.concat([
|
||||
sshString(`${keyType}-cert-v00@openssh.com`),
|
||||
...keyFields.map(sshString),
|
||||
Buffer.from([0, 0, 0, 2]),
|
||||
sshString("legacy"), sshString(""), Buffer.alloc(16),
|
||||
sshString(""), sshString("nonce"), sshString(""),
|
||||
sshString("signing key"), sshString("signature"),
|
||||
]);
|
||||
return {
|
||||
blob,
|
||||
plain: Buffer.concat([sshString(keyType), ...keyFields.map(sshString)]),
|
||||
line: `${keyType}-cert-v00@openssh.com ${blob.toString("base64")}`,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip };
|
||||
@@ -1,6 +1,7 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { utils: sshUtils } = require("ssh2");
|
||||
const { extractCertificatePublicKey } = require("../../domain/sshPublicKey.shared.cjs");
|
||||
|
||||
const REQUEST_TTL_MS = 2 * 60 * 1000;
|
||||
const hostKeyRequests = new Map();
|
||||
@@ -65,8 +66,9 @@ const fingerprintFromPublicKey = (publicKey) => {
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
|
||||
try {
|
||||
const blob = Buffer.from(parts[1], "base64");
|
||||
return crypto.createHash("sha256")
|
||||
.update(Buffer.from(parts[1], "base64"))
|
||||
.update(extractCertificatePublicKey(blob) || blob)
|
||||
.digest("base64")
|
||||
.replace(/=+$/g, "");
|
||||
} catch {
|
||||
@@ -77,7 +79,16 @@ const fingerprintFromPublicKey = (publicKey) => {
|
||||
};
|
||||
|
||||
const getKnownHostFingerprint = (knownHost) => {
|
||||
return normalizeFingerprint(knownHost?.fingerprint)
|
||||
const stored = normalizeFingerprint(knownHost?.fingerprint);
|
||||
const parts = knownHost?.publicKey?.trim().split(/\s+/) || [];
|
||||
if (parts.length >= 2 && /-cert-v0[01]@openssh\.com$/.test(parts[0])) {
|
||||
const blob = Buffer.from(parts[1], "base64");
|
||||
// Only replace an explicit fingerprint if it is the legacy full-cert hash.
|
||||
// Fingerprint-only records cannot be migrated without the certificate body.
|
||||
const legacy = crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
|
||||
if (!stored || stored === legacy) return fingerprintFromPublicKey(knownHost.publicKey);
|
||||
}
|
||||
return stored
|
||||
|| fingerprintFromPublicKey(knownHost?.publicKey);
|
||||
};
|
||||
|
||||
@@ -134,7 +145,7 @@ const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, finger
|
||||
const describeHostKey = (rawKey) => {
|
||||
const key = Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || "");
|
||||
const fingerprint = crypto.createHash("sha256")
|
||||
.update(key)
|
||||
.update(extractCertificatePublicKey(key) || key)
|
||||
.digest("base64")
|
||||
.replace(/=+$/g, "");
|
||||
let keyType = "unknown";
|
||||
@@ -149,7 +160,9 @@ const describeHostKey = (rawKey) => {
|
||||
try {
|
||||
const parsed = sshUtils.parseKey(key);
|
||||
const parsedKey = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
if (parsedKey && !(parsedKey instanceof Error)) {
|
||||
// Keep the presented certificate identity, even if a parser returns its
|
||||
// base algorithm or underlying public key as metadata.
|
||||
if (parsedKey && !(parsedKey instanceof Error) && !/-cert-v0[01]@openssh\.com$/.test(keyType)) {
|
||||
keyType = parsedKey.type || keyType;
|
||||
const publicSsh = parsedKey.getPublicSSH?.();
|
||||
if (publicSsh) publicKey = publicSsh.toString("utf8");
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { mintHostCertificates, legacyCertificate, sshString, sshKeygenSkip } = require("./fixtures/hostCertificates.test.fixture.cjs");
|
||||
|
||||
const {
|
||||
classifyHostKey,
|
||||
createHostVerifier,
|
||||
describeHostKey,
|
||||
getKnownHostFingerprint,
|
||||
handleResponse,
|
||||
normalizeFingerprint,
|
||||
} = require("./hostKeyVerifier.cjs");
|
||||
@@ -17,6 +19,84 @@ const makeRawPublicKey = (keyType, body = "trusted imported host key") => {
|
||||
return Buffer.concat([length, type, Buffer.from(body)]);
|
||||
};
|
||||
|
||||
const hashKey = (blob) => crypto.createHash("sha256").update(blob).digest("base64").replace(/=+$/g, "");
|
||||
|
||||
test("host certificate renewals trust the inner key, including legacy saved fingerprints", { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t);
|
||||
const original = describeHostKey(first.blob);
|
||||
const live = describeHostKey(renewed.blob);
|
||||
const changed = describeHostKey(different.blob);
|
||||
assert.notDeepEqual(first.blob, renewed.blob);
|
||||
assert.equal(original.fingerprint, first.publicKeyFingerprint);
|
||||
assert.equal(live.fingerprint, original.fingerprint);
|
||||
assert.notEqual(changed.fingerprint, live.fingerprint);
|
||||
assert.equal(original.keyType, "ssh-ed25519-cert-v01@openssh.com");
|
||||
assert.equal(original.publicKey, `${first.keyType} ${first.blob.toString("base64")}`);
|
||||
assert.equal(describeHostKey(first.plain.blob).fingerprint, hashKey(first.plain.blob));
|
||||
|
||||
const stored = { id: "kh-cert", hostname: "cert.local", port: 2222, ...original };
|
||||
const classify = (key, record = stored) => classifyHostKey({
|
||||
knownHosts: [record], hostname: "cert.local", port: 2222, ...key,
|
||||
});
|
||||
assert.equal(classify(live).status, "trusted");
|
||||
assert.equal(classify(changed).status, "changed");
|
||||
assert.equal(classify(changed, { ...stored, keyType: "ssh-rsa" }).status, "unknown");
|
||||
|
||||
const legacy = { ...stored, fingerprint: hashKey(first.blob) };
|
||||
assert.notEqual(legacy.fingerprint, live.fingerprint);
|
||||
assert.equal(getKnownHostFingerprint(legacy), live.fingerprint);
|
||||
assert.equal(classify(live, legacy).status, "trusted");
|
||||
assert.equal(classify(changed, legacy).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, publicKey: undefined }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, publicKey: `SHA256:${legacy.fingerprint}` }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, fingerprint: "unrelated-explicit-fingerprint" }).status, "changed");
|
||||
assert.equal(classify(live, { ...legacy, fingerprint: undefined }).status, "trusted");
|
||||
|
||||
// Renewal is trusted on reconnect, but changing the raw certificate during
|
||||
// an established connection must still be rejected synchronously on rekey.
|
||||
const verifier = createHostVerifier({ hostname: stored.hostname, port: stored.port, knownHosts: [legacy] });
|
||||
const results = [];
|
||||
verifier(first.blob, (accepted) => results.push(accepted));
|
||||
verifier(Buffer.from(first.blob), (accepted) => results.push(accepted));
|
||||
verifier(renewed.blob, (accepted) => results.push(accepted));
|
||||
assert.deepEqual(results, [true, true, false]);
|
||||
});
|
||||
|
||||
for (const options of [
|
||||
{ type: "rsa", bits: 2048 },
|
||||
...[256, 384, 521].map((bits) => ({ type: "ecdsa", bits })),
|
||||
{ type: "ed25519", sk: true },
|
||||
{ type: "ecdsa", bits: 256, sk: true },
|
||||
{ type: "ed25519", user: true },
|
||||
]) {
|
||||
test(`certificate fingerprint matches ssh-keygen for ${JSON.stringify(options)}`, { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t, options);
|
||||
assert.equal(describeHostKey(first.blob).fingerprint, first.publicKeyFingerprint);
|
||||
assert.equal(describeHostKey(first.blob).fingerprint, hashKey(first.plain.blob));
|
||||
assert.equal(describeHostKey(renewed.blob).fingerprint, first.publicKeyFingerprint);
|
||||
assert.notEqual(describeHostKey(different.blob).fingerprint, first.publicKeyFingerprint);
|
||||
});
|
||||
}
|
||||
|
||||
test("v00 certificates extract the key before the nonce", () => {
|
||||
for (const [type, fields] of [["ssh-rsa", ["exponent", "modulus"]], ["ssh-dss", ["p", "q", "g", "y"]]]) {
|
||||
const cert = legacyCertificate(type, fields);
|
||||
assert.equal(describeHostKey(cert.blob).fingerprint, hashKey(cert.plain));
|
||||
assert.equal(describeHostKey(cert.blob).keyType, `${type}-cert-v00@openssh.com`);
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed and unsupported certificates keep their full-blob fingerprint", () => {
|
||||
const truncated = Buffer.concat([
|
||||
sshString("ssh-ed25519-cert-v01@openssh.com"), sshString("nonce"), sshString(Buffer.alloc(32)),
|
||||
]);
|
||||
const badLength = Buffer.concat([sshString("ssh-ed25519-cert-v01@openssh.com"), Buffer.from([255, 255, 255, 255])]);
|
||||
const unsupported = Buffer.concat([sshString("unknown-cert-v01@openssh.com"), sshString("nonce")]);
|
||||
for (const blob of [truncated, badLength, unsupported]) {
|
||||
assert.equal(describeHostKey(blob).fingerprint, hashKey(blob));
|
||||
}
|
||||
});
|
||||
|
||||
test("classifyHostKey prompts for unknown hosts", () => {
|
||||
const result = classifyHostKey({
|
||||
knownHosts: [],
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
// Keep the renderer alive until its ConnectionLog replay writes have committed.
|
||||
function finalizeSessionLogs(webContents, { ipcMain, timeoutMs = 5000 } = {}) {
|
||||
if (!webContents || webContents.isDestroyed?.() || webContents.isCrashed?.()
|
||||
|| typeof webContents.send !== "function"
|
||||
|| typeof ipcMain?.on !== "function"
|
||||
|| typeof ipcMain?.removeListener !== "function") {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const requestId = randomUUID();
|
||||
const finish = (success) => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
ipcMain.removeListener("app:session-logs-finalized", onResult);
|
||||
} catch {
|
||||
// IPC teardown must not prevent the window from closing.
|
||||
}
|
||||
if (!success) console.warn("[SessionLogs] Renderer did not finalize logs before close");
|
||||
resolve(success);
|
||||
};
|
||||
const onResult = (event, payload) => {
|
||||
if (event.sender === webContents && payload?.requestId === requestId) {
|
||||
finish(payload.success === true);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
try {
|
||||
ipcMain.on("app:session-logs-finalized", onResult);
|
||||
webContents.send("app:finalize-session-logs", { requestId });
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { finalizeSessionLogs };
|
||||
@@ -0,0 +1,193 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { finalizeSessionLogs } = require("./sessionLogShutdown.cjs");
|
||||
const { createPreloadApi } = require("../preload/api.cjs");
|
||||
const { handleBeforeQuit } = require("../main/appLockLifecycle.cjs");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const vm = require("node:vm");
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function renderer(ipcMain = new EventEmitter()) {
|
||||
const ipcRenderer = new EventEmitter();
|
||||
const webContents = { send: (channel, payload) => ipcRenderer.emit(channel, {}, payload) };
|
||||
ipcRenderer.send = (channel, payload) => ipcMain.emit(channel, { sender: webContents }, payload);
|
||||
const api = createPreloadApi({ ipcRenderer });
|
||||
return { api, ipcMain, ipcRenderer, webContents };
|
||||
}
|
||||
|
||||
test("finalization waits for every terminal replay commit, including a hidden renderer", async () => {
|
||||
const h = renderer();
|
||||
const commits = [];
|
||||
const off = h.api.onFinalizeSessionLogs(() => { throw new Error("unmounted terminal"); });
|
||||
off();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
h.api.onFinalizeSessionLogs(() => new Promise((resolve) => commits.push(resolve)));
|
||||
}
|
||||
let done = false;
|
||||
const pending = finalizeSessionLogs(h.webContents, h).then((result) => { done = true; return result; });
|
||||
await tick();
|
||||
assert.equal(done, false);
|
||||
commits[0](); await tick();
|
||||
assert.equal(done, false);
|
||||
commits[1]();
|
||||
assert.equal(await pending, true);
|
||||
assert.equal(h.ipcMain.listenerCount("app:session-logs-finalized"), 0);
|
||||
});
|
||||
|
||||
test("empty renderers acknowledge immediately; failed captures acknowledge failure", async () => {
|
||||
const h = renderer();
|
||||
assert.equal(await finalizeSessionLogs(h.webContents, h), true);
|
||||
h.api.onFinalizeSessionLogs(() => { throw new Error("capture failed"); });
|
||||
assert.equal(await finalizeSessionLogs(h.webContents, h), false);
|
||||
});
|
||||
|
||||
test("overlapping window close and app quit share the pending replay commit", async () => {
|
||||
const h = renderer();
|
||||
let finish;
|
||||
let captures = 0;
|
||||
h.api.onFinalizeSessionLogs(() => {
|
||||
captures++;
|
||||
return new Promise((resolve) => { finish = resolve; });
|
||||
});
|
||||
const first = finalizeSessionLogs(h.webContents, h);
|
||||
let secondDone = false;
|
||||
const second = finalizeSessionLogs(h.webContents, h).then((result) => { secondDone = true; return result; });
|
||||
await tick();
|
||||
assert.equal(captures, 1);
|
||||
assert.equal(secondDone, false);
|
||||
finish();
|
||||
assert.equal(await first, true);
|
||||
assert.equal(await second, true);
|
||||
});
|
||||
|
||||
test("only the queried renderer and current request can acknowledge finalization", async () => {
|
||||
const ipcMain = new EventEmitter();
|
||||
let request;
|
||||
const webContents = { send: (_channel, payload) => { request = payload; } };
|
||||
let done = false;
|
||||
const pending = finalizeSessionLogs(webContents, { ipcMain }).then((result) => { done = true; return result; });
|
||||
ipcMain.emit("app:session-logs-finalized", { sender: {} }, { ...request, success: true });
|
||||
ipcMain.emit("app:session-logs-finalized", { sender: webContents }, { requestId: "old", success: true });
|
||||
await tick();
|
||||
assert.equal(done, false);
|
||||
ipcMain.emit("app:session-logs-finalized", { sender: webContents }, { ...request, success: true });
|
||||
assert.equal(await pending, true);
|
||||
});
|
||||
|
||||
test("unresponsive and destroyed renderers do not strand quit or leak listeners", async () => {
|
||||
const ipcMain = new EventEmitter();
|
||||
assert.equal(await finalizeSessionLogs({ send() {} }, { ipcMain, timeoutMs: 1 }), false);
|
||||
assert.equal(await finalizeSessionLogs({ send() { throw new Error("destroyed"); } }, { ipcMain }), false);
|
||||
assert.equal(await finalizeSessionLogs({ isDestroyed: () => true }, { ipcMain }), false);
|
||||
assert.equal(ipcMain.listenerCount("app:session-logs-finalized"), 0);
|
||||
});
|
||||
|
||||
test("unavailable finalize IPC returns immediately without scheduling timers or sending", async (t) => {
|
||||
const timer = t.mock.method(globalThis, "setTimeout");
|
||||
const ipcMain = new EventEmitter();
|
||||
const send = t.mock.fn();
|
||||
const on = t.mock.method(ipcMain, "on");
|
||||
for (const unavailableIpc of [undefined, null, {}, { on() {} }, { removeListener() {} }, { on: true, removeListener() {} }, { on() {}, removeListener: true }]) {
|
||||
assert.equal(await finalizeSessionLogs({ send }, { ipcMain: unavailableIpc }), false);
|
||||
}
|
||||
for (const webContents of [undefined, null, {}, { send: true }]) {
|
||||
assert.equal(await finalizeSessionLogs(webContents, { ipcMain }), false);
|
||||
}
|
||||
assert.equal(timer.mock.callCount(), 0);
|
||||
assert.equal(on.mock.callCount(), 0);
|
||||
assert.equal(send.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
for (const failure of ["registration", "send", "cleanup"]) {
|
||||
test(`finalize IPC ${failure} errors do not reject or leave a pending timeout`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const warn = t.mock.method(console, "warn", () => {});
|
||||
const ipcMain = new EventEmitter();
|
||||
const webContents = { send() { throw new Error("send failed"); } };
|
||||
if (failure === "registration") {
|
||||
ipcMain.on = (channel, listener) => {
|
||||
EventEmitter.prototype.on.call(ipcMain, channel, listener);
|
||||
throw new Error("registration failed");
|
||||
};
|
||||
} else if (failure === "cleanup") {
|
||||
ipcMain.removeListener = (channel, listener) => {
|
||||
EventEmitter.prototype.removeListener.call(ipcMain, channel, listener);
|
||||
throw new Error("cleanup failed");
|
||||
};
|
||||
}
|
||||
let result;
|
||||
const pending = finalizeSessionLogs(webContents, { ipcMain }).then((success) => { result = success; });
|
||||
await tick();
|
||||
assert.equal(result, false, "failed setup must finish before the timeout");
|
||||
await pending;
|
||||
assert.equal(ipcMain.listenerCount("app:session-logs-finalized"), 0);
|
||||
assert.equal(warn.mock.callCount(), 1);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(warn.mock.callCount(), 1, "the cleared timeout must not run after finalization");
|
||||
});
|
||||
}
|
||||
|
||||
test("tray quit finalizes only after the dirty guard accepts and waits before quitting", async () => {
|
||||
const h = renderer();
|
||||
let finishCapture;
|
||||
let captures = 0;
|
||||
let quits = 0;
|
||||
h.api.onFinalizeSessionLogs(() => {
|
||||
captures++;
|
||||
return new Promise((resolve) => { finishCapture = resolve; });
|
||||
});
|
||||
let shutdown;
|
||||
const options = {
|
||||
event: { preventDefault() {} }, mainWindows: [{ webContents: h.webContents }],
|
||||
queryDirtyEditors: async () => true,
|
||||
commitQuit: () => { shutdown = finalizeSessionLogs(h.webContents, h).then(() => { quits++; }); },
|
||||
dialog: { showMessageBoxSync() {} },
|
||||
};
|
||||
await handleBeforeQuit(options);
|
||||
assert.equal(captures, 0);
|
||||
await handleBeforeQuit({ ...options, queryDirtyEditors: async () => false });
|
||||
await tick();
|
||||
assert.equal(captures, 1);
|
||||
assert.equal(quits, 0);
|
||||
finishCapture(); await shutdown;
|
||||
assert.equal(quits, 1);
|
||||
});
|
||||
|
||||
test("production quit commit awaits renderer logs and file streams before plugins and process exit", async () => {
|
||||
const source = readFileSync(require.resolve("../main.cjs"), "utf8");
|
||||
const start = source.indexOf(" const commitQuit = () => {");
|
||||
const end = source.indexOf('\n app.on("before-quit"', start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const calls = [];
|
||||
let finishCapture;
|
||||
let finishStreams;
|
||||
const hiddenWindow = { webContents: {} };
|
||||
const context = {
|
||||
console,
|
||||
getWindowManager: () => ({ setIsQuitting: () => calls.push("quitting"), getAppContentWindows: () => [hiddenWindow] }),
|
||||
electronModule: { ipcMain: {} },
|
||||
finalizeSessionLogs: (wc) => {
|
||||
assert.equal(wc, hiddenWindow.webContents);
|
||||
calls.push("capture");
|
||||
return new Promise((resolve) => { finishCapture = resolve; });
|
||||
},
|
||||
sessionLogStreamManager: { cleanupAll: () => {
|
||||
calls.push("streams");
|
||||
return new Promise((resolve) => { finishStreams = resolve; });
|
||||
} },
|
||||
appLockController: { setLocked: () => calls.push("lock") },
|
||||
runPluginShutdown: async () => { calls.push("plugins"); return { timedOut: false }; },
|
||||
app: { quit: () => calls.push("quit") },
|
||||
quitGuardChannelBusy: false, quitConfirmed: false,
|
||||
};
|
||||
vm.runInNewContext(`${source.slice(start, end)}; commitQuit();`, context);
|
||||
assert.deepEqual(calls, ["lock", "quitting", "capture"]);
|
||||
assert.equal(context.quitGuardChannelBusy, true);
|
||||
finishCapture(); await tick();
|
||||
assert.deepEqual(calls, ["lock", "quitting", "capture", "streams"]);
|
||||
assert.equal(context.quitConfirmed, false);
|
||||
finishStreams(); await tick();
|
||||
assert.deepEqual(calls, ["lock", "quitting", "capture", "streams", "plugins", "quit"]);
|
||||
assert.equal(context.quitConfirmed, true);
|
||||
});
|
||||
@@ -16,10 +16,29 @@ const {
|
||||
startStream,
|
||||
startStreamToFile,
|
||||
stopStream,
|
||||
cleanupAll,
|
||||
} = require("./sessionLogStreamManager.cjs");
|
||||
|
||||
const TEMP_ROOT = path.join(__dirname, ".tmp-session-log-stream-tests");
|
||||
|
||||
test("quit cleanup finishes all pending file captures before resolving", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `quit-${Date.now()}`);
|
||||
try {
|
||||
for (const sessionId of ["quit-one", "quit-two"]) {
|
||||
startStreamToFile(sessionId, { filePath: path.join(directory, `${sessionId}.log`), format: "raw" });
|
||||
appendData(sessionId, `output before quit: ${sessionId}\r\n`);
|
||||
}
|
||||
await cleanupAll();
|
||||
for (const sessionId of ["quit-one", "quit-two"]) {
|
||||
assert.equal(hasStream(sessionId), false);
|
||||
assert.equal(fs.readFileSync(path.join(directory, `${sessionId}.log`), "utf8"), `output before quit: ${sessionId}\r\n`);
|
||||
}
|
||||
} finally {
|
||||
await cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("txt stream live snapshots include pending ED2 cleared screens", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
@@ -1913,6 +1913,16 @@ function createSessionBackedSftpClient(sessionId, sshClient, options = {}) {
|
||||
const attrs = await statAsync(sftp, remotePath);
|
||||
return statResultFromAttrs(attrs);
|
||||
},
|
||||
async lstat(remotePath) {
|
||||
const sftp = await requireSftpChannel(client);
|
||||
// Copy preflights must inspect link nodes, never their followed targets.
|
||||
// lstatAsync falls back to STAT when LSTAT is absent, so reject first.
|
||||
if (typeof sftp.lstat !== "function") {
|
||||
throw Object.assign(new Error("Remote server does not support LSTAT"), { code: "ENOTSUP" });
|
||||
}
|
||||
const attrs = await lstatAsync(sftp, remotePath);
|
||||
return statResultFromAttrs(attrs);
|
||||
},
|
||||
async realPath(remotePath) {
|
||||
const sftp = await requireSftpChannel(client);
|
||||
return await realpathAsync(sftp, remotePath);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { Readable, PassThrough } = require("node:stream");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
|
||||
const missing = () => Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
|
||||
async function openSessionClient(t, { sourceIsLink = false, targetExists = false } = {}) {
|
||||
const commands = [];
|
||||
const channel = new EventEmitter();
|
||||
channel.readdir = (_path, callback) => callback(null, []);
|
||||
channel.mkdir = (_path, callback) => callback(null);
|
||||
channel.unlink = (_path, callback) => callback(null);
|
||||
channel.stat = (remotePath, callback) => {
|
||||
if (String(remotePath) === "/target/file" && !targetExists) return callback(missing());
|
||||
callback(null, { size: 66, mode: 0o100640, mtime: 1 });
|
||||
};
|
||||
channel.lstat = (remotePath, callback) => {
|
||||
if (String(remotePath) === "/target" && !targetExists) return callback(missing());
|
||||
callback(null, { size: 66, mode: sourceIsLink ? 0o120777 : 0o100640, mtime: 1 });
|
||||
};
|
||||
channel.createReadStream = () => Readable.from([Buffer.alloc(66)]);
|
||||
const connection = {
|
||||
sftp(callback) { callback(null, channel); },
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
};
|
||||
const clients = new Map();
|
||||
sftpBridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["copy-session", { conn: connection }]]),
|
||||
sftpClients: clients,
|
||||
});
|
||||
transferBridge.init({ sftpClients: clients });
|
||||
const { sftpId } = await sftpBridge.openSftpForSession(null, {
|
||||
sessionId: "copy-session", fileProtocol: "sftp",
|
||||
});
|
||||
t.after(() => sftpBridge.closeSftp(null, { sftpId }));
|
||||
return { client: clients.get(sftpId), channel, commands, sftpId };
|
||||
}
|
||||
|
||||
test("same-host file copy executes cp using the actual session-backed client (#4207)", async (t) => {
|
||||
const { commands, sftpId } = await openSessionClient(t);
|
||||
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
|
||||
transferId: "session-backed-file-copy",
|
||||
sourcePath: "/source/file", targetPath: "/target/file",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: sftpId, targetSftpId: sftpId,
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: 66, sameHost: true, resumable: false,
|
||||
});
|
||||
assert.deepEqual(commands, ["cp -a '/source/file' '/target/file'"]);
|
||||
assert.equal(result.error, undefined);
|
||||
});
|
||||
|
||||
for (const sourceIsLink of [false, true]) {
|
||||
test(`session-backed LSTAT preserves link classification (${sourceIsLink})`, async (t) => {
|
||||
const { client } = await openSessionClient(t, { sourceIsLink });
|
||||
assert.equal((await client.lstat("/source/file")).isSymbolicLink, sourceIsLink);
|
||||
assert.equal((await client.stat("/source/file")).isSymbolicLink, false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const unsupported of ["missing-method", "server-error"]) {
|
||||
test(`session-backed LSTAT never follows STAT on ${unsupported}`, async (t) => {
|
||||
const { client, channel } = await openSessionClient(t);
|
||||
let statCalls = 0;
|
||||
channel.stat = () => { statCalls++; throw new Error("must not follow symlinks"); };
|
||||
if (unsupported === "missing-method") delete channel.lstat;
|
||||
else channel.lstat = (_path, callback) => callback(Object.assign(new Error("unsupported"), { code: 8 }));
|
||||
await assert.rejects(() => client.lstat("/source/file"), error =>
|
||||
error.code === (unsupported === "missing-method" ? "ENOTSUP" : 8));
|
||||
assert.equal(statCalls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const targetExists of [false, true]) {
|
||||
test(`session-backed directory cp preserves the destination merge guard (${targetExists})`, async (t) => {
|
||||
const { commands, sftpId } = await openSessionClient(t, { targetExists });
|
||||
const result = await transferBridge.sameHostCopyDirectory({ sender: { send() {} } }, {
|
||||
transferId: `session-backed-directory-copy-${targetExists}`,
|
||||
sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
|
||||
});
|
||||
assert.equal(result.success, !targetExists);
|
||||
assert.deepEqual(commands, targetExists ? [] : ["cp -ra '/source/.' '/target/'"]);
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,8 @@
|
||||
* leaves the Mosh stats bar empty (graceful degradation), never weakens
|
||||
* security.
|
||||
*/
|
||||
const { extractCertificatePublicKey } = require("../../../domain/sshPublicKey.shared.cjs");
|
||||
|
||||
function createSystemKnownHostsApi(ctx) {
|
||||
const { fs, path, os, crypto, log } = ctx;
|
||||
|
||||
@@ -64,7 +66,7 @@ function createSystemKnownHostsApi(ctx) {
|
||||
}
|
||||
if (blob.length === 0) return "";
|
||||
return stripFingerprintPadding(
|
||||
crypto.createHash("sha256").update(blob).digest("base64"),
|
||||
crypto.createHash("sha256").update(extractCertificatePublicKey(blob) || blob).digest("base64"),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -176,7 +178,7 @@ function createSystemKnownHostsApi(ctx) {
|
||||
|
||||
// Parse one known_hosts line into { revoked, certAuthority, hostField,
|
||||
// keyType, fingerprint } or null when it is a comment / blank / malformed.
|
||||
// The fingerprint is the SHA-256 of the line's key blob.
|
||||
// Certificates pin their underlying key, just like the live verifier.
|
||||
const parseKnownHostsLine = (rawLine) => {
|
||||
const line = String(rawLine || "").trim();
|
||||
if (!line || line.startsWith("#")) return null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { mintHostCertificates, sshKeygenSkip } = require("../fixtures/hostCertificates.test.fixture.cjs");
|
||||
const { describeHostKey } = require("../hostKeyVerifier.cjs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
@@ -59,6 +61,22 @@ function hashedHostField(token, salt = crypto.randomBytes(20)) {
|
||||
|
||||
const HOME_KH = path.join(os.homedir(), ".ssh", "known_hosts");
|
||||
|
||||
test("system certificate entries pin the inner key across renewals without CA trust", { skip: sshKeygenSkip }, (t) => {
|
||||
const { first, renewed, different } = mintHostCertificates(t);
|
||||
const { api } = makeApi({ [HOME_KH]: `example.com ${first.line}\n` });
|
||||
const liveFingerprint = describeHostKey(renewed.blob).fingerprint;
|
||||
assert.equal(api.parseKnownHostsLine(`example.com ${first.line}`).fingerprint, liveFingerprint);
|
||||
assert.equal(api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), true);
|
||||
assert.equal(api.isHostKeyTrustedBySystem({
|
||||
hostname: "example.com", fingerprint: describeHostKey(different.blob).fingerprint,
|
||||
}), false);
|
||||
|
||||
const revoked = makeApi({ [HOME_KH]: `example.com ${first.line}\n@revoked example.com ${renewed.line}\n` });
|
||||
assert.equal(revoked.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
|
||||
const ca = makeApi({ [HOME_KH]: `@cert-authority example.com ${first.line}\n` });
|
||||
assert.equal(ca.api.isHostKeyTrustedBySystem({ hostname: "example.com", fingerprint: liveFingerprint }), false);
|
||||
});
|
||||
|
||||
test("system known_hosts paths include the OpenSSH defaults for the platform", () => {
|
||||
const { api } = makeApi();
|
||||
const paths = api.getSystemKnownHostsPaths();
|
||||
|
||||
@@ -7174,6 +7174,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
const singleChannelCopy = srcClient.__netcattySingleChannelSsh || sshClient?.__netcattySingleChannelSsh;
|
||||
if (sshClient && typeof sshClient.exec === 'function' && !singleChannelCopy) {
|
||||
try {
|
||||
// cp -a preserves link nodes; the SFTP fallback follows target bytes.
|
||||
// A failed lstat also falls through to that existing route.
|
||||
const sourceAttrs = await runCancelablePreflight(() => srcClient.lstat(
|
||||
encodePathForSession(sourceSftpId, sourcePath, resolvedSourceEnc),
|
||||
));
|
||||
if (sourceAttrs.isSymbolicLink) throw new Error("Source is a symbolic link");
|
||||
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try {
|
||||
await runTransferAbortableOperation(transfer, (signal) => ensureRemoteDirForSession(
|
||||
@@ -7186,6 +7193,18 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const targetClient = sftpClients.get(targetSftpId);
|
||||
let existingRemoteMode = null;
|
||||
try {
|
||||
const attrs = await runCancelablePreflight(() => targetClient.stat(
|
||||
encodePathForSession(targetSftpId, targetPath, resolvedTargetEnc),
|
||||
));
|
||||
if (Number.isFinite(attrs?.mode)) existingRemoteMode = attrs.mode & 0o7777;
|
||||
} catch {
|
||||
// Missing destinations or failed stats need no permission restore.
|
||||
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
|
||||
@@ -7194,6 +7213,13 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
if (result.code === 0) {
|
||||
sendProgress(fileSize, fileSize);
|
||||
sameHostDone = true;
|
||||
await restoreRemoteUploadModeBestEffort(
|
||||
targetClient,
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
resolvedTargetEnc,
|
||||
existingRemoteMode,
|
||||
);
|
||||
} else if (result.code === 127) {
|
||||
// Exit 127 = command not found — cache to skip future attempts
|
||||
cpUnavailableSet.add(srcClient);
|
||||
@@ -8204,11 +8230,28 @@ async function sameHostCopyDirectory(event, payload) {
|
||||
}
|
||||
if (cpUnavailableSet.has(client)) return { success: false };
|
||||
|
||||
const resolvedEncoding = resolveEncodingForRequest(sftpId, encoding);
|
||||
if (resolvedEncoding && resolvedEncoding !== 'utf-8') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const sshClient = client.client;
|
||||
if (!sshClient || typeof sshClient.exec !== 'function') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
// Merges must use recursive SFTP transfers to preserve destination child modes.
|
||||
// Only a clearly missing target is safe for the remote cp optimization.
|
||||
try {
|
||||
await runTransferCancelablePreflight(transfer, () => client.lstat(
|
||||
encodePathForSession(sftpId, targetPath, resolvedEncoding),
|
||||
));
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
if (!isMissingRemoteStageError(error)) return { success: false };
|
||||
}
|
||||
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
const lifecyclePayload = {
|
||||
...payload,
|
||||
@@ -8233,8 +8276,7 @@ async function sameHostCopyDirectory(event, payload) {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
// Use "source/." to copy directory *contents* into target, preserving merge
|
||||
// semantics consistent with the recursive per-file transfer path.
|
||||
// Use "source/." to copy directory *contents* into the newly created target.
|
||||
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
|
||||
@@ -106,6 +106,7 @@ test("same-host cp capability cache follows the live SFTP client, not a recycled
|
||||
bridge.init({ sftpClients });
|
||||
|
||||
const makeClient = (exitCode, calls) => ({
|
||||
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
calls.count += 1;
|
||||
|
||||
@@ -1294,7 +1294,9 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
t.after(async () => fs.promises.rm(tempPath, { force: true }));
|
||||
let markPrefixStarted;
|
||||
const prefixStarted = new Promise((resolve) => { markPrefixStarted = resolve; });
|
||||
let execCalls = 0;
|
||||
const sourceClient = {
|
||||
client: { exec() { execCalls += 1; throw new Error("checkpointed copies must not exec cp"); } },
|
||||
sftp: createFastSftp({
|
||||
createReadStream() {
|
||||
markPrefixStarted();
|
||||
@@ -1317,7 +1319,7 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
targetSftpId: "target",
|
||||
totalBytes: 8,
|
||||
resumable: true,
|
||||
sameHost: false,
|
||||
sameHost: true,
|
||||
resumeStage: "download",
|
||||
checkpointBytes: 4,
|
||||
downloadCheckpointBytes: 4,
|
||||
@@ -1332,6 +1334,7 @@ test("server-to-server resume cancellation settles while source identity verific
|
||||
]);
|
||||
|
||||
assert.match(result.error || "", /cancel/i);
|
||||
assert.equal(execCalls, 0, "same-host resume must verify its saved prefix instead of running cp");
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
||||
});
|
||||
@@ -13243,6 +13246,119 @@ test("same-host directory copy rejected by a committed close leaves no lease or
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
|
||||
test("same-host directory copy skips cp when auto encoding resolves to non-UTF-8", async (t) => {
|
||||
const {
|
||||
sftpTransferSessionLeaseStore,
|
||||
} = require("./sftpTransferSessionLease.cjs");
|
||||
sftpTransferSessionLeaseStore.resetForTests();
|
||||
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
||||
|
||||
const sftpId = "same-host-directory-auto-gb18030";
|
||||
// Seed the session's resolved encoding before the copy requests auto.
|
||||
sftpBridge.resolveEncodingForRequest(sftpId, "gb18030");
|
||||
let execCalls = 0;
|
||||
transferBridge.init({
|
||||
sftpClients: new Map([[sftpId, {
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
execCalls += 1;
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
}]]),
|
||||
});
|
||||
const sender = createSender();
|
||||
|
||||
const result = await transferBridge.sameHostCopyDirectory(
|
||||
{ sender },
|
||||
{
|
||||
transferId: "same-host-directory-auto-gb18030-copy",
|
||||
sftpId,
|
||||
sourcePath: "/source",
|
||||
targetPath: "/\u76ee\u6807",
|
||||
encoding: "auto",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(execCalls, 0);
|
||||
assert.equal(result.success, false);
|
||||
assert.deepEqual(sender.sent, []);
|
||||
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
|
||||
for (const targetState of ["existing", "missing", "stat-error"]) {
|
||||
test(`same-host directory copy ${targetState} target uses cp only for a fresh destination`, async (t) => {
|
||||
const { sftpTransferSessionLeaseStore } = require("./sftpTransferSessionLease.cjs");
|
||||
sftpTransferSessionLeaseStore.resetForTests();
|
||||
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
|
||||
const sftpId = `same-host-directory-${targetState}`;
|
||||
const commands = [];
|
||||
const mkdirs = [];
|
||||
let targetExists = targetState === "existing";
|
||||
const client = {
|
||||
async lstat(remotePath) {
|
||||
assert.equal(String(remotePath), "/target");
|
||||
assert.deepEqual(mkdirs, [], "check the destination before ensuring it");
|
||||
if (targetState === "stat-error") {
|
||||
throw Object.assign(new Error("Permission denied"), { code: 3 });
|
||||
}
|
||||
if (!targetExists) throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
return { isDirectory: true };
|
||||
},
|
||||
sftp: createFastSftp({
|
||||
stat(_remotePath, callback) {
|
||||
if (!targetExists) {
|
||||
callback(Object.assign(new Error("ENOENT"), { code: 2 }));
|
||||
return;
|
||||
}
|
||||
callback(null, { isDirectory: () => true });
|
||||
},
|
||||
mkdir(remotePath, callback) {
|
||||
mkdirs.push(String(remotePath));
|
||||
targetExists = true;
|
||||
callback(null);
|
||||
},
|
||||
}),
|
||||
client: {
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
assert.equal(targetExists, true, "create the fresh destination before cp");
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
};
|
||||
const sftpClients = new Map([[sftpId, client]]);
|
||||
transferBridge.init({ sftpClients });
|
||||
sftpBridge.init({ sftpClients });
|
||||
const sender = createSender();
|
||||
const result = await transferBridge.sameHostCopyDirectory({ sender }, {
|
||||
transferId: `${sftpId}-copy`, sftpId,
|
||||
sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (targetState === "missing") {
|
||||
assert.deepEqual(result, { success: true });
|
||||
assert.deepEqual(commands, ["cp -ra '/source/.' '/target/'"]);
|
||||
assert.deepEqual(mkdirs, ["/target"]);
|
||||
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
|
||||
} else {
|
||||
assert.deepEqual(result, { success: false });
|
||||
assert.deepEqual(commands, []);
|
||||
assert.deepEqual(mkdirs, []);
|
||||
assert.deepEqual(sender.sent, []);
|
||||
}
|
||||
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
|
||||
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("same-host directory copy cancellation aborts cp and releases its lease", async (t) => {
|
||||
const {
|
||||
sftpTransferSessionLeaseStore,
|
||||
@@ -13259,6 +13375,7 @@ test("same-host directory copy cancellation aborts cp and releases its lease", a
|
||||
stream.end = () => {};
|
||||
stream.destroy = () => {};
|
||||
const client = {
|
||||
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
@@ -13342,6 +13459,7 @@ test("same-host file cp cancellation releases the shared session lease", async (
|
||||
stream.end = () => {};
|
||||
stream.destroy = () => {};
|
||||
const client = {
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
client: {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
@@ -15139,3 +15257,209 @@ test("pending channel-open skips the fastPut reopen and uses the shared channel"
|
||||
assert.ok(sharedWrites >= 1);
|
||||
assert.deepEqual(remoteFiles.get(targetPath), payload);
|
||||
});
|
||||
|
||||
test("same-host file cp restores existing destination mode", async () => {
|
||||
const commands = [];
|
||||
const chmods = [];
|
||||
let targetMode = 0o100600;
|
||||
let downloads = 0;
|
||||
let uploads = 0;
|
||||
const sourceSftp = createFastSftp({
|
||||
open() { downloads++; throw new Error("unexpected download"); },
|
||||
});
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
stat: async () => ({ size: 64, mode: 0o100644 }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
targetMode = 0o100644;
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", 0));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetClient = {
|
||||
sftp: createFastSftp({
|
||||
open() { uploads++; throw new Error("unexpected upload"); },
|
||||
}),
|
||||
stat: async () => ({ size: 64, mode: targetMode }),
|
||||
chmod: async (remotePath, mode) => {
|
||||
chmods.push([String(remotePath), mode]);
|
||||
targetMode = mode;
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: `same-host-cp-mode-${crypto.randomUUID()}`,
|
||||
sourcePath: "/source/payload.bin", targetPath: "/target/payload.bin",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: 64,
|
||||
sameHost: true, resumable: false,
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
|
||||
assert.deepEqual(chmods, [["/target/payload.bin", 0o600]]);
|
||||
assert.equal(targetMode, 0o600);
|
||||
assert.equal(downloads, 0);
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
|
||||
for (const sourceState of ["symlink", "lstat-error"]) {
|
||||
test(`same-host file cp skips a source ${sourceState} and downloads/uploads target bytes`, async (t) => {
|
||||
const transferId = `same-host-cp-fallback-${crypto.randomUUID()}`;
|
||||
const sourcePath = "/opt/app/config";
|
||||
const targetPath = "/tmp/app/config";
|
||||
const payload = Buffer.from("contents of ../shared/config");
|
||||
const commands = [];
|
||||
let reads = 0;
|
||||
let remote = Buffer.alloc(0);
|
||||
let promoted = false;
|
||||
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
|
||||
const originalRead = sourceSftp.read;
|
||||
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
async lstat(remotePath) {
|
||||
assert.equal(String(remotePath), sourcePath);
|
||||
if (sourceState === "lstat-error") throw new Error("lstat unsupported");
|
||||
return { isSymbolicLink: true, size: "../shared/config".length };
|
||||
},
|
||||
stat: async () => ({ size: payload.length }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
callback(new Error("unexpected exec"));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetSftp = createFastSftp({
|
||||
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
|
||||
write(_handle, buffer, offset, length, position, callback) {
|
||||
if (remote.length < position + length) {
|
||||
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
|
||||
}
|
||||
buffer.copy(remote, position, offset, offset + length);
|
||||
callback(null);
|
||||
},
|
||||
close(_handle, callback) { callback(null); },
|
||||
});
|
||||
const targetClient = {
|
||||
sftp: targetSftp,
|
||||
client: { sftp: (callback) => callback(null, targetSftp) },
|
||||
async stat(remotePath) {
|
||||
if (String(remotePath) === targetPath && remote.length === 0) {
|
||||
throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
}
|
||||
return { size: remote.length };
|
||||
},
|
||||
rename: async () => { promoted = true; },
|
||||
delete: async () => {},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "config");
|
||||
t.after(async () => fs.promises.rm(localStage, { force: true }));
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId, sourcePath, targetPath,
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: payload.length, sameHost: true, resumable: false,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, [], "source symlinks and lstat failures must bypass cp -a");
|
||||
assert.ok(reads > 0, "fallback must download the followed source contents");
|
||||
assert.deepEqual(remote, payload, "fallback must upload target bytes rather than the link node");
|
||||
assert.equal(promoted, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const cpResult of [0, 127, 1, "exec-error"]) {
|
||||
test(`fresh same-host file cp result ${cpResult} preserves copy/fallback behavior (#4207)`, async (t) => {
|
||||
const transferId = `same-host-cp-${crypto.randomUUID()}`;
|
||||
const payload = Buffer.from("same-host ordinary SFTP copy");
|
||||
const commands = [];
|
||||
const chmods = [];
|
||||
let reads = 0;
|
||||
let remote = Buffer.alloc(0);
|
||||
let promoted = false;
|
||||
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
|
||||
const originalRead = sourceSftp.read;
|
||||
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
|
||||
const sourceClient = {
|
||||
sftp: sourceSftp,
|
||||
lstat: async () => ({ isSymbolicLink: false }),
|
||||
stat: async () => ({ size: payload.length }),
|
||||
client: {
|
||||
sftp: (callback) => callback(null, sourceSftp),
|
||||
exec(command, callback) {
|
||||
commands.push(command);
|
||||
if (cpResult === "exec-error") { callback(new Error("exec denied")); return; }
|
||||
const stream = new PassThrough();
|
||||
stream.stderr = new PassThrough();
|
||||
callback(null, stream);
|
||||
setImmediate(() => stream.emit("close", cpResult));
|
||||
},
|
||||
},
|
||||
};
|
||||
const targetSftp = createFastSftp({
|
||||
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
|
||||
write(_handle, buffer, offset, length, position, callback) {
|
||||
if (remote.length < position + length) {
|
||||
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
|
||||
}
|
||||
buffer.copy(remote, position, offset, offset + length);
|
||||
callback(null);
|
||||
},
|
||||
close(_handle, callback) { callback(null); },
|
||||
});
|
||||
const targetClient = {
|
||||
sftp: targetSftp,
|
||||
client: { sftp: (callback) => callback(null, targetSftp) },
|
||||
stat: async (remotePath) => {
|
||||
if (String(remotePath) === "/target/payload.bin" && remote.length === 0) {
|
||||
throw Object.assign(new Error("ENOENT"), { code: 2 });
|
||||
}
|
||||
return { size: remote.length };
|
||||
},
|
||||
chmod: async (...args) => { chmods.push(args); },
|
||||
rename: async () => { promoted = true; },
|
||||
delete: async () => {},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
|
||||
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
|
||||
t.after(async () => fs.promises.rm(localStage, { force: true }));
|
||||
const sender = createSender();
|
||||
const result = await transferBridge.startTransfer({ sender }, {
|
||||
transferId,
|
||||
sourcePath: "/source/payload.bin",
|
||||
targetPath: "/target/payload.bin",
|
||||
sourceType: "sftp", targetType: "sftp",
|
||||
sourceSftpId: "source", targetSftpId: "target",
|
||||
sourceEncoding: "utf-8", targetEncoding: "utf-8",
|
||||
totalBytes: payload.length,
|
||||
sameHost: true, resumable: false,
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error);
|
||||
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
|
||||
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
|
||||
if (cpResult === 0) {
|
||||
assert.deepEqual(chmods, [], "fresh cp must not restore destination permissions");
|
||||
assert.equal(reads, 0, "successful cp must not download the source");
|
||||
assert.equal(remote.length, 0, "successful cp must not upload through SFTP");
|
||||
assert.equal(fs.existsSync(localStage), false);
|
||||
} else {
|
||||
assert.ok(reads > 0, "cp failure must fall back to SFTP download");
|
||||
assert.deepEqual(remote, payload, "fallback must upload the downloaded contents");
|
||||
assert.equal(promoted, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const {
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
const { attachDisplayRecovery } = require("./displayRecovery.cjs");
|
||||
const perfTrace = require("../../perfTrace.cjs");
|
||||
const { finalizeSessionLogs } = require("../sessionLogShutdown.cjs");
|
||||
|
||||
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
|
||||
|
||||
@@ -534,11 +535,12 @@ function createMainWindowApi(ctx) {
|
||||
: false;
|
||||
})();
|
||||
Promise.resolve(dirtyEditorQuery)
|
||||
.then((hasDirty) => {
|
||||
.then(async (hasDirty) => {
|
||||
if (hasDirty) {
|
||||
thisWindowCloseRequested = false;
|
||||
return;
|
||||
}
|
||||
await finalizeSessionLogs(win.webContents, { ipcMain: electronModule.ipcMain });
|
||||
dirtyEditorCloseConfirmed = true;
|
||||
try {
|
||||
win.close();
|
||||
|
||||
@@ -10,10 +10,18 @@ for (const peer of [true, false]) {
|
||||
let ownsTabs = true;
|
||||
let ownerChecks = 0;
|
||||
let dirtyChecks = 0;
|
||||
const ipcMain = new EventEmitter();
|
||||
let finishLogCapture;
|
||||
let logCaptures = 0;
|
||||
let closeToTray = false;
|
||||
class Window extends EventEmitter {
|
||||
constructor() {
|
||||
super(); this.destroyed = false;
|
||||
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send() {} });
|
||||
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send: (channel, payload) => {
|
||||
if (channel !== "app:finalize-session-logs") return;
|
||||
logCaptures++;
|
||||
finishLogCapture = () => ipcMain.emit("app:session-logs-finalized", { sender: this.webContents }, { requestId: payload.requestId, success: true });
|
||||
} });
|
||||
}
|
||||
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit("close", event); if (!event.prevented) { this.destroyed = true; this.emit("closed"); } }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
@@ -30,7 +38,11 @@ for (const peer of [true, false]) {
|
||||
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
|
||||
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname, URL, require, console, setTimeout, clearTimeout,
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
|
||||
getGlobalShortcutBridge: () => ({ handleWindowClose: (event) => {
|
||||
if (!closeToTray) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
} }),
|
||||
getMainWindowCount: () => 1,
|
||||
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
|
||||
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
|
||||
@@ -43,10 +55,18 @@ for (const peer of [true, false]) {
|
||||
closeEditorTabsForSource: () => { ownerChecks++; return new Promise((resolve) => { finish = resolve; }); },
|
||||
queryDirtyEditors: async () => { dirtyChecks++; return false; },
|
||||
});
|
||||
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain: {} }, {
|
||||
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
|
||||
preload: "/tmp/preload.cjs", devServerUrl: "http://localhost:5173", isDev: true, isMac: true,
|
||||
electronDir: __dirname, route: peer ? "session-window" : undefined, registerAsMainWindow: !peer,
|
||||
});
|
||||
if (!peer) {
|
||||
closeToTray = true;
|
||||
win.close(); await tick();
|
||||
assert.equal(win.destroyed, false);
|
||||
assert.equal(logCaptures, 0, "hiding to tray must keep the session log live");
|
||||
assert.equal(ownerChecks, 0);
|
||||
closeToTray = false;
|
||||
}
|
||||
win.close(); win.close();
|
||||
assert.equal(ownerChecks, 1, "repeat close must share the ownership check");
|
||||
assert.equal(dirtyChecks, 0, "the short dirty query must not race a detached Save/Cancel prompt");
|
||||
@@ -54,10 +74,14 @@ for (const peer of [true, false]) {
|
||||
finish({ success: true, cancelled: true }); await tick();
|
||||
assert.equal(win.destroyed, false, "Cancel keeps the source save/dock renderer alive");
|
||||
assert.equal(dirtyChecks, 0);
|
||||
assert.equal(logCaptures, 0, "Cancel must leave the live log ongoing");
|
||||
win.close();
|
||||
ownsTabs = false;
|
||||
finish({ success: true, cancelled: false }); await tick();
|
||||
assert.equal(dirtyChecks, 1);
|
||||
assert.equal(logCaptures, 1);
|
||||
assert.equal(win.destroyed, false, "renderer must survive until replay persistence acknowledges completion");
|
||||
finishLogCapture(); await tick();
|
||||
assert.equal(win.destroyed, true, "close proceeds only after all owned editors settled");
|
||||
});
|
||||
}
|
||||
+12
-6
@@ -237,6 +237,7 @@ const {
|
||||
} = require("./main/appLockLifecycle.cjs");
|
||||
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
|
||||
const { queryDirtyEditors } = require("./bridges/dirtyEditorGuard.cjs");
|
||||
const { finalizeSessionLogs } = require("./bridges/sessionLogShutdown.cjs");
|
||||
|
||||
// GPU settings
|
||||
// NOTE: Do not disable Chromium sandbox by default.
|
||||
@@ -1665,7 +1666,17 @@ if (!gotLock) {
|
||||
}
|
||||
getWindowManager().setIsQuitting(true);
|
||||
quitGuardChannelBusy = true;
|
||||
void runPluginShutdown()
|
||||
void (async () => {
|
||||
try {
|
||||
await Promise.all(getWindowManager().getAppContentWindows().map((win) => (
|
||||
finalizeSessionLogs(win.webContents, { ipcMain: electronModule.ipcMain })
|
||||
)));
|
||||
await sessionLogStreamManager.cleanupAll();
|
||||
} catch (error) {
|
||||
console.warn("[SessionLogs] Shutdown failed; continuing app quit:", error);
|
||||
}
|
||||
return runPluginShutdown();
|
||||
})()
|
||||
.then(({ timedOut }) => {
|
||||
if (timedOut) console.warn("[Plugins] Shutdown deadline elapsed; continuing app quit");
|
||||
})
|
||||
@@ -1727,11 +1738,6 @@ if (!gotLock) {
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
sessionLogStreamManager.cleanupAll();
|
||||
} catch (err) {
|
||||
console.warn("Error during session log stream cleanup:", err);
|
||||
}
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
|
||||
@@ -7,6 +7,19 @@ const fs = require("node:fs");
|
||||
const { stageRendererFileToTemp } = require("./stageUploadFile.cjs");
|
||||
|
||||
function createPreloadApi(ctx) {
|
||||
const sessionLogFinalizers = new Set();
|
||||
let pendingSessionLogFinalization = null;
|
||||
ctx.ipcRenderer?.on?.("app:finalize-session-logs", async (_event, { requestId }) => {
|
||||
const pending = pendingSessionLogFinalization ??= Promise.allSettled(
|
||||
[...sessionLogFinalizers].map((finalize) => Promise.resolve().then(finalize)),
|
||||
);
|
||||
const results = await pending;
|
||||
if (pendingSessionLogFinalization === pending) pendingSessionLogFinalization = null;
|
||||
ctx.ipcRenderer.send("app:session-logs-finalized", {
|
||||
requestId,
|
||||
success: results.every((result) => result.status === "fulfilled"),
|
||||
});
|
||||
});
|
||||
const terminalDataBacklog = ctx.terminalDataBacklog || null;
|
||||
const displayDataListeners = ctx.displayDataListeners || new Map();
|
||||
const closedTerminalDataSessions = ctx.closedTerminalDataSessions || null;
|
||||
@@ -1544,6 +1557,10 @@ function createPreloadApi(ctx) {
|
||||
reportDirtyEditorsResult: (hasDirty, reason, options) => ipcRenderer.send("app:dirty-editors-result", {
|
||||
hasDirty, reason, requestId: options?.requestId, pending: options?.pending === true,
|
||||
}),
|
||||
onFinalizeSessionLogs: (finalize) => {
|
||||
sessionLogFinalizers.add(finalize);
|
||||
return () => sessionLogFinalizers.delete(finalize);
|
||||
},
|
||||
|
||||
// Port Forwarding API
|
||||
startPortForward: async (options) => {
|
||||
|
||||
+1
@@ -299,6 +299,7 @@ declare global {
|
||||
// Quit guard: subscribe to main-process quit requests that query for dirty editors.
|
||||
// Each response must echo the request ID, including pending-transfer acknowledgements.
|
||||
onCheckDirtyEditors?(listener: (request: { requestId: string; deadline: number }) => void): () => void;
|
||||
onFinalizeSessionLogs?(listener: () => Promise<void>): () => void;
|
||||
// Report the dirty-check result back to the main process.
|
||||
reportDirtyEditorsResult?(hasDirty: boolean, reason?: "editors" | "transfers", options?: { requestId?: string; pending?: boolean }): void;
|
||||
|
||||
|
||||
+2
-1
@@ -111,7 +111,7 @@ export default defineConfig(() => {
|
||||
build: {
|
||||
// The shared runtime is also required by Electron; convert it for browser builds.
|
||||
commonjsOptions: {
|
||||
include: [/node_modules/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
|
||||
include: [/node_modules/, /domain\/sshPublicKey\.shared\.cjs$/, /domain\/systemManager\/containerRuntime\.shared\.cjs$/, /domain\/plugin(?:RuntimePolicy|PermissionDescriptions|ThemePack|RemoteTreePolicy|ViewStylePolicy|GitHubRepository)\.cjs$/],
|
||||
},
|
||||
chunkSizeWarningLimit: 3000,
|
||||
target: 'esnext', // Required for top-level await in WASM modules
|
||||
@@ -134,6 +134,7 @@ export default defineConfig(() => {
|
||||
include: [
|
||||
// Keep the renderer alias pre-bundled so dev serves ESM instead of raw CommonJS.
|
||||
'@/domain/systemManager/containerRuntime.shared.cjs',
|
||||
'@/domain/sshPublicKey.shared.cjs',
|
||||
'@/domain/pluginRuntimePolicy.cjs',
|
||||
'@/domain/pluginGitHubRepository.cjs',
|
||||
'@/domain/pluginPermissionDescriptions.cjs',
|
||||
|
||||
Reference in new issue
Block a user