mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-14 19:17:26 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46df6f3b93 | ||
|
|
32130a560e | ||
|
|
b28a2bf1d8 | ||
|
|
cd6334e150 | ||
|
|
aa4583050d | ||
|
|
37188871d8 |
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user