Compare commits

...
Author SHA1 Message Date
陈大猫 0cb540c3f8 fix(ci): wait for plugin log dialog focus cleanup 2026-11-14 19:36:01 +08:00
陈大猫andCodex db3a9e88e2 fix(terminal): cap WebGL contexts for visible split panes (Fixes #4128) (#4239)
The #4122 budget of 12 only released hidden panes, so 17+ visible
splits still crossed Chromium’s 16-context limit and the 50ms
rebuild avalanched into white panes until the DOM breaker tripped.

Refuse new visible contexts when visible holders fill the cap while
preserving recently hidden holders during the tab-flip grace period.
Cover acquisition limits, missed visibility syncs, and recovery at cap.

Co-authored-by: Codex <codex@openai.com>
2026-11-14 18:21:32 +08:00
陈大猫andCodex 65435c06f5 fix(x11): hide fd-bearing X11 extensions on byte-only forwards (Fixes #4200) (#4238)
Clear QueryExtension present for DRI3 and MIT-SHM on built-in X11 forwards so clients avoid SCM_RIGHTS replies that the SSH byte stream cannot transport. Preserve framing, byte order, sequence isolation and cookie rewriting.

Strip AppImage ARGV0 from local shell PTY environments so zsh preserves ssh argv[0] and Xorg recognizes system SSH forwarding. Add protocol and environment regression tests. Fixes #4200.

Co-authored-by: Codex <codex@openai.com>
2026-11-14 16:28:28 +08:00
陈大猫 3096f86889 fix(sftp): use remote cp for fresh same-host copies (Fixes #4207) (#4226)
* fix(sftp): use remote cp for fresh same-host copies (Fixes #4207)

Ordinary SFTP tasks were marked resumable, which skipped the existing file and directory cp optimizations. Select the non-resumable route only when creating fresh matching-host copies with eligible encodings, keeping transfer-center rows and recursive children consistent. Existing checkpointed transfers retain their saved resumable path; failed cp attempts retain the download/upload fallback.

* fix(sftp): skip same-host directory cp when auto encoding resolves to non-UTF-8

* fix(sftp): preserve dest mode when same-host cp replaces a file

* fix(sftp): skip same-host cp for symlinks and directory merges

* test(sftp): mock missing dest in same-host cp cache fixture

* fix(sftp): expose lstat on session-backed copy clients
2026-11-13 17:24:27 +08:00
陈大猫 94f8bd9bc7 fix(ai): recognize Enter-PSSession remoting prompts as idle (#4229)
Fixes #4228

PowerShell remoting (Enter-PSSession / WinRM) prefixes the default
prompt with [ComputerName]: , which POWERSHELL_PROMPT_PATTERN missed,
so the readiness gate returned SHELL_NOT_READY and never sent the
command. Accept one or more [token]: prefixes before the existing
PS ...> shape, including [DBG]:.
2026-11-13 16:47:59 +08:00
陈大猫 5cd42cb518 fix(settings): include folder compression in settings search (#4231)
Fixes #4230

Settings search uses a static catalog. The SFTP "Enable folder
compression" row was already in the tab but missing from the catalog
and had no anchor, so queries such as 压缩 showed no matches. Index
that destination, plus the skip-unchanged and SSH keep-alive rows in
the same tab that had the same omission.
2026-11-13 16:30:21 +08:00
陈大猫andCodex 9ccb62ea07 fix(ssh): fingerprint OpenSSH host certs by the underlying key (Fixes #4222)
Fixes #4222

describeHostKey previously hashed the entire certificate blob, so load-balanced or re-issued certs for the same host key looked like a fingerprint change. Pin the underlying public key instead.

Share extraction across verifier, renderer migration, and system known_hosts. Migrate legacy full-cert fingerprints only when the stored certificate body is available, preserving certificate metadata and raw rekey checks.

Co-authored-by: Codex <codex@openai.com>
2026-11-13 09:33:14 +08:00
36 changed files with 1709 additions and 43 deletions

No files matched your search

@@ -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();
}
});
}
+15 -2
View File
@@ -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",
});
}
@@ -66,9 +66,15 @@ test('local logging controls default off, disclose verbose risk, and call host-o
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
await act(async () => button('Close').click());
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, configure);
// Radix restores focus in a setTimeout(0) after the dialog unmounts.
// act() can finish before that timer, so drain it before checking focus.
await new Promise((resolve) => setTimeout(resolve, 0));
// Comparing DOM objects directly makes Node inspect the entire JSDOM graph
// on failure, which can stall the test process while formatting the diff.
assert.ok(document.activeElement === configure, `expected focus on Configure, got ${document.activeElement?.tagName}`);
} finally {
await act(async () => root.unmount());
await new Promise((resolve) => setTimeout(resolve, 0));
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
@@ -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();
@@ -89,6 +89,15 @@ test("a visible pane past the cap releases the least recently seen hidden pane",
assert.equal(budget.getActiveCount(), 3);
});
test("new visible panes are refused when visible holders fill the cap", () => {
const { budget } = setup(2);
const members = ["a", "b", "c", "d"].map((name) => new FakeMember(name));
assert.deepEqual(members.map((member) => budget.tryAcquire(member)), [true, true, false, false]);
assert.equal(budget.tryAcquire(members[0]), true, "an existing holder keeps its slot");
assert.equal(budget.getActiveCount(), 2);
assert.deepEqual(members.map((member) => member.releases), [0, 0, 0, 0]);
});
test("recently hidden panes are released only once they stay hidden long enough", () => {
const { clock, budget } = setup(2);
const [a, b] = ["a", "b"].map((name) => new FakeMember(name));
@@ -101,6 +110,8 @@ test("recently hidden panes are released only once they stay hidden long enough"
budget.tryAcquire(new FakeMember("shown"));
assert.equal(a.releases, 0, "a quick tab flip must not lose the renderer");
assert.equal(budget.getActiveCount(), 3);
assert.equal(budget.tryAcquire(new FakeMember("overflow")), false);
assert.equal(budget.getActiveCount(), 3, "one hidden holder allows only one extra context");
clock.advance(2_999);
assert.equal(a.releases, 0);
@@ -110,26 +121,33 @@ test("recently hidden panes are released only once they stay hidden long enough"
assert.equal(clock.pendingTimers, 0);
});
test("a pane hidden after the budget went over is released once eligible", () => {
test("a pane hidden after a grace overshoot is released once eligible", () => {
const { clock, budget } = setup(2);
const [a, b, c] = ["a", "b", "c"].map((name) => new FakeMember(name));
budget.tryAcquire(a);
budget.tryAcquire(b);
budget.tryAcquire(c);
assert.equal(budget.getActiveCount(), 3, "three visible panes all keep WebGL");
assert.equal(budget.tryAcquire(c), false);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
a.hiddenSince = clock.now();
budget.noteHidden(a);
assert.equal(budget.tryAcquire(c), true);
assert.equal(budget.getActiveCount(), 3, "recently hidden holders allow a grace overshoot");
a.hiddenSince = null;
b.hiddenSince = clock.now();
budget.noteHidden(b);
clock.advance(3_000);
assert.equal(b.releases, 1);
assert.equal(a.releases, 0, "a holder shown again keeps its renderer");
assert.equal(budget.getActiveCount(), 2);
});
test("a hidden pane cannot take a context once the budget is full", () => {
const { budget } = setup(2);
budget.tryAcquire(new FakeMember("a"));
const { clock, budget } = setup(2);
const a = new FakeMember("a");
budget.tryAcquire(a);
budget.tryAcquire(new FakeMember("b"));
const hidden = new FakeMember("hidden");
@@ -138,7 +156,13 @@ test("a hidden pane cannot take a context once the budget is full", () => {
assert.equal(budget.getActiveCount(), 2);
hidden.hiddenSince = null;
assert.equal(budget.tryAcquire(hidden), true, "it gets one as soon as it is shown");
assert.equal(budget.tryAcquire(hidden), false, "visible holders still fill the cap");
a.hiddenSince = clock.now();
budget.noteHidden(a);
clock.advance(3_000);
assert.equal(budget.tryAcquire(hidden), true, "a shown pane can replace an eligible hidden holder");
assert.equal(a.releases, 1);
assert.equal(budget.getActiveCount(), 2);
});
test("released and forgotten panes free their slot", () => {
@@ -10,8 +10,9 @@
*
* The budget stays below the browser limit by releasing the renderers of panes
* that have been hidden for a while, least recently seen first. Visible panes
* are never released and always get a context. A released pane builds a new
* renderer when it is shown again. Below the cap nothing changes.
* are never released, but new ones stay on DOM when visible holders fill the
* cap. Recently hidden holders may temporarily exceed the cap during tab flips.
* A released pane can rebuild when shown if the budget permits.
*/
export const MAX_ACTIVE_WEBGL_CONTEXTS = 12;
@@ -31,8 +32,8 @@ export interface WebglContextBudgetMember {
export interface WebglContextBudget {
/**
* Reserve a context for a member about to create its renderer. Visible
* members always get one; hidden members only while the budget has room.
* Reserve a context if the budget has room. Visible members may replace
* hidden holders, allowing a temporary overshoot until their grace expires.
*/
tryAcquire(member: WebglContextBudgetMember): boolean;
/** Give the slot back once the member's renderer is gone. Idempotent. */
@@ -102,7 +103,11 @@ export function createWebglContextBudget(
return {
tryAcquire(member) {
if (active.has(member)) return true;
if (member.getHiddenSince() !== null && active.size >= maxActive) return false;
if (active.size >= maxActive) {
if (member.getHiddenSince() !== null) return false;
const visibleCount = [...active].filter((holder) => holder.getHiddenSince() === null).length;
if (visibleCount >= maxActive) return false;
}
active.add(member);
releaseOverflow();
return true;
@@ -186,20 +186,93 @@ test("panes past the budget give up WebGL after staying hidden, and get it back
assert.equal(budget.getActiveCount(), 2);
});
test("simultaneous visible ensures stay within the context budget", () => {
const { budget, createPane } = setup();
const panes = Array.from({ length: 13 }, () => createPane());
for (const pane of panes) pane.controller.ensure();
assert.deepEqual(panes.map((pane) => pane.loaded), [...Array(12).fill(true), false]);
assert.equal(panes[12].addons.length, 0);
assert.equal(budget.getActiveCount(), 12);
});
test("visible context loss recovers at the cap, including with a refused visible pane", () => {
for (const withOverflow of [false, true]) {
const { clock, budget, createPane } = setup(2);
const panes = [createPane(), createPane()];
for (const pane of panes) pane.controller.ensure();
const overflow = withOverflow ? createPane() : null;
overflow?.controller.ensure();
assert.equal(overflow?.addons.length ?? 0, 0);
assert.equal(budget.getActiveCount(), 2);
panes[0].addons[0].loseContext();
assert.equal(budget.getActiveCount(), 1);
clock.advance(49);
assert.equal(panes[0].loaded, false);
clock.advance(1);
assert.equal(panes[0].loaded, true);
assert.equal(panes[0].addons.length, 2);
assert.equal(panes[1].addons.length, 1);
overflow?.controller.ensure();
assert.equal(overflow?.addons.length ?? 0, 0);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
}
});
test("visible loss stays on DOM if another visible pane takes the freed slot before recovery", () => {
const { clock, budget, createPane } = setup(2);
const panes = [createPane(), createPane(), createPane()];
for (const pane of panes) pane.controller.ensure();
assert.equal(panes[2].loaded, false);
panes[0].addons[0].loseContext();
panes[2].controller.ensure();
assert.equal(panes[2].loaded, true);
clock.advance(50);
assert.equal(panes[0].loaded, false);
assert.deepEqual(panes.map((pane) => pane.addons.length), [1, 1, 1]);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
});
test("a hidden pane mounted past the budget keeps the DOM renderer until shown", () => {
const { budget, createPane } = setup(1);
createPane().controller.ensure();
const { clock, budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
assert.equal(hidden.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
occupant.setVisible(false);
clock.advance(3_000);
hidden.reveal();
assert.equal(hidden.loaded, true);
assert.equal(occupant.loaded, false);
assert.equal(budget.getActiveCount(), 1);
});
test("a pane on screen is never refused or released, even if a sync was missed", () => {
test("revealing a hidden pane is refused while visible holders fill the budget", () => {
const { budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
hidden.reveal();
assert.equal(hidden.loaded, false);
assert.equal(hidden.addons.length, 0);
assert.equal(occupant.loaded, true);
assert.equal(budget.getActiveCount(), 1);
});
test("a holder on screen is never released, even if a sync was missed", () => {
const { clock, budget, createPane } = setup(1);
const visible = createPane();
visible.controller.ensure();
@@ -210,12 +283,37 @@ test("a pane on screen is never refused or released, even if a sync was missed",
createPane().controller.ensure();
clock.advance(10_000);
assert.equal(visible.loaded, true);
assert.equal(budget.getActiveCount(), 2);
assert.equal(budget.getActiveCount(), 1);
const late = createPane(false);
late.setVisibleWithoutSync(true);
late.controller.ensure();
assert.equal(late.loaded, true);
assert.equal(late.loaded, false);
assert.equal(late.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
});
test("grace overflow never releases a holder shown again without a visibility sync", () => {
const { clock, budget, createPane } = setup(1);
const holder = createPane();
holder.controller.ensure();
holder.setVisible(false);
const front = createPane();
front.controller.ensure();
assert.equal(budget.getActiveCount(), 2);
holder.setVisibleWithoutSync(true);
clock.advance(3_000);
assert.equal(holder.loaded, true);
assert.equal(front.loaded, true);
assert.equal(holder.addons[0].disposeCalls, 0);
front.addons[0].loseContext();
clock.advance(50);
assert.equal(front.loaded, false, "recovery cannot exceed a cap already filled by visible holders");
assert.equal(front.addons.length, 1);
assert.equal(budget.getActiveCount(), 1);
assert.equal(clock.pendingTimers, 0);
});
test("suspend, dispose and failed loads return the budget slot", () => {
@@ -65,7 +65,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
// isVisible() is the source of truth; hiddenSince only remembers when the
// pane was last seen. Re-reading it at every decision keeps a pane that is
// on screen from being refused or released even if a sync was missed.
// on screen from being released even if a sync was missed.
const readVisibility = () => {
if (!options.isVisible) return;
if (options.isVisible()) hiddenSince = null;
@@ -94,7 +94,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
const ensure = () => {
if (loaded || circuitBroken || disposed || !options.enabled) return;
// A hidden pane past the budget keeps the DOM renderer until it is shown.
// Panes past the budget keep the DOM renderer until a slot is available.
if (budget && !budget.tryAcquire(budgetMember)) return;
let nextAddon: TAddon | null = null;
try {
+54
View File
@@ -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
View File
@@ -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
+42
View File
@@ -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;
+21
View File
@@ -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",
+84
View File
@@ -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;
+1
View File
@@ -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();
+4 -3
View File
@@ -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
+40
View File
@@ -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 };
+17 -4
View File
@@ -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");
+80
View File
@@ -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: [],
+10
View File
@@ -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();
+3
View File
@@ -959,6 +959,9 @@ async function buildLocalSessionEnv(
) {
const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs");
const env = { ...buildTerminalProcessEnv(baseEnv), ...(payload?.env || {}) };
// AppImage's ARGV0 makes zsh rename external commands, including ssh. Xorg
// then mistakes ssh's X11 connection for a local client and sends unusable fds.
delete env.ARGV0;
if (platform !== "win32") return env;
const { getPathEnvKey, resolveWindowsLivePath } = require("./ai/shellUtils.cjs");
@@ -37,6 +37,15 @@ const STALE_PATH =
const LIVE_PATH =
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64-pc-windows-msvc;C:\\Windows\\System32";
test("buildLocalSessionEnv strips AppImage ARGV0 without changing the base env", async () => {
const baseEnv = { PATH: "/usr/bin:/bin", ARGV0: "/opt/Netcatty.AppImage" };
const env = await bridge.buildLocalSessionEnv({}, { baseEnv, platform: "linux" });
assert.equal(Object.hasOwn(env, "ARGV0"), false);
assert.equal(env.PATH, baseEnv.PATH);
assert.equal(baseEnv.ARGV0, "/opt/Netcatty.AppImage");
});
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", async () => {
const env = await bridge.buildLocalSessionEnv(
{ env: { NETCATTY_MARKER: "1" } },
+44 -2
View File
@@ -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;
+325 -1
View File
@@ -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);
}
});
}
+190
View File
@@ -0,0 +1,190 @@
const { Transform } = require("node:stream");
const DEFAULT_HIDDEN_EXTENSIONS = Object.freeze(["DRI3", "MIT-SHM"]);
const EMPTY = Buffer.alloc(0);
function readUInt16(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
}
function readUInt32(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
}
function pad4(length) {
return Math.ceil(length / 4) * 4;
}
// Retain only the header we need; large request/reply bodies stream through.
function collectHeader(state, length, chunk, offset) {
const count = Math.min(Math.max(0, length - state.header.length), chunk.length - offset);
if (count > 0) {
state.header = Buffer.concat([state.header, chunk.subarray(offset, offset + count)]);
}
return offset + count;
}
class RequestTracker extends Transform {
constructor(shared) {
super();
this.shared = shared;
this.setup = true;
this.header = EMPTY;
this.remaining = 0;
this.sequence = 0;
}
_transform(chunk, _encoding, callback) {
const shared = this.shared;
let offset = 0;
while (offset < chunk.length && shared.enabled) {
if (this.remaining > 0) {
const count = Math.min(this.remaining, chunk.length - offset);
offset += count;
this.remaining -= count;
continue;
}
if (this.setup) {
offset = collectHeader(this, 1, chunk, offset);
if (this.header[0] !== 0x6c && this.header[0] !== 0x42) {
shared.enabled = false;
break;
}
shared.littleEndian = this.header[0] === 0x6c;
offset = collectHeader(this, 12, chunk, offset);
if (this.header.length < 12) break;
this.remaining = pad4(readUInt16(this.header, 6, shared.littleEndian))
+ pad4(readUInt16(this.header, 8, shared.littleEndian));
this.header = EMPTY;
this.setup = false;
continue;
}
offset = collectHeader(this, 4, chunk, offset);
if (this.header.length < 4) break;
let words = readUInt16(this.header, 2, shared.littleEndian);
const headerLength = words === 0 ? 8 : 4;
if (headerLength === 8) {
offset = collectHeader(this, 8, chunk, offset);
if (this.header.length < 8) break;
words = readUInt32(this.header, 4, shared.littleEndian);
}
const total = words * 4;
if (total < headerLength) {
shared.enabled = false;
break;
}
let name;
const nameOffset = headerLength + 4;
if (this.header[0] === 98 && total >= nameOffset) { // QueryExtension
offset = collectHeader(this, nameOffset, chunk, offset);
if (this.header.length < nameOffset) break;
const nameLength = readUInt16(this.header, headerLength, shared.littleEndian);
if (nameLength <= shared.maxNameLength && nameOffset + pad4(nameLength) <= total) {
offset = collectHeader(this, nameOffset + nameLength, chunk, offset);
if (this.header.length < nameOffset + nameLength) break;
name = this.header.toString("latin1", nameOffset, nameOffset + nameLength);
}
}
this.sequence = (this.sequence + 1) & 0xffff;
// Do not let an unanswered query affect an unrelated request after wrap.
shared.pending.delete(this.sequence);
if (shared.hidden.has(name)) shared.pending.set(this.sequence, name);
this.remaining = total - this.header.length;
this.header = EMPTY;
}
callback(null, chunk);
}
}
class ReplyFilter extends Transform {
constructor(shared) {
super();
this.shared = shared;
this.setup = true;
this.header = EMPTY;
this.remaining = 0;
}
_transform(chunk, _encoding, callback) {
const shared = this.shared;
let offset = 0;
while (offset < chunk.length) {
if (!shared.enabled) {
this.push(this.header);
this.header = EMPTY;
this.push(chunk.subarray(offset));
break;
}
if (this.remaining > 0) {
const count = Math.min(this.remaining, chunk.length - offset);
this.push(chunk.subarray(offset, offset + count));
offset += count;
this.remaining -= count;
continue;
}
offset = collectHeader(this, this.setup ? 8 : 4, chunk, offset);
if (this.header.length < (this.setup ? 8 : 4)) break;
if (this.setup) {
this.remaining = readUInt16(this.header, 6, shared.littleEndian) * 4;
this.setup = false;
} else {
const type = this.header[0];
let total = 32; // Errors and ordinary events are fixed size.
if (type === 1 || (type & 0x7f) === 35) { // Reply or GenericEvent
offset = collectHeader(this, 8, chunk, offset);
if (this.header.length < 8) break;
total += readUInt32(this.header, 4, shared.littleEndian) * 4;
}
if (type === 0 || type === 1) {
const sequence = readUInt16(this.header, 2, shared.littleEndian);
const name = shared.pending.get(sequence);
if (name !== undefined) {
if (type === 1) {
offset = collectHeader(this, 9, chunk, offset);
if (this.header.length < 9) break;
if (this.header[8] !== 0) {
this.header[8] = 0; // QueryExtension.present = False
if (!shared.warned) {
shared.warned = true;
shared.logger.warn?.(`[X11] hiding ${name}: forwarding cannot carry file descriptors`);
}
}
}
shared.pending.delete(sequence);
}
}
this.remaining = total - this.header.length;
}
this.push(this.header);
this.header = EMPTY;
}
callback();
}
_flush(callback) {
callback(null, this.header);
}
}
// SCM_RIGHTS descriptors cannot travel over an SSH byte stream. Hide extensions
// that require them so clients select a supported rendering path instead.
function createX11FdGuard(options = {}) {
const hidden = new Set(options.hiddenExtensions ?? DEFAULT_HIDDEN_EXTENSIONS);
const shared = {
enabled: hidden.size > 0,
hidden,
maxNameLength: Math.max(0, ...Array.from(hidden, (name) => name.length)),
littleEndian: true,
pending: new Map(),
warned: false,
logger: options.logger || console,
};
return { toServer: new RequestTracker(shared), toClient: new ReplyFilter(shared) };
}
module.exports = { createX11FdGuard, DEFAULT_HIDDEN_EXTENSIONS };
+204
View File
@@ -0,0 +1,204 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { once } = require("node:events");
const { createX11FdGuard, DEFAULT_HIDDEN_EXTENSIONS } = require("./x11FdGuard.cjs");
function writeUInt16(buffer, value, offset, endian) {
buffer[endian === "l" ? "writeUInt16LE" : "writeUInt16BE"](value, offset);
}
function writeUInt32(buffer, value, offset, endian) {
buffer[endian === "l" ? "writeUInt32LE" : "writeUInt32BE"](value, offset);
}
function setupRequest(endian = "l") {
const packet = Buffer.alloc(24, 0xab);
packet[0] = endian.charCodeAt(0);
writeUInt16(packet, 11, 2, endian);
writeUInt16(packet, 3, 6, endian);
writeUInt16(packet, 5, 8, endian);
return packet;
}
function setupReply(endian = "l", status = 1) {
const packet = Buffer.alloc(20, 0xcd);
packet[0] = status;
writeUInt16(packet, 11, 2, endian);
writeUInt16(packet, 3, 6, endian);
return packet;
}
function request(name, { endian = "l", big = false, opcode = 98 } = {}) {
const nameBytes = Buffer.from(name, "latin1");
const prefix = big ? 12 : 8;
const packet = Buffer.alloc(prefix + Math.ceil(nameBytes.length / 4) * 4);
packet[0] = opcode;
writeUInt16(packet, big ? 0 : packet.length / 4, 2, endian);
if (big) writeUInt32(packet, packet.length / 4, 4, endian);
writeUInt16(packet, nameBytes.length, big ? 8 : 4, endian);
nameBytes.copy(packet, prefix);
return packet;
}
function reply(sequence, { endian = "l", type = 1, present = 1, extra = 0 } = {}) {
const packet = Buffer.alloc(32 + extra, 0xef);
packet[0] = type;
writeUInt16(packet, sequence, 2, endian);
writeUInt32(packet, extra / 4, 4, endian);
packet[8] = present;
return packet;
}
function withoutPresent(packet) {
const copy = Buffer.from(packet);
copy[8] = 0;
return copy;
}
function chunks(buffer, size) {
if (!size) return [buffer];
const result = [];
for (let offset = 0; offset < buffer.length; offset += size) {
result.push(buffer.subarray(offset, offset + size));
}
return result;
}
async function exchange(uplink, downlink, { chunkSize, ...options } = {}) {
const warnings = [];
const guard = createX11FdGuard({ logger: { warn: (message) => warnings.push(message) }, ...options });
const sent = [];
const received = [];
guard.toServer.on("data", (chunk) => sent.push(chunk));
guard.toClient.on("data", (chunk) => received.push(chunk));
const ended = Promise.all([once(guard.toServer, "end"), once(guard.toClient, "end")]);
for (const chunk of chunks(uplink, chunkSize)) guard.toServer.write(chunk);
for (const chunk of chunks(downlink, chunkSize)) guard.toClient.write(chunk);
guard.toServer.end();
guard.toClient.end();
await ended;
assert.deepEqual(Buffer.concat(sent), uplink, "uplink must stay byte-identical");
return { received: Buffer.concat(received), warnings };
}
for (const endian of ["l", "B"]) {
for (const chunkSize of [undefined, 1]) {
test(`default guard hides only DRI3 and MIT-SHM (${endian}, chunks ${chunkSize || "coalesced"})`, async () => {
assert.deepEqual(DEFAULT_HIDDEN_EXTENSIONS, ["DRI3", "MIT-SHM"]);
const names = ["DRI3", "GLX", "MIT-SHM", "RENDER", "dri3", "DRI3-extra", ""];
const replies = names.map((_, index) => reply(index + 1, { endian }));
const uplink = Buffer.concat([setupRequest(endian), ...names.map((name) => request(name, { endian }))]);
const downlink = Buffer.concat([setupReply(endian), ...replies]);
const expected = Buffer.concat([setupReply(endian), ...replies.map((packet, index) => (
index === 0 || index === 2 ? withoutPresent(packet) : packet
))]);
const result = await exchange(uplink, downlink, { chunkSize });
assert.deepEqual(result.received, expected);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /^\[X11\] hiding DRI3/);
assert.deepEqual(downlink, Buffer.concat([setupReply(endian), ...replies]), "input buffers must stay unchanged");
});
}
test(`BIG-REQUESTS use the extended header offsets (${endian})`, async () => {
const queries = [
request("DRI3", { endian, big: true }),
request("MIT-SHM", { endian, big: true }),
request("GLX", { endian, big: true }),
request("DRI3", { endian }),
];
const replies = queries.map((_, index) => reply(index + 1, { endian }));
const result = await exchange(
Buffer.concat([setupRequest(endian), ...queries]),
Buffer.concat([setupReply(endian), ...replies]),
{ chunkSize: 1 },
);
assert.deepEqual(result.received, Buffer.concat([
setupReply(endian), withoutPresent(replies[0]), withoutPresent(replies[1]), replies[2], withoutPresent(replies[3]),
]));
});
}
test("an empty hide list is byte-identical and a custom list replaces the defaults", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("MIT-SHM"), request("GLX")]);
const replies = [reply(1), reply(2), reply(3)];
const downlink = Buffer.concat([setupReply(), ...replies]);
const disabled = await exchange(uplink, downlink, { hiddenExtensions: [], chunkSize: 1 });
assert.deepEqual(disabled.received, downlink);
assert.deepEqual(disabled.warnings, []);
const custom = await exchange(uplink, downlink, { hiddenExtensions: ["GLX"], chunkSize: 1 });
assert.deepEqual(custom.received, Buffer.concat([setupReply(), replies[0], replies[1], withoutPresent(replies[2])]));
assert.equal(custom.warnings.length, 1);
assert.match(custom.warnings[0], /hiding GLX/);
});
test("errors, unrelated replies, ordinary events and variable GenericEvents preserve sequence isolation", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("GLX"), request("MIT-SHM")]);
const error = reply(1, { type: 0 });
const afterError = reply(1);
const unrelated = reply(2, { extra: 64 });
const event = reply(3, { type: 12 });
const generic = reply(3, { type: 35, extra: 48 });
const sentGeneric = reply(3, { type: 0x80 | 35, extra: 12 });
const hidden = reply(3);
const repeated = reply(3);
const prefix = [setupReply(), error, afterError, unrelated, event, generic, sentGeneric];
const result = await exchange(uplink, Buffer.concat([...prefix, hidden, repeated]), { chunkSize: 1 });
assert.deepEqual(result.received, Buffer.concat([...prefix, withoutPresent(hidden), repeated]));
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /hiding MIT-SHM/);
});
test("absent extensions and failed queries do not log", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("MIT-SHM")]);
const downlink = Buffer.concat([setupReply(), reply(1, { present: 0 }), reply(2, { type: 0 })]);
const result = await exchange(uplink, downlink, { chunkSize: 1 });
assert.deepEqual(result.received, downlink);
assert.deepEqual(result.warnings, []);
});
test("request bodies are skipped without mistaking embedded queries for requests", async () => {
const body = Buffer.alloc(128 * 1024);
request("DRI3").copy(body, 0);
const large = Buffer.alloc(8 + body.length);
large[0] = 18; // ChangeProperty, using BIG-REQUESTS framing
large.writeUInt32LE(large.length / 4, 4);
body.copy(large, 8);
const uplink = Buffer.concat([setupRequest(), large, request("MIT-SHM")]);
const visible = reply(1, { extra: 128 * 1024 });
const hidden = reply(2);
const result = await exchange(uplink, Buffer.concat([setupReply(), visible, hidden]), { chunkSize: 17 });
assert.deepEqual(result.received, Buffer.concat([setupReply(), visible, withoutPresent(hidden)]));
});
test("sequence wrap hides new queries and clears stale entries on reused sequences", async () => {
const noOp = Buffer.from([127, 0, 1, 0]);
const queries = [request("DRI3"), Buffer.concat(Array(65534).fill(noOp)), request("MIT-SHM"), noOp, request("DRI3")];
const stale = reply(1);
const wrapped = reply(0);
const hidden = reply(2);
const result = await exchange(
Buffer.concat([setupRequest(), ...queries]),
Buffer.concat([setupReply(), wrapped, stale, hidden]),
{ chunkSize: 13 },
);
assert.deepEqual(result.received, Buffer.concat([setupReply(), withoutPresent(wrapped), stale, withoutPresent(hidden)]));
});
test("setup failures and truncated headers flush unchanged", async () => {
for (const status of [0, 2]) {
const downlink = setupReply("B", status);
const result = await exchange(setupRequest("B"), downlink, { chunkSize: 1 });
assert.deepEqual(result.received, downlink);
}
const shortSetup = await exchange(setupRequest(), setupReply().subarray(0, 5), { chunkSize: 1 });
assert.deepEqual(shortSetup.received, setupReply().subarray(0, 5));
const partial = Buffer.concat([setupReply(), reply(1).subarray(0, 7)]);
const result = await exchange(Buffer.concat([setupRequest(), request("DRI3")]), partial, { chunkSize: 1 });
assert.deepEqual(result.received, partial);
});
test("non-X11 payloads pass through unchanged", async () => {
const result = await exchange(Buffer.from("remote"), Buffer.from("local"), { chunkSize: 1 });
assert.equal(result.received.toString(), "local");
});
+13 -3
View File
@@ -2,6 +2,7 @@ const net = require("node:net");
const fs = require("node:fs");
const { Transform } = require("node:stream");
const { execFileSync } = require("node:child_process");
const { createX11FdGuard } = require("./x11FdGuard.cjs");
const X11_PORT_BASE = 6000;
const MIT_MAGIC_COOKIE_PROTOCOL = "MIT-MAGIC-COOKIE-1";
@@ -253,11 +254,16 @@ function attachX11Forwarding(conn, options = {}) {
const target = resolveX11DisplaySpec(display, { platform });
const localSocket = createSocket();
let acceptedChannel = null;
let guard = null;
let settled = false;
const cleanup = () => {
if (acceptedChannel) destroyStream(acceptedChannel);
destroyStream(localSocket);
if (guard) {
destroyStream(guard.toServer);
destroyStream(guard.toClient);
}
};
localSocket.once("connect", () => {
@@ -275,15 +281,19 @@ function attachX11Forwarding(conn, options = {}) {
localSocket.on("error", () => cleanup());
acceptedChannel.on("close", () => destroyStream(localSocket));
localSocket.on("close", () => destroyStream(acceptedChannel));
guard = createX11FdGuard({ hiddenExtensions: options.hiddenExtensions, logger });
guard.toServer.on("error", cleanup);
guard.toClient.on("error", cleanup);
const realCookie = resolveLocalAuthCookie();
if (realCookie && fakeCookie) {
acceptedChannel
.pipe(createX11AuthTransform({ fakeCookie, realCookie }))
.pipe(localSocket)
.pipe(acceptedChannel);
.pipe(guard.toServer)
.pipe(localSocket);
} else {
acceptedChannel.pipe(localSocket).pipe(acceptedChannel);
acceptedChannel.pipe(guard.toServer).pipe(localSocket);
}
localSocket.pipe(guard.toClient).pipe(acceptedChannel);
});
localSocket.once("error", (err) => {
+74
View File
@@ -181,6 +181,7 @@ test("attachX11Forwarding pipes accepted X11 channels to the local display socke
attachX11Forwarding(conn, {
display: ":0",
createSocket: () => localSocket,
readLocalAuthCookie: () => null,
sendMessage: (message) => messages.push(message),
platform: "linux",
});
@@ -203,6 +204,79 @@ test("attachX11Forwarding pipes accepted X11 channels to the local display socke
assert.deepEqual(messages, []);
});
for (const rewriteCookie of [false, true]) {
test(`attachX11Forwarding hides fd extensions with cookie rewriting ${rewriteCookie ? "enabled" : "disabled"}`, async (t) => {
const conn = new EventEmitter();
const capturedLocal = [];
const capturedRemote = [];
const warnings = [];
const makeDuplex = (captured) => new Duplex({
read() {},
write(chunk, _encoding, callback) {
captured.push(Buffer.from(chunk));
callback();
},
});
const localSocket = makeDuplex(capturedLocal);
const acceptedChannel = makeDuplex(capturedRemote);
t.after(() => {
localSocket.destroy();
acceptedChannel.destroy();
});
localSocket.connect = () => {
queueMicrotask(() => localSocket.emit("connect"));
return localSocket;
};
const fakeCookie = "11111111111111111111111111111111";
const realCookie = "22222222222222222222222222222222";
attachX11Forwarding(conn, {
display: ":0",
platform: "linux",
createSocket: () => localSocket,
fakeCookie: rewriteCookie ? fakeCookie : undefined,
readLocalAuthCookie: () => rewriteCookie ? Buffer.from(realCookie, "hex") : null,
logger: { warn: (message) => warnings.push(message) },
});
conn.emit("x11", {}, () => acceptedChannel, () => assert.fail("unexpected reject"));
await new Promise((resolve) => setImmediate(resolve));
const setup = buildX11SetupPacket({ cookie: fakeCookie });
const queries = ["DRI3", "MIT-SHM", "GLX"].map((name) => {
const packet = Buffer.alloc(8 + Math.ceil(name.length / 4) * 4);
packet[0] = 98;
packet.writeUInt16LE(packet.length / 4, 2);
packet.writeUInt16LE(name.length, 4);
packet.write(name, 8, "ascii");
return packet;
});
// Fragment the auth packet, coalescing its tail with the first requests.
acceptedChannel.push(setup.subarray(0, 7));
acceptedChannel.push(Buffer.concat([setup.subarray(7), ...queries]));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(Buffer.concat(capturedLocal), Buffer.concat([
buildX11SetupPacket({ cookie: rewriteCookie ? realCookie : fakeCookie }), ...queries,
]));
const setupReply = Buffer.from([1, 0, 11, 0, 0, 0, 0, 0]);
const replies = queries.map((_, index) => {
const packet = Buffer.alloc(32);
packet[0] = 1;
packet.writeUInt16LE(index + 1, 2);
packet[8] = 1;
return packet;
});
const response = Buffer.concat([setupReply, ...replies]);
for (const byte of response) localSocket.push(Buffer.from([byte]));
await new Promise((resolve) => setImmediate(resolve));
const expected = Buffer.from(response);
expected[8 + 8] = 0;
expected[8 + 32 + 8] = 0;
assert.deepEqual(Buffer.concat(capturedRemote), expected);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /^\[X11\] hiding DRI3/);
});
}
test("attachX11Forwarding rejects the remote channel and explains local display failures", async () => {
const conn = new EventEmitter();
const localSocket = new EventEmitter();
+2 -1
View File
@@ -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',