Compare commits

..
Author SHA1 Message Date
陈大猫 46df6f3b93 fix(sftp): expose lstat on session-backed copy clients 2026-11-13 16:29:40 +08:00
陈大猫 32130a560e test(sftp): mock missing dest in same-host cp cache fixture 2026-11-13 15:20:52 +08:00
陈大猫 b28a2bf1d8 fix(sftp): skip same-host cp for symlinks and directory merges 2026-11-13 14:55:19 +08:00
陈大猫 cd6334e150 fix(sftp): preserve dest mode when same-host cp replaces a file 2026-11-13 14:20:17 +08:00
陈大猫 aa4583050d fix(sftp): skip same-host directory cp when auto encoding resolves to non-UTF-8 2026-11-13 13:48:33 +08:00
陈大猫 37188871d8 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.
2026-11-13 13:28:47 +08:00
18 changed files with 25 additions and 782 deletions

No files matched your search

@@ -66,15 +66,9 @@ 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);
// 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}`);
assert.equal(document.activeElement, configure);
} 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,13 +9,6 @@ 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,7 +180,6 @@ 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')}
>
@@ -232,14 +231,12 @@ 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')}
>
@@ -89,15 +89,6 @@ 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));
@@ -110,8 +101,6 @@ 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);
@@ -121,33 +110,26 @@ test("recently hidden panes are released only once they stay hidden long enough"
assert.equal(clock.pendingTimers, 0);
});
test("a pane hidden after a grace overshoot is released once eligible", () => {
test("a pane hidden after the budget went over 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);
assert.equal(budget.tryAcquire(c), false);
assert.equal(budget.getActiveCount(), 2);
budget.tryAcquire(c);
assert.equal(budget.getActiveCount(), 3, "three visible panes all keep WebGL");
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 { clock, budget } = setup(2);
const a = new FakeMember("a");
budget.tryAcquire(a);
const { budget } = setup(2);
budget.tryAcquire(new FakeMember("a"));
budget.tryAcquire(new FakeMember("b"));
const hidden = new FakeMember("hidden");
@@ -156,13 +138,7 @@ 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), 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);
assert.equal(budget.tryAcquire(hidden), true, "it gets one as soon as it is shown");
});
test("released and forgotten panes free their slot", () => {
@@ -10,9 +10,8 @@
*
* 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, 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.
* 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.
*/
export const MAX_ACTIVE_WEBGL_CONTEXTS = 12;
@@ -32,8 +31,8 @@ export interface WebglContextBudgetMember {
export interface WebglContextBudget {
/**
* Reserve a context if the budget has room. Visible members may replace
* hidden holders, allowing a temporary overshoot until their grace expires.
* Reserve a context for a member about to create its renderer. Visible
* members always get one; hidden members only while the budget has room.
*/
tryAcquire(member: WebglContextBudgetMember): boolean;
/** Give the slot back once the member's renderer is gone. Idempotent. */
@@ -103,11 +102,7 @@ export function createWebglContextBudget(
return {
tryAcquire(member) {
if (active.has(member)) return true;
if (active.size >= maxActive) {
if (member.getHiddenSince() !== null) return false;
const visibleCount = [...active].filter((holder) => holder.getHiddenSince() === null).length;
if (visibleCount >= maxActive) return false;
}
if (member.getHiddenSince() !== null && active.size >= maxActive) return false;
active.add(member);
releaseOverflow();
return true;
@@ -186,93 +186,20 @@ 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 { clock, budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const { budget, createPane } = setup(1);
createPane().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("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", () => {
test("a pane on screen is never refused or released, even if a sync was missed", () => {
const { clock, budget, createPane } = setup(1);
const visible = createPane();
visible.controller.ensure();
@@ -283,37 +210,12 @@ test("a holder on screen is never released, even if a sync was missed", () => {
createPane().controller.ensure();
clock.advance(10_000);
assert.equal(visible.loaded, true);
assert.equal(budget.getActiveCount(), 1);
assert.equal(budget.getActiveCount(), 2);
const late = createPane(false);
late.setVisibleWithoutSync(true);
late.controller.ensure();
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);
assert.equal(late.loaded, true);
});
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 released even if a sync was missed.
// on screen from being refused or 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;
// Panes past the budget keep the DOM renderer until a slot is available.
// A hidden pane past the budget keeps the DOM renderer until it is shown.
if (budget && !budget.tryAcquire(budgetMember)) return;
let nextAddon: TAddon | null = null;
try {
-42
View File
@@ -125,48 +125,6 @@ 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,13 +671,6 @@ 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",
@@ -698,20 +691,6 @@ 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",
@@ -281,26 +281,6 @@ 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();
+3 -4
View File
@@ -50,12 +50,11 @@ function formatSyntheticEcho(command) {
}
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
// `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 /home/alice>`). 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 = /^(?:\[[^\]\r\n]+\]: )*PS(?:\s+\S.*)?>$/;
const POWERSHELL_PROMPT_PATTERN = /^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,46 +112,6 @@ 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.
-3
View File
@@ -959,9 +959,6 @@ 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,15 +37,6 @@ 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" } },
-190
View File
@@ -1,190 +0,0 @@
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
@@ -1,204 +0,0 @@
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");
});
+3 -13
View File
@@ -2,7 +2,6 @@ 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";
@@ -254,16 +253,11 @@ 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", () => {
@@ -281,19 +275,15 @@ 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(guard.toServer)
.pipe(localSocket);
.pipe(localSocket)
.pipe(acceptedChannel);
} else {
acceptedChannel.pipe(guard.toServer).pipe(localSocket);
acceptedChannel.pipe(localSocket).pipe(acceptedChannel);
}
localSocket.pipe(guard.toClient).pipe(acceptedChannel);
});
localSocket.once("error", (err) => {
-74
View File
@@ -181,7 +181,6 @@ 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",
});
@@ -204,79 +203,6 @@ 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();