mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
test: gate the remaining chmod-based root skips on the permission-bit probe (#14403)
Fifty cases across core, cli, browser-use, sdk-typescript, test-failure-explorer and scripts/tests force a failure with chmod and skip whenever uid is 0. ci.yml runs unit tests as root on the ECS pool, but drops CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH first, so permission bits bind there and these cases could run; the uid check skips them all on that lane anyway. Gate them on whether a mode-000 directory refuses this process, as #14162, #14271 and #14283 do for the rest: - core (22 cases in 14 files) and cli (1) use their own helper. - browser-use, sdk-typescript and test-failure-explorer import nothing from another package, so each gets a copy of the helper; browser-use, with six cases, also gets its tests, as core did in #14283. - scripts/tests (19 cases in 7 files) imports the cli helper, as other scripts tests do. Three core cases returned early as root instead of skipping, so they reported a pass without asserting anything; they now skip there. The workflow-extension case left its unreadable directory empty, so it passed whether or not the directory was refused; it now holds a workflow that would show up if it were read. package-scripts pins every copy of the helper, and the cli, core and browser-use copies of its tests, byte-identical, so a fix to one cannot leave the others behind. Left on the uid check: pathReader's case, whose permissions come from mock-fs, which lets uid 0 through whatever the mode; the session writer lease's chown case, which needs root; and the release workflow's wipe test, which does not depend on permission bits.
This commit is contained in:
1 parent
d496c15a22
commit
6e6a69c71f
34 files changed
+1006
-146
No files matched your search
@@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MAX_SNAPSHOT_BYTES, MAX_SOURCE_BYTES } from './contracts.js';
|
||||
import { ReportStore } from './report-store.js';
|
||||
import { parseVitestReport } from './vitest-report.js';
|
||||
import { arePermissionBitsEnforced } from './test-utils/permission-bits.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
async function workspace(): Promise<string> {
|
||||
@@ -319,7 +320,7 @@ describe('workspace report snapshots', () => {
|
||||
expect(await readFile(path, 'utf8')).toBe(altered);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'reports an actually unwritable cache without claiming an import',
|
||||
async () => {
|
||||
const root = await workspace();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import * as path from 'node:path';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
/** The node:fs calls the probe makes. */
|
||||
export interface ProbeFs {
|
||||
mkdtempSync(prefix: string): string;
|
||||
chmodSync(path: string, mode: number): void;
|
||||
readdirSync(path: string): unknown;
|
||||
writeFileSync(path: string, data: string): void;
|
||||
rmSync(path: string, options: { recursive: boolean; force: boolean }): void;
|
||||
}
|
||||
|
||||
// The real node:fs and node:os, even when the test file that asks has
|
||||
// vi.mock()ed them: vitest mocks its own module graph, not a builtin loaded
|
||||
// through Node's require. A mocked fs would answer for the mock, not the
|
||||
// kernel. A vi.spyOn on node:fs's default export at collection time would
|
||||
// still reach the probe, since that export is this same module object.
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const realFs = nodeRequire('node:fs') as typeof import('node:fs');
|
||||
const realOs = nodeRequire('node:os') as typeof import('node:os');
|
||||
|
||||
/**
|
||||
* Whether file permission bits bind this process, for tests that force a
|
||||
* failure with `chmod`. They do not on Windows, which ignores Unix-style
|
||||
* bits, nor for root, which bypasses them (on Linux through
|
||||
* CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH). ci.yml drops both
|
||||
* capabilities when it runs unit tests as root (package-scripts.test.js
|
||||
* pins that), so asking the kernel, rather than checking for uid 0, keeps
|
||||
* those tests running there. A probe that cannot measure also answers
|
||||
* false, and says so on stderr.
|
||||
* `fs` is for this helper's own tests; every caller probes the real one.
|
||||
*/
|
||||
export function arePermissionBitsEnforced(fs: ProbeFs = realFs): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
let dir: string | undefined;
|
||||
try {
|
||||
dir = fs.mkdtempSync(path.join(realOs.tmpdir(), 'qwen-permission-probe-'));
|
||||
fs.chmodSync(dir, 0o000);
|
||||
const probe = dir;
|
||||
return (
|
||||
refused(() => fs.readdirSync(probe)) &&
|
||||
refused(() => fs.writeFileSync(path.join(probe, 'probe'), ''))
|
||||
);
|
||||
} catch (error) {
|
||||
// A temp directory that cannot hold the probe, or refuses chmod,
|
||||
// cannot hold these tests' fixtures either, though after a transient
|
||||
// ENOSPC it may hold them again by the time they run, so say which it
|
||||
// was rather than skip silently.
|
||||
inconclusive(error);
|
||||
return false;
|
||||
} finally {
|
||||
if (dir !== undefined) {
|
||||
try {
|
||||
fs.chmodSync(dir, 0o700);
|
||||
} catch {
|
||||
// Best effort, like the removal below: newer Node versions refuse
|
||||
// to remove even an empty mode-000 directory.
|
||||
}
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best effort: a leftover probe must not fail the test file.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refused(attempt: () => unknown): boolean {
|
||||
try {
|
||||
attempt();
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (errnoOf(error) === 'EACCES') return true;
|
||||
inconclusive(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function errnoOf(error: unknown): string | undefined {
|
||||
const code = attempt(
|
||||
() => (error as NodeJS.ErrnoException | null | undefined)?.code,
|
||||
);
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names a probe that could not measure, and the test file that asked, so
|
||||
* the skips it causes are not mistaken for a Windows or root run. vitest's
|
||||
* `silent: true` drops console output, so this writes to stderr directly.
|
||||
* Under GitHub Actions the line opens with a workflow command, so the skips
|
||||
* show in the check UI and not only deep in the log.
|
||||
*/
|
||||
function inconclusive(error: unknown): void {
|
||||
try {
|
||||
process.stderr.write(
|
||||
`${process.env['GITHUB_ACTIONS'] === 'true' ? '::warning::' : ''}` +
|
||||
`[permission-bits] probe inconclusive (${causeOf(error)})${askedBy()}; ` +
|
||||
'treating permission bits as unenforced, so chmod-based tests skip\n',
|
||||
);
|
||||
} catch {
|
||||
// A diagnostic must never fail the test file it is meant to explain.
|
||||
}
|
||||
}
|
||||
|
||||
function causeOf(error: unknown): string {
|
||||
const code = errnoOf(error);
|
||||
if (code === undefined) {
|
||||
return (
|
||||
attempt(() => String(error).split('\n', 1)[0]) ?? 'an unprintable value'
|
||||
);
|
||||
}
|
||||
const syscall = attempt(() => (error as NodeJS.ErrnoException).syscall);
|
||||
return typeof syscall === 'string' ? `${code} in ${syscall}` : code;
|
||||
}
|
||||
|
||||
function askedBy(): string {
|
||||
const testPath = attempt(() => expect.getState().testPath);
|
||||
if (!testPath) return '';
|
||||
return ` for ${attempt(() => path.relative(process.cwd(), testPath)) ?? testPath}`;
|
||||
}
|
||||
|
||||
function attempt<T>(read: () => T): T | undefined {
|
||||
try {
|
||||
return read();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
CHROME_NATIVE_HOST_NAME,
|
||||
CHROME_NATIVE_HOST_REVISION,
|
||||
} from './bridge/protocol.js';
|
||||
import { arePermissionBitsEnforced } from './test-utils/permission-bits.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
@@ -178,9 +179,7 @@ describe('Chrome Native Host installer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it
|
||||
.skipIf(process.platform === 'win32' || process.getuid?.() === 0)
|
||||
.each(['launcher', 'manifest'])(
|
||||
it.skipIf(!arePermissionBitsEnforced()).each(['launcher', 'manifest'])(
|
||||
'preserves an unreadable %s during install, status and uninstall',
|
||||
async (kind) => {
|
||||
const fixture = createFixture();
|
||||
@@ -265,7 +264,7 @@ describe('Chrome Native Host installer', () => {
|
||||
});
|
||||
|
||||
it
|
||||
.skipIf(process.platform === 'win32' || process.getuid?.() === 0)
|
||||
.skipIf(!arePermissionBitsEnforced())
|
||||
.each(['launcher', 'manifest'] as const)(
|
||||
'keeps a correct %s usable in a read-only directory',
|
||||
async (kind) => {
|
||||
@@ -313,7 +312,7 @@ describe('Chrome Native Host installer', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'propagates a required launcher update failure and updates when writable',
|
||||
async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
@@ -8,6 +8,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { arePermissionBitsEnforced } from './test-utils/permission-bits.js';
|
||||
|
||||
const installer = vi.hoisted(() => ({
|
||||
ensure: vi.fn(async () => ({
|
||||
@@ -93,7 +94,7 @@ describe('createBrowserBackend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'initializes when Chrome preference files are unreadable',
|
||||
async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import mockedFsDefault, * as mockedFs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
|
||||
import { arePermissionBitsEnforced } from './permission-bits.js';
|
||||
|
||||
// A test file that replaces node:fs, as some callers of the probe do, must
|
||||
// not change its answer: the probe asks the kernel, not the mock. The test
|
||||
// makes the mock answer the opposite of the kernel, so a probe that read the
|
||||
// mock fails here whoever runs it.
|
||||
const mocked = vi.hoisted(() => ({
|
||||
mkdtempSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
// Both the named exports and the default one: a probe reading either would
|
||||
// answer for the mock.
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, ...mocked, default: { ...actual, ...mocked } };
|
||||
});
|
||||
|
||||
const realFs = createRequire(import.meta.url)(
|
||||
'node:fs',
|
||||
) as typeof import('node:fs');
|
||||
|
||||
function refusedWithEacces(attempt: () => unknown): boolean {
|
||||
try {
|
||||
attempt();
|
||||
return false;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'EACCES';
|
||||
}
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers for the kernel while the test file mocks node:fs',
|
||||
() => {
|
||||
// The mock is in place, both ways, or this test would pass without
|
||||
// pinning anything. Asked of the graph's node:fs, never of realFs,
|
||||
// which the mock never reaches.
|
||||
expect(mockedFs.mkdtempSync).toBe(mocked.mkdtempSync);
|
||||
expect(mockedFsDefault.mkdtempSync).toBe(mocked.mkdtempSync);
|
||||
const dir = realFs.mkdtempSync(path.join(os.tmpdir(), 'locked-'));
|
||||
realFs.chmodSync(dir, 0o000);
|
||||
let enforced: boolean;
|
||||
try {
|
||||
enforced =
|
||||
refusedWithEacces(() => realFs.readdirSync(dir)) &&
|
||||
refusedWithEacces(() =>
|
||||
realFs.writeFileSync(path.join(dir, 'probe'), ''),
|
||||
);
|
||||
} finally {
|
||||
realFs.chmodSync(dir, 0o700);
|
||||
realFs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
mocked.mkdtempSync.mockImplementation((prefix: string) =>
|
||||
realFs.mkdtempSync(prefix),
|
||||
);
|
||||
if (enforced) {
|
||||
// The mock lists and writes freely: it would answer false.
|
||||
mocked.readdirSync.mockReturnValue([]);
|
||||
} else {
|
||||
// The mock refuses both: it would answer true.
|
||||
const eacces = () => {
|
||||
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||
};
|
||||
mocked.readdirSync.mockImplementation(eacces);
|
||||
mocked.writeFileSync.mockImplementation(eacces);
|
||||
}
|
||||
expect(arePermissionBitsEnforced()).toBe(enforced);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { arePermissionBitsEnforced } from './permission-bits.js';
|
||||
|
||||
/** The real filesystem, with each call the probe makes open to a fault. */
|
||||
function probeFs() {
|
||||
return {
|
||||
mkdtempSync: vi.fn((prefix: string) => fs.mkdtempSync(prefix)),
|
||||
chmodSync: vi.fn((file: string, mode: number) => fs.chmodSync(file, mode)),
|
||||
readdirSync: vi.fn((file: string): unknown => fs.readdirSync(file)),
|
||||
writeFileSync: vi.fn((file: string, data: string) =>
|
||||
fs.writeFileSync(file, data),
|
||||
),
|
||||
rmSync: vi.fn(
|
||||
(file: string, options: { recursive: boolean; force: boolean }) =>
|
||||
fs.rmSync(file, options),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function refusal(code: string, syscall?: string): () => never {
|
||||
return () => {
|
||||
throw Object.assign(new Error(code), { code, syscall });
|
||||
};
|
||||
}
|
||||
|
||||
describe('arePermissionBitsEnforced', () => {
|
||||
const initialTmpdir = process.env['TMPDIR'];
|
||||
let tmp: string;
|
||||
let stderr: MockInstance<typeof process.stderr.write>;
|
||||
let probe: ReturnType<typeof probeFs>;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'permission-bits-test-'));
|
||||
process.env['TMPDIR'] = tmp;
|
||||
// The diagnostic's shape depends on it, and CI sets it.
|
||||
vi.stubEnv('GITHUB_ACTIONS', '');
|
||||
stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
probe = probeFs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderr.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
if (initialTmpdir === undefined) delete process.env['TMPDIR'];
|
||||
else process.env['TMPDIR'] = initialTmpdir;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** What the probe wrote to stderr about an inconclusive measurement. */
|
||||
function diagnostics(): string[] {
|
||||
return stderr.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.filter((line) => /^(::warning::)?\[permission-bits\]/.test(line));
|
||||
}
|
||||
|
||||
/** The probe was made in os.tmpdir() and is gone again. */
|
||||
function expectProbeCleanedUp(): void {
|
||||
const dir = String(probe.chmodSync.mock.calls[0]?.[0]);
|
||||
expect(dir.startsWith(path.join(tmp, 'qwen-permission-probe-'))).toBe(true);
|
||||
expect(fs.readdirSync(tmp)).toEqual([]);
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers true only when listing and writing are both refused',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(refusal('EACCES'));
|
||||
probe.writeFileSync.mockImplementationOnce(refusal('EACCES'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(true);
|
||||
expectProbeCleanedUp();
|
||||
expect(diagnostics()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers false when the directory can still be listed',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(() => []);
|
||||
probe.writeFileSync.mockImplementationOnce(refusal('EACCES'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expectProbeCleanedUp();
|
||||
// Bits that do not bind are an answer, not a failed measurement.
|
||||
expect(diagnostics()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers false when listing is refused but writing is not',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(refusal('EACCES'));
|
||||
probe.writeFileSync.mockImplementationOnce(() => undefined);
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expectProbeCleanedUp();
|
||||
expect(diagnostics()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers false for a refusal other than EACCES',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(refusal('EPERM'));
|
||||
probe.writeFileSync.mockImplementationOnce(refusal('EACCES'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expectProbeCleanedUp();
|
||||
expect(diagnostics()).toEqual([
|
||||
expect.stringContaining('probe inconclusive (EPERM)'),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'names a measurement lost to fd exhaustion instead of skipping silently',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(refusal('EMFILE', 'scandir'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expectProbeCleanedUp();
|
||||
expect(diagnostics()).toEqual([
|
||||
expect.stringMatching(
|
||||
/probe inconclusive \(EMFILE in scandir\) for .+permission-bits\.test\.ts;/,
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'names a temp directory that cannot hold the probe, without throwing',
|
||||
() => {
|
||||
probe.mkdtempSync.mockImplementationOnce(refusal('ENOSPC'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expect(probe.chmodSync).not.toHaveBeenCalled();
|
||||
expect(fs.readdirSync(tmp)).toEqual([]);
|
||||
// One whole line, with no workflow command outside GitHub Actions.
|
||||
expect(diagnostics()).toEqual([
|
||||
expect.stringMatching(
|
||||
/^\[permission-bits\] probe inconclusive \(ENOSPC\)[^\n]*\n$/,
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'opens the line with a warning command under GitHub Actions',
|
||||
() => {
|
||||
vi.stubEnv('GITHUB_ACTIONS', 'true');
|
||||
probe.mkdtempSync.mockImplementationOnce(refusal('ENOSPC'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
// A workflow command counts only at the start of a line.
|
||||
expect(diagnostics()).toEqual([
|
||||
expect.stringMatching(
|
||||
/^::warning::\[permission-bits\] probe inconclusive \(ENOSPC\)[^\n]*\n$/,
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers false, without throwing, when the directory refuses chmod',
|
||||
() => {
|
||||
probe.chmodSync.mockImplementationOnce(refusal('EPERM'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expectProbeCleanedUp();
|
||||
expect(diagnostics()).toEqual([
|
||||
expect.stringContaining('probe inconclusive (EPERM)'),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'agrees with whether a mode-000 directory refuses this process',
|
||||
() => {
|
||||
const dir = fs.mkdtempSync(path.join(tmp, 'locked-'));
|
||||
fs.chmodSync(dir, 0o000);
|
||||
let refused = false;
|
||||
try {
|
||||
fs.readdirSync(dir);
|
||||
} catch (error) {
|
||||
refused = (error as NodeJS.ErrnoException).code === 'EACCES';
|
||||
} finally {
|
||||
fs.chmodSync(dir, 0o700);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
expect(arePermissionBitsEnforced()).toBe(refused);
|
||||
expect(diagnostics()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers false, without throwing, for a value its diagnostic cannot print',
|
||||
() => {
|
||||
// Thrown where only the diagnostic's own guard stands between it and
|
||||
// a failed test file.
|
||||
probe.chmodSync.mockImplementationOnce(() => {
|
||||
throw Object.assign(Object.create(null), { code: Symbol('odd') });
|
||||
});
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expectProbeCleanedUp();
|
||||
expect(diagnostics()).toEqual([
|
||||
expect.stringContaining('probe inconclusive (an unprintable value)'),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'prints an error whose code is not an errno by its message',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('odd'), { code: Symbol('odd') });
|
||||
});
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expect(diagnostics()).toEqual([
|
||||
expect.stringContaining('probe inconclusive (Error: odd)'),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'still names the errno when its syscall cannot be read',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(() => {
|
||||
const error = Object.assign(new Error('EPERM'), { code: 'EPERM' });
|
||||
Object.defineProperty(error, 'syscall', {
|
||||
get() {
|
||||
throw new Error('no syscall');
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expect(diagnostics()).toEqual([
|
||||
expect.stringContaining('probe inconclusive (EPERM)'),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers false, without throwing, when stderr itself refuses the line',
|
||||
() => {
|
||||
stderr.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('EPIPE'), { code: 'EPIPE' });
|
||||
});
|
||||
// The outer catch reports here, so nothing else stands between the
|
||||
// throwing write and the test file.
|
||||
probe.mkdtempSync.mockImplementationOnce(refusal('ENOSPC'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
expect(fs.readdirSync(tmp)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers, without throwing, when the probe cannot be removed',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(refusal('EACCES'));
|
||||
probe.writeFileSync.mockImplementationOnce(refusal('EACCES'));
|
||||
probe.rmSync.mockImplementationOnce(refusal('EBUSY'));
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(true);
|
||||
expect(diagnostics()).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers, without throwing, when the probe mode cannot be restored',
|
||||
() => {
|
||||
probe.readdirSync.mockImplementationOnce(refusal('EACCES'));
|
||||
probe.writeFileSync.mockImplementationOnce(refusal('EACCES'));
|
||||
probe.chmodSync
|
||||
.mockImplementationOnce((file: string, mode: number) =>
|
||||
fs.chmodSync(file, mode),
|
||||
)
|
||||
.mockImplementationOnce(refusal('EPERM'));
|
||||
try {
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(true);
|
||||
expect(diagnostics()).toEqual([]);
|
||||
} finally {
|
||||
// Newer Node versions cannot remove a mode-000 directory, so open
|
||||
// whatever the probe left behind before afterEach clears tmp.
|
||||
const dir = probe.chmodSync.mock.calls[0]?.[0];
|
||||
if (dir !== undefined && fs.existsSync(dir)) fs.chmodSync(dir, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('answers false on Windows without probing', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
try {
|
||||
expect(arePermissionBitsEnforced(probe)).toBe(false);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
}
|
||||
expect(probe.chmodSync).not.toHaveBeenCalled();
|
||||
expect(diagnostics()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import * as path from 'node:path';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
/** The node:fs calls the probe makes. */
|
||||
export interface ProbeFs {
|
||||
mkdtempSync(prefix: string): string;
|
||||
chmodSync(path: string, mode: number): void;
|
||||
readdirSync(path: string): unknown;
|
||||
writeFileSync(path: string, data: string): void;
|
||||
rmSync(path: string, options: { recursive: boolean; force: boolean }): void;
|
||||
}
|
||||
|
||||
// The real node:fs and node:os, even when the test file that asks has
|
||||
// vi.mock()ed them: vitest mocks its own module graph, not a builtin loaded
|
||||
// through Node's require. A mocked fs would answer for the mock, not the
|
||||
// kernel. A vi.spyOn on node:fs's default export at collection time would
|
||||
// still reach the probe, since that export is this same module object.
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const realFs = nodeRequire('node:fs') as typeof import('node:fs');
|
||||
const realOs = nodeRequire('node:os') as typeof import('node:os');
|
||||
|
||||
/**
|
||||
* Whether file permission bits bind this process, for tests that force a
|
||||
* failure with `chmod`. They do not on Windows, which ignores Unix-style
|
||||
* bits, nor for root, which bypasses them (on Linux through
|
||||
* CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH). ci.yml drops both
|
||||
* capabilities when it runs unit tests as root (package-scripts.test.js
|
||||
* pins that), so asking the kernel, rather than checking for uid 0, keeps
|
||||
* those tests running there. A probe that cannot measure also answers
|
||||
* false, and says so on stderr.
|
||||
* `fs` is for this helper's own tests; every caller probes the real one.
|
||||
*/
|
||||
export function arePermissionBitsEnforced(fs: ProbeFs = realFs): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
let dir: string | undefined;
|
||||
try {
|
||||
dir = fs.mkdtempSync(path.join(realOs.tmpdir(), 'qwen-permission-probe-'));
|
||||
fs.chmodSync(dir, 0o000);
|
||||
const probe = dir;
|
||||
return (
|
||||
refused(() => fs.readdirSync(probe)) &&
|
||||
refused(() => fs.writeFileSync(path.join(probe, 'probe'), ''))
|
||||
);
|
||||
} catch (error) {
|
||||
// A temp directory that cannot hold the probe, or refuses chmod,
|
||||
// cannot hold these tests' fixtures either, though after a transient
|
||||
// ENOSPC it may hold them again by the time they run, so say which it
|
||||
// was rather than skip silently.
|
||||
inconclusive(error);
|
||||
return false;
|
||||
} finally {
|
||||
if (dir !== undefined) {
|
||||
try {
|
||||
fs.chmodSync(dir, 0o700);
|
||||
} catch {
|
||||
// Best effort, like the removal below: newer Node versions refuse
|
||||
// to remove even an empty mode-000 directory.
|
||||
}
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best effort: a leftover probe must not fail the test file.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refused(attempt: () => unknown): boolean {
|
||||
try {
|
||||
attempt();
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (errnoOf(error) === 'EACCES') return true;
|
||||
inconclusive(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function errnoOf(error: unknown): string | undefined {
|
||||
const code = attempt(
|
||||
() => (error as NodeJS.ErrnoException | null | undefined)?.code,
|
||||
);
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names a probe that could not measure, and the test file that asked, so
|
||||
* the skips it causes are not mistaken for a Windows or root run. vitest's
|
||||
* `silent: true` drops console output, so this writes to stderr directly.
|
||||
* Under GitHub Actions the line opens with a workflow command, so the skips
|
||||
* show in the check UI and not only deep in the log.
|
||||
*/
|
||||
function inconclusive(error: unknown): void {
|
||||
try {
|
||||
process.stderr.write(
|
||||
`${process.env['GITHUB_ACTIONS'] === 'true' ? '::warning::' : ''}` +
|
||||
`[permission-bits] probe inconclusive (${causeOf(error)})${askedBy()}; ` +
|
||||
'treating permission bits as unenforced, so chmod-based tests skip\n',
|
||||
);
|
||||
} catch {
|
||||
// A diagnostic must never fail the test file it is meant to explain.
|
||||
}
|
||||
}
|
||||
|
||||
function causeOf(error: unknown): string {
|
||||
const code = errnoOf(error);
|
||||
if (code === undefined) {
|
||||
return (
|
||||
attempt(() => String(error).split('\n', 1)[0]) ?? 'an unprintable value'
|
||||
);
|
||||
}
|
||||
const syscall = attempt(() => (error as NodeJS.ErrnoException).syscall);
|
||||
return typeof syscall === 'string' ? `${code} in ${syscall}` : code;
|
||||
}
|
||||
|
||||
function askedBy(): string {
|
||||
const testPath = attempt(() => expect.getState().testPath);
|
||||
if (!testPath) return '';
|
||||
return ` for ${attempt(() => path.relative(process.cwd(), testPath)) ?? testPath}`;
|
||||
}
|
||||
|
||||
function attempt<T>(read: () => T): T | undefined {
|
||||
try {
|
||||
return read();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,5 @@
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "node_modules", "dist"]
|
||||
"exclude": ["src/**/*.test.ts", "src/test-utils/**", "node_modules", "dist"]
|
||||
}
|
||||
@@ -3434,7 +3434,7 @@ describe('WeComChannel', () => {
|
||||
|
||||
// The read-only parent only blocks removal where permission bits bind:
|
||||
// Windows ignores them and root with its DAC capabilities bypasses them.
|
||||
// Ask the kernel, as the cli and core permission-bits helpers do, so a
|
||||
// Ask the kernel, as the permission-bits helpers in other packages do, so a
|
||||
// root lane that drops those capabilities still runs this case. A probe
|
||||
// that cannot measure answers false too, and says so on stderr, as a
|
||||
// warning under GitHub Actions.
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './local-process-runtime-activator.js';
|
||||
import { ProcessRegistry } from '@qwen-code/acp-bridge';
|
||||
import type { WorkspaceRuntime } from './workspace-registry.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
const fixture = `
|
||||
process.on('message', b => {
|
||||
@@ -492,7 +493,7 @@ process.on('SIGTERM', () => process.exit(0));
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'aggregates a settled rm-class cleanup failure into close()',
|
||||
async () => {
|
||||
const { activator, stateDir } = await setup(1);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MAX_EXTENSION_WORKFLOW_SCRIPT_BYTES,
|
||||
} from './workflow-extension.js';
|
||||
import { computeWorkflowScriptDigest } from './workflow-saved.js';
|
||||
import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js';
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
@@ -249,11 +250,12 @@ describe('loadExtensionWorkflows', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(isWindows || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'keeps later declared paths when an earlier one cannot be read',
|
||||
async () => {
|
||||
await write('good/x.js', workflowSource('x'));
|
||||
await fs.mkdir(path.join(root, 'bad'));
|
||||
// Read, `bad` would add gcp:y, so the result shows it was refused.
|
||||
await write('bad/y.js', workflowSource('y'));
|
||||
await fs.chmod(path.join(root, 'bad'), 0o000);
|
||||
try {
|
||||
expect(await names(['bad', 'good'])).toEqual(['gcp:x']);
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
localManagedSessionKey,
|
||||
managedSessionResourceRoot,
|
||||
} from '../utils/sessionStorageUtils.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
const SESSION_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
|
||||
|
||||
@@ -639,8 +640,8 @@ describe('Managed Session log recording', () => {
|
||||
});
|
||||
|
||||
// An unreadable file is made with chmod, which neither Windows nor root
|
||||
// honours.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
// with its DAC capabilities honours.
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'keeps a reclaimed lock when it cannot read the log',
|
||||
async () => {
|
||||
const first = await start(managedConfig());
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import type { PeerControllerIdentity } from './peer-controllers.js';
|
||||
import { expectWithinLatencyBudget } from '../test-utils/latency-budget.js';
|
||||
import { leaveStaleSocket } from '../test-utils/stale-socket.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
/**
|
||||
* A PID no process can ever hold.
|
||||
@@ -331,7 +332,7 @@ describe.skipIf(isWindows)('startPeerInbox', () => {
|
||||
expectFailure('path_too_long', 'shorter directory');
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'names the cause when a parent directory is not writable',
|
||||
async () => {
|
||||
const locked = path.join(tmpDir, 'locked');
|
||||
@@ -1132,7 +1133,7 @@ describe.skipIf(isWindows)('orphan socket sweeps', () => {
|
||||
// it. The existing "keeps a listening socket even when its filename PID
|
||||
// is absent" pins only the case where the probe reaches a definitive
|
||||
// answer, and stays green whether or not `unknown` is honoured.
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'keeps a socket whose probe could not reach a verdict',
|
||||
async () => {
|
||||
const dir = await socksDir();
|
||||
@@ -1147,7 +1148,7 @@ describe.skipIf(isWindows)('orphan socket sweeps', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'keeps a fallback directory whose probe could not reach a verdict',
|
||||
async () => {
|
||||
const parent = await makeShortTmpDir('qwen-inbox-unknown-');
|
||||
@@ -1401,7 +1402,7 @@ describe.skipIf(isWindows)('PID-keyed path collisions', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'binds a sibling when the requested path cannot be verified free',
|
||||
async () => {
|
||||
const taken = sockAt('4242.sock');
|
||||
@@ -1556,7 +1557,7 @@ describe.skipIf(isWindows)('PID-keyed path collisions', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'reports sibling overflow when the requested path cannot be verified free',
|
||||
async (ctx) => {
|
||||
const { root, taken } = await paddedTaken();
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
// 模拟依赖项
|
||||
class MockConfig {
|
||||
@@ -1125,7 +1126,7 @@ describe('NativeLspService', () => {
|
||||
// typed cast so the private method need not be public just for tests.
|
||||
describe('applyTextEdits — error branches', () => {
|
||||
let tmpDir: string;
|
||||
const noChmod = process.platform === 'win32' || process.getuid?.() === 0;
|
||||
const noChmod = !arePermissionBitsEnforced();
|
||||
|
||||
const applyEdit = (filePath: string, newText: string) =>
|
||||
(
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from './indexer.js';
|
||||
import { ensureAutoMemoryScaffold } from './store.js';
|
||||
import * as trustedMemoryFilesystem from './trusted-memory-filesystem.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
vi.mock('./trusted-memory-filesystem.js', { spy: true });
|
||||
|
||||
@@ -127,40 +128,45 @@ describe('managed auto-memory indexer', () => {
|
||||
await expect(fs.readFile(index, 'utf-8')).resolves.toBe('GOOD INDEX\n');
|
||||
});
|
||||
|
||||
it('refuses to persist a partial index when a subdirectory cannot be read', async () => {
|
||||
// chmod 000 blocks neither root nor Windows, where a directory chmod only
|
||||
// toggles FILE_ATTRIBUTE_READONLY and so cannot make a directory
|
||||
// unreadable — the rebuild would resolve and the rejects assertion red.
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
||||
return;
|
||||
}
|
||||
// A partially walked root must fail the rebuild loudly: the index is the
|
||||
// persisted, authoritative artifact, so committing it from a scan that
|
||||
// silently skipped a directory would drop those entries with no warning.
|
||||
const memoryRoot = getAutoMemoryRoot(projectRoot);
|
||||
const indexPath = getAutoMemoryIndexPath(projectRoot);
|
||||
const doc = (name: string) =>
|
||||
`---\ntype: project\nname: ${name}\ndescription: ${name}\n---\nbody`;
|
||||
const visible = path.join(memoryRoot, 'project', 'visible.md');
|
||||
const locked = path.join(memoryRoot, 'reference');
|
||||
await fs.mkdir(path.dirname(visible), { recursive: true });
|
||||
await fs.writeFile(visible, doc('Visible'), 'utf-8');
|
||||
await fs.mkdir(locked, { recursive: true });
|
||||
await fs.writeFile(path.join(locked, 'hidden.md'), doc('Hidden'), 'utf-8');
|
||||
|
||||
const complete = await rebuildManagedAutoMemoryIndex(projectRoot);
|
||||
expect(complete).toContain('reference/hidden.md');
|
||||
|
||||
await fs.chmod(locked, 0o000);
|
||||
try {
|
||||
await expect(rebuildManagedAutoMemoryIndex(projectRoot)).rejects.toThrow(
|
||||
'memory scan',
|
||||
// chmod 000 blocks neither root with its DAC capabilities nor Windows,
|
||||
// where a directory chmod only toggles FILE_ATTRIBUTE_READONLY and so
|
||||
// cannot make a directory unreadable — the rebuild would resolve and the
|
||||
// rejects assertion red.
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'refuses to persist a partial index when a subdirectory cannot be read',
|
||||
async () => {
|
||||
// A partially walked root must fail the rebuild loudly: the index is the
|
||||
// persisted, authoritative artifact, so committing it from a scan that
|
||||
// silently skipped a directory would drop those entries with no warning.
|
||||
const memoryRoot = getAutoMemoryRoot(projectRoot);
|
||||
const indexPath = getAutoMemoryIndexPath(projectRoot);
|
||||
const doc = (name: string) =>
|
||||
`---\ntype: project\nname: ${name}\ndescription: ${name}\n---\nbody`;
|
||||
const visible = path.join(memoryRoot, 'project', 'visible.md');
|
||||
const locked = path.join(memoryRoot, 'reference');
|
||||
await fs.mkdir(path.dirname(visible), { recursive: true });
|
||||
await fs.writeFile(visible, doc('Visible'), 'utf-8');
|
||||
await fs.mkdir(locked, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(locked, 'hidden.md'),
|
||||
doc('Hidden'),
|
||||
'utf-8',
|
||||
);
|
||||
await expect(fs.readFile(indexPath, 'utf-8')).resolves.toBe(complete);
|
||||
} finally {
|
||||
await fs.chmod(locked, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
const complete = await rebuildManagedAutoMemoryIndex(projectRoot);
|
||||
expect(complete).toContain('reference/hidden.md');
|
||||
|
||||
await fs.chmod(locked, 0o000);
|
||||
try {
|
||||
await expect(
|
||||
rebuildManagedAutoMemoryIndex(projectRoot),
|
||||
).rejects.toThrow('memory scan');
|
||||
await expect(fs.readFile(indexPath, 'utf-8')).resolves.toBe(complete);
|
||||
} finally {
|
||||
await fs.chmod(locked, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('does not create a missing user root while rebuilding', async () => {
|
||||
const previousBaseDir = process.env['QWEN_CODE_MEMORY_BASE_DIR'];
|
||||
|
||||
@@ -9,6 +9,7 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { OmniUploadCache } from './upload-cache.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
let root: string;
|
||||
|
||||
@@ -25,11 +26,9 @@ afterEach(async () => {
|
||||
|
||||
const SHA = 'a'.repeat(64);
|
||||
|
||||
// chmod-based denial tests are meaningless on Windows and as root (root
|
||||
// bypasses file permission bits).
|
||||
const canDropPermissions =
|
||||
process.platform !== 'win32' &&
|
||||
(typeof process.getuid !== 'function' || process.getuid() !== 0);
|
||||
// chmod-based denial tests are meaningless where permission bits do not
|
||||
// bind: on Windows, and for root with its DAC capabilities.
|
||||
const canDropPermissions = arePermissionBitsEnforced();
|
||||
|
||||
async function listCorruptBackups(): Promise<string[]> {
|
||||
return (await fs.readdir(root)).filter((n) =>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Config } from '../config/config.js';
|
||||
import { findProjectRoot } from '../utils/projectRoot.js';
|
||||
import { ContainerExecutionEnvironment } from './container-execution-environment.js';
|
||||
import type { ExecutionWorkerRequest } from './execution-environment.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
const runtime = vi.hoisted(() => ({ spawn: vi.fn(), execFile: vi.fn() }));
|
||||
vi.mock('node:child_process', async (original) => ({
|
||||
@@ -374,7 +375,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.getuid?.() === 0).each(['mount point', 'output'])(
|
||||
it.skipIf(!arePermissionBitsEnforced()).each(['mount point', 'output'])(
|
||||
'retries failed %s deletion without releasing another sibling lease',
|
||||
async (target) => {
|
||||
const temporary = dirname(environment.outputDirectory);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { truncateUtf8 } from './service.js';
|
||||
import { MEDIA_MEMORY_FILE_NAME } from './store.js';
|
||||
import type { MediaMemorySnapshot } from './types.js';
|
||||
import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js';
|
||||
|
||||
let root: string;
|
||||
let clock: number;
|
||||
@@ -42,9 +43,7 @@ afterEach(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const canDropPermissions =
|
||||
process.platform !== 'win32' &&
|
||||
(typeof process.getuid !== 'function' || process.getuid() !== 0);
|
||||
const canDropPermissions = arePermissionBitsEnforced();
|
||||
|
||||
async function readSnapshot(): Promise<MediaMemorySnapshot> {
|
||||
const raw = await fs.readFile(
|
||||
|
||||
@@ -9,6 +9,7 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { MediaMemoryStore, MEDIA_MEMORY_FILE_NAME } from './store.js';
|
||||
import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js';
|
||||
|
||||
let root: string;
|
||||
let store: MediaMemoryStore;
|
||||
@@ -24,10 +25,9 @@ afterEach(async () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// chmod-based denial tests are meaningless on Windows and as root.
|
||||
const canDropPermissions =
|
||||
process.platform !== 'win32' &&
|
||||
(typeof process.getuid !== 'function' || process.getuid() !== 0);
|
||||
// chmod-based denial tests are meaningless where permission bits do not
|
||||
// bind: on Windows, and for root with its DAC capabilities.
|
||||
const canDropPermissions = arePermissionBitsEnforced();
|
||||
|
||||
async function listCorruptBackups(): Promise<string[]> {
|
||||
return (await fs.readdir(root)).filter((n) =>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SSH_WORKSPACE_SCRIPT } from './ssh-workspace-script.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
interface Reply {
|
||||
ok: boolean;
|
||||
@@ -631,7 +632,7 @@ describe.skipIf(process.platform === 'win32')('SSH filesystem script', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'keeps readable results when files and directories deny access',
|
||||
() => {
|
||||
writeFileSync(join(root, 'visible.txt'), 'needle');
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
STALE_WORKTREE_CUTOFF_MS,
|
||||
__test__,
|
||||
} from './worktreeCleanup.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
const { isEphemeralSlug } = __test__;
|
||||
|
||||
@@ -561,8 +562,9 @@ describe('cleanupStaleAgentWorktrees', () => {
|
||||
});
|
||||
|
||||
// chmod-based denial needs POSIX semantics; Windows ACLs do not map, and
|
||||
// root (CI containers) opens mode-000 directories without a warning.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
// root with its DAC capabilities opens mode-000 directories without a
|
||||
// warning.
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'preserves a stale worktree when the probe cannot enumerate a directory',
|
||||
async () => {
|
||||
// `git status` answers a directory it cannot open with a stderr
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
RecordArtifactTool,
|
||||
type RecordArtifactParams,
|
||||
} from './record-artifact.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
const RES = 'https://example.com/resource';
|
||||
@@ -456,21 +457,23 @@ describe('RecordArtifactTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies an unreadable path as permission denied', async () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) return;
|
||||
const ws = await workspace();
|
||||
await ws.write('hidden/a.csv', 'x');
|
||||
const hidden = path.join(ws.cwd, 'hidden');
|
||||
await chmod(hidden, 0);
|
||||
try {
|
||||
expectFailed(
|
||||
await ws.record('Hidden', 'hidden/a.csv'),
|
||||
ToolErrorType.PERMISSION_DENIED,
|
||||
);
|
||||
} finally {
|
||||
await chmod(hidden, 0o755);
|
||||
}
|
||||
});
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'classifies an unreadable path as permission denied',
|
||||
async () => {
|
||||
const ws = await workspace();
|
||||
await ws.write('hidden/a.csv', 'x');
|
||||
const hidden = path.join(ws.cwd, 'hidden');
|
||||
await chmod(hidden, 0);
|
||||
try {
|
||||
expectFailed(
|
||||
await ws.record('Hidden', 'hidden/a.csv'),
|
||||
ToolErrorType.PERMISSION_DENIED,
|
||||
);
|
||||
} finally {
|
||||
await chmod(hidden, 0o755);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a wrong workspace-folder prefix instead of reporting success', async () => {
|
||||
const ws = await workspace();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
renameWithRetry,
|
||||
renameWithRetrySync,
|
||||
} from './atomicFileWrite.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
type AsyncSeams = NonNullable<Parameters<typeof atomicWriteFile>[3]>;
|
||||
type SyncSeams = NonNullable<Parameters<typeof atomicWriteFileSync>[3]>;
|
||||
@@ -373,12 +374,11 @@ describe('atomicWriteFile', () => {
|
||||
);
|
||||
|
||||
it.skipIf(
|
||||
process.platform === 'win32' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
// chmod 0o000 against the file's real owner still succeeds via
|
||||
// POSIX rename in CI/sandbox setups where the user is effectively
|
||||
// root; only assert real EACCES when we own and can be denied.
|
||||
process.geteuid() === 0,
|
||||
typeof process.geteuid !== 'function' ||
|
||||
// The in-place write is refused only where permission bits bind: not
|
||||
// on Windows, nor for root with its DAC capabilities, which writes
|
||||
// the 0o444 file anyway.
|
||||
!arePermissionBitsEnforced(),
|
||||
)(
|
||||
'should surface EACCES when in-place fallback hits an unwritable file',
|
||||
async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
collectRecordableWorkspaceFiles,
|
||||
pathHasSkippedDirectoryComponent,
|
||||
} from './workspace-artifact-directory.js';
|
||||
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
|
||||
|
||||
describe('collectRecordableWorkspaceFiles', () => {
|
||||
const dirs: string[] = [];
|
||||
@@ -145,25 +146,25 @@ describe('collectRecordableWorkspaceFiles', () => {
|
||||
expect(collected.depthLimited).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an unreadable over-depth directory as depth-limited', async () => {
|
||||
if (process.platform === 'win32' || process.getuid?.() === 0) {
|
||||
return;
|
||||
}
|
||||
const root = await workspace();
|
||||
const deepParts = Array.from(
|
||||
{ length: MAX_DIRECTORY_ARTIFACT_DEPTH + 1 },
|
||||
(_, index) => `d${index}`,
|
||||
);
|
||||
const deepDir = path.join(root, ...deepParts);
|
||||
await mkdir(deepDir, { recursive: true });
|
||||
await writeFile(path.join(root, 'shallow.xlsx'), 'shallow');
|
||||
await chmod(deepDir, 0o000);
|
||||
try {
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files).toEqual(['shallow.xlsx']);
|
||||
expect(collected.depthLimited).toBe(true);
|
||||
} finally {
|
||||
await chmod(deepDir, 0o755);
|
||||
}
|
||||
});
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'treats an unreadable over-depth directory as depth-limited',
|
||||
async () => {
|
||||
const root = await workspace();
|
||||
const deepParts = Array.from(
|
||||
{ length: MAX_DIRECTORY_ARTIFACT_DEPTH + 1 },
|
||||
(_, index) => `d${index}`,
|
||||
);
|
||||
const deepDir = path.join(root, ...deepParts);
|
||||
await mkdir(deepDir, { recursive: true });
|
||||
await writeFile(path.join(root, 'shallow.xlsx'), 'shallow');
|
||||
await chmod(deepDir, 0o000);
|
||||
try {
|
||||
const collected = await collectRecordableWorkspaceFiles(root, '', root);
|
||||
expect(collected.files).toEqual(['shallow.xlsx']);
|
||||
expect(collected.depthLimited).toBe(true);
|
||||
} finally {
|
||||
await chmod(deepDir, 0o755);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import * as path from 'node:path';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
/** The node:fs calls the probe makes. */
|
||||
export interface ProbeFs {
|
||||
mkdtempSync(prefix: string): string;
|
||||
chmodSync(path: string, mode: number): void;
|
||||
readdirSync(path: string): unknown;
|
||||
writeFileSync(path: string, data: string): void;
|
||||
rmSync(path: string, options: { recursive: boolean; force: boolean }): void;
|
||||
}
|
||||
|
||||
// The real node:fs and node:os, even when the test file that asks has
|
||||
// vi.mock()ed them: vitest mocks its own module graph, not a builtin loaded
|
||||
// through Node's require. A mocked fs would answer for the mock, not the
|
||||
// kernel. A vi.spyOn on node:fs's default export at collection time would
|
||||
// still reach the probe, since that export is this same module object.
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const realFs = nodeRequire('node:fs') as typeof import('node:fs');
|
||||
const realOs = nodeRequire('node:os') as typeof import('node:os');
|
||||
|
||||
/**
|
||||
* Whether file permission bits bind this process, for tests that force a
|
||||
* failure with `chmod`. They do not on Windows, which ignores Unix-style
|
||||
* bits, nor for root, which bypasses them (on Linux through
|
||||
* CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH). ci.yml drops both
|
||||
* capabilities when it runs unit tests as root (package-scripts.test.js
|
||||
* pins that), so asking the kernel, rather than checking for uid 0, keeps
|
||||
* those tests running there. A probe that cannot measure also answers
|
||||
* false, and says so on stderr.
|
||||
* `fs` is for this helper's own tests; every caller probes the real one.
|
||||
*/
|
||||
export function arePermissionBitsEnforced(fs: ProbeFs = realFs): boolean {
|
||||
if (process.platform === 'win32') return false;
|
||||
let dir: string | undefined;
|
||||
try {
|
||||
dir = fs.mkdtempSync(path.join(realOs.tmpdir(), 'qwen-permission-probe-'));
|
||||
fs.chmodSync(dir, 0o000);
|
||||
const probe = dir;
|
||||
return (
|
||||
refused(() => fs.readdirSync(probe)) &&
|
||||
refused(() => fs.writeFileSync(path.join(probe, 'probe'), ''))
|
||||
);
|
||||
} catch (error) {
|
||||
// A temp directory that cannot hold the probe, or refuses chmod,
|
||||
// cannot hold these tests' fixtures either, though after a transient
|
||||
// ENOSPC it may hold them again by the time they run, so say which it
|
||||
// was rather than skip silently.
|
||||
inconclusive(error);
|
||||
return false;
|
||||
} finally {
|
||||
if (dir !== undefined) {
|
||||
try {
|
||||
fs.chmodSync(dir, 0o700);
|
||||
} catch {
|
||||
// Best effort, like the removal below: newer Node versions refuse
|
||||
// to remove even an empty mode-000 directory.
|
||||
}
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best effort: a leftover probe must not fail the test file.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refused(attempt: () => unknown): boolean {
|
||||
try {
|
||||
attempt();
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (errnoOf(error) === 'EACCES') return true;
|
||||
inconclusive(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function errnoOf(error: unknown): string | undefined {
|
||||
const code = attempt(
|
||||
() => (error as NodeJS.ErrnoException | null | undefined)?.code,
|
||||
);
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names a probe that could not measure, and the test file that asked, so
|
||||
* the skips it causes are not mistaken for a Windows or root run. vitest's
|
||||
* `silent: true` drops console output, so this writes to stderr directly.
|
||||
* Under GitHub Actions the line opens with a workflow command, so the skips
|
||||
* show in the check UI and not only deep in the log.
|
||||
*/
|
||||
function inconclusive(error: unknown): void {
|
||||
try {
|
||||
process.stderr.write(
|
||||
`${process.env['GITHUB_ACTIONS'] === 'true' ? '::warning::' : ''}` +
|
||||
`[permission-bits] probe inconclusive (${causeOf(error)})${askedBy()}; ` +
|
||||
'treating permission bits as unenforced, so chmod-based tests skip\n',
|
||||
);
|
||||
} catch {
|
||||
// A diagnostic must never fail the test file it is meant to explain.
|
||||
}
|
||||
}
|
||||
|
||||
function causeOf(error: unknown): string {
|
||||
const code = errnoOf(error);
|
||||
if (code === undefined) {
|
||||
return (
|
||||
attempt(() => String(error).split('\n', 1)[0]) ?? 'an unprintable value'
|
||||
);
|
||||
}
|
||||
const syscall = attempt(() => (error as NodeJS.ErrnoException).syscall);
|
||||
return typeof syscall === 'string' ? `${code} in ${syscall}` : code;
|
||||
}
|
||||
|
||||
function askedBy(): string {
|
||||
const testPath = attempt(() => expect.getState().testPath);
|
||||
if (!testPath) return '';
|
||||
return ` for ${attempt(() => path.relative(process.cwd(), testPath)) ?? testPath}`;
|
||||
}
|
||||
|
||||
function attempt<T>(read: () => T): T | undefined {
|
||||
try {
|
||||
return read();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type SessionRecord,
|
||||
writeOwnRecord,
|
||||
} from '../../../src/peer/registry.js';
|
||||
import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js';
|
||||
|
||||
const isPosix = process.platform !== 'win32';
|
||||
const isLinux = process.platform === 'linux';
|
||||
@@ -274,7 +275,7 @@ describe('writeOwnRecord — defensive paths', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(isPosix && process.getuid?.() !== 0)(
|
||||
it.runIf(arePermissionBitsEnforced())(
|
||||
'does not replace a record it merely failed to read',
|
||||
async () => {
|
||||
const shared = plant(
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
listSourceFiles,
|
||||
symlinkedPathComponents,
|
||||
} from '../check-tui-dep-direction.mjs';
|
||||
import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js';
|
||||
|
||||
const temporaryDirectories = [];
|
||||
|
||||
@@ -435,7 +436,7 @@ describe('checkRule', () => {
|
||||
expect(otherRule.violations).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'reports unlistable directories instead of silently dropping them',
|
||||
() => {
|
||||
const root = makeTemporaryDirectory();
|
||||
@@ -709,7 +710,7 @@ describe('end-to-end gate run (main wiring)', () => {
|
||||
expect(out).toContain('not found or has no source files');
|
||||
}, 40000);
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'fails on an unlistable directory inside the scanned tree',
|
||||
() => {
|
||||
const base = makeFixtureCheckout();
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../copy_bundle_assets.js';
|
||||
import { copyFiles } from '../copy_files.js';
|
||||
import { preparePackage } from '../prepare-package.js';
|
||||
import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js';
|
||||
|
||||
const realReadFileSync = fs.readFileSync;
|
||||
const realReaddirSync = fs.readdirSync;
|
||||
@@ -569,12 +570,12 @@ describe('package asset scripts', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// chmod is the only lever this case has: on Windows it is a no-op, and a
|
||||
// root user reads through it, so the branch under test is unreachable
|
||||
// there. The case skips rather than running into the other branch — a
|
||||
// readable tree, stamped as usual — and failing red against the no-stamp
|
||||
// assertion.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
// chmod is the only lever this case has: on Windows it is a no-op, and
|
||||
// root with its DAC capabilities reads through it, so the branch under
|
||||
// test is unreachable there. The case skips rather than running into the
|
||||
// other branch — a readable tree, stamped as usual — and failing red
|
||||
// against the no-stamp assertion.
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'refuses when a digest root exists but cannot be listed',
|
||||
() => {
|
||||
// The root-level arm of the unlistable-directory refusal: with the
|
||||
|
||||
@@ -1158,9 +1158,9 @@ describe('package scripts', () => {
|
||||
});
|
||||
|
||||
it('drops DAC capabilities for root unit tests on both CI commands', () => {
|
||||
// The permission-bit gates in packages/cli, packages/core,
|
||||
// packages/channels/wecom and scripts/tests ask the kernel, so on a root
|
||||
// lane that keeps these capabilities they all skip, without failing.
|
||||
// The permission-bit probes in the workspaces and scripts/tests ask the
|
||||
// kernel, so on a root lane that keeps these capabilities every case
|
||||
// they gate skips, without failing.
|
||||
const step = getWorkflowStep(
|
||||
getWorkflowJob(readWorkflow('.github/workflows/ci.yml'), 'test'),
|
||||
'Run tests and generate reports',
|
||||
@@ -1198,6 +1198,40 @@ describe('package scripts', () => {
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps every copy of the permission-bit probe identical', () => {
|
||||
// The probe is copied, not shared: each package that uses it keeps its
|
||||
// own copy, and cli, core and browser-use also keep its tests (wecom
|
||||
// inlines a smaller probe for its one case). A fix to one copy has to
|
||||
// reach the others, or the packages measure differently.
|
||||
const copies = {
|
||||
'permission-bits.ts': [
|
||||
'packages/cli/src/test-utils',
|
||||
'packages/core/src/test-utils',
|
||||
'packages/browser-use/src/test-utils',
|
||||
'packages/sdk-typescript/test/test-utils',
|
||||
'integrations/test-failure-explorer/src/test-utils',
|
||||
],
|
||||
'permission-bits.test.ts': [
|
||||
'packages/cli/src/test-utils',
|
||||
'packages/core/src/test-utils',
|
||||
'packages/browser-use/src/test-utils',
|
||||
],
|
||||
'permission-bits.mocked-fs.test.ts': [
|
||||
'packages/cli/src/test-utils',
|
||||
'packages/core/src/test-utils',
|
||||
'packages/browser-use/src/test-utils',
|
||||
],
|
||||
};
|
||||
for (const [file, dirs] of Object.entries(copies)) {
|
||||
const [reference, ...others] = dirs.map((dir) =>
|
||||
readFileSync(path.join(root, dir, file), 'utf8'),
|
||||
);
|
||||
others.forEach((text, i) => {
|
||||
expect(text, `${file}: ${dirs[i + 1]} vs ${dirs[0]}`).toBe(reference);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds the CI unit step so a hang fails instead of stalling', () => {
|
||||
// #10490's run 3 was cancelled at ~60 minutes, leaving behind
|
||||
// orphaned test processes; a stall reads as a timeout, not a failure.
|
||||
|
||||
@@ -3427,7 +3427,8 @@ describe('checkout self-heal', () => {
|
||||
// A workspace whose entries the owning user cannot unlink: find -exec rm
|
||||
// exits nonzero, so the user-mode wipe leg fails deterministically. Root
|
||||
// bypasses the 0o500 lock via CAP_DAC_OVERRIDE, so the tests built on it
|
||||
// skip there, and win32 has no POSIX permission bits to honor.
|
||||
// skip where it keeps that capability, and win32 has no POSIX permission
|
||||
// bits to honor.
|
||||
const lockFixture = () => {
|
||||
const parent = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), 'checkout-heal-lock-')),
|
||||
@@ -3443,7 +3444,7 @@ describe('checkout self-heal', () => {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'exits 0 and keeps survivors when BOTH wipe legs fail',
|
||||
() => {
|
||||
// The wipe step has no continue-on-error, so its own never-fail exit
|
||||
@@ -3473,7 +3474,7 @@ describe('checkout self-heal', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'escalates to the sudo leg when the user-mode wipe fails',
|
||||
() => {
|
||||
// Deleting the `|| sudo -n find …` leg (or flipping it to `&&`) must
|
||||
|
||||
@@ -21,6 +21,7 @@ import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from 'yaml';
|
||||
import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js';
|
||||
|
||||
const workflow = readFileSync('.github/workflows/qwen-triage.yml', 'utf8');
|
||||
const cacheProducerWorkflow = readFileSync(
|
||||
@@ -2284,12 +2285,13 @@ describe('qwen-triage verify hardening', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)(
|
||||
it.skipIf(!hasGnuRealpath || !arePermissionBitsEnforced())(
|
||||
'fails closed when the corrupt workspace cannot be unlinked',
|
||||
() => {
|
||||
// A swallowed `rm -f` failure would let the mkdir and the wipe run on
|
||||
// a path that is still a symlink. Root bypasses the mode bits, so the
|
||||
// fixture cannot produce the refusal there.
|
||||
// a path that is still a symlink. Root with its DAC capabilities
|
||||
// bypasses the mode bits, so the fixture cannot produce the refusal
|
||||
// there.
|
||||
for (const stepName of WIPE_STEPS) {
|
||||
const parent = mkdtempSync(join(tmpdir(), 'verify-heal-perm-'));
|
||||
const outside = mkdtempSync(join(tmpdir(), 'verify-heal-outside-'));
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
reviewSourceRoots,
|
||||
reviewSourcesDigest,
|
||||
} from '../../packages/cli/src/commands/review/lib/stale-bundle.js';
|
||||
import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js';
|
||||
|
||||
const repoRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
|
||||
@@ -228,11 +229,12 @@ describe('the build stamp and the staleness check agree', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// chmod is the only lever this case has: on Windows it is a no-op, and a
|
||||
// root user reads through it, so the branch under test is unreachable
|
||||
// there. The case skips rather than measuring a readable tree and failing
|
||||
// red against the throw-and-nothing-measured assertions.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
// chmod is the only lever this case has: on Windows it is a no-op, and
|
||||
// root with its DAC capabilities reads through it, so the branch under
|
||||
// test is unreachable there. The case skips rather than measuring a
|
||||
// readable tree and failing red against the throw-and-nothing-measured
|
||||
// assertions.
|
||||
it.skipIf(!arePermissionBitsEnforced())(
|
||||
'neither side measures a tree whose subdirectory cannot be listed',
|
||||
() => {
|
||||
// The unreadable-DIRECTORY case, on both sides of the boundary at once:
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
reviewBranch,
|
||||
worktreePath,
|
||||
} from '../../packages/cli/src/commands/review/lib/paths.js';
|
||||
import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js';
|
||||
|
||||
// The cleanup steps in ci.yml and qwen-code-pr-review.yml hard-code the
|
||||
// review-artifact layout owned by paths.ts: worktreePath()/reviewBranch()
|
||||
@@ -191,13 +192,13 @@ function expectQuarantineFallback(run) {
|
||||
// guard's `! -L` arm is what keeps `chmod -R u+w` from dereferencing a
|
||||
// symlinked leftover into a tree outside the workspace. The behavioral
|
||||
// fixtures below execute both copies where permission fixtures are
|
||||
// available (skipped on the Windows/root lanes): the dangling-symlink
|
||||
// fixture witnesses the existence guard's `-L` arm behaviorally on each
|
||||
// copy, but the chmod guard's `! -L` arm has no behavioral witness (the
|
||||
// live-symlink fixture asserts content, not mode), so pin both arms
|
||||
// textually here on every copy — dropping `-L` fails the dangling-symlink
|
||||
// fixture, while dropping `! -L` keeps this suite green and re-poisons
|
||||
// the checkout (mutation-probed).
|
||||
// available (skipped on Windows, and for root with its DAC capabilities):
|
||||
// the dangling-symlink fixture witnesses the existence guard's `-L` arm
|
||||
// behaviorally on each copy, but the chmod guard's `! -L` arm has no
|
||||
// behavioral witness (the live-symlink fixture asserts content, not
|
||||
// mode), so pin both arms textually here on every copy — dropping `-L`
|
||||
// fails the dangling-symlink fixture, while dropping `! -L` keeps this
|
||||
// suite green and re-poisons the checkout (mutation-probed).
|
||||
expect(code).toContain('[ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]');
|
||||
expect(code).toContain('[ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]');
|
||||
// The move must be the fallback of the removal chain, not an
|
||||
@@ -263,9 +264,9 @@ const sweepToolsAvailable =
|
||||
).status === 0;
|
||||
// The fixtures defeat rm with a chmod-555 parent, which needs POSIX
|
||||
// permission semantics: Git Bash on Windows resolves `bash` but not chmod,
|
||||
// and root ignores the bits entirely.
|
||||
// and root with its DAC capabilities ignores the bits entirely.
|
||||
const permissionFixturesAvailable =
|
||||
bashAvailable && process.platform !== 'win32' && process.geteuid?.() !== 0;
|
||||
bashAvailable && arePermissionBitsEnforced();
|
||||
// The ladder's failure path resolves the leftover with `realpath --`; on a
|
||||
// host without it (the merge_group macOS lane ships none) the function
|
||||
// refuses as `path could not be resolved` instead, so the test asserting
|
||||
|
||||
@@ -22,6 +22,7 @@ import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from 'yaml';
|
||||
import { arePermissionBitsEnforced } from '../../packages/cli/src/test-utils/permission-bits.js';
|
||||
|
||||
const workflow = readFileSync('.github/workflows/serve-ab.yml', 'utf8');
|
||||
|
||||
@@ -744,12 +745,13 @@ describe('serve-ab pre-checkout workspace wipe', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)(
|
||||
it.skipIf(!hasGnuRealpath || !arePermissionBitsEnforced())(
|
||||
'fails closed when the corrupt workspace cannot be unlinked',
|
||||
() => {
|
||||
// A swallowed `rm -f` failure would let the mkdir and the wipe run on
|
||||
// a path that is still a symlink. Root bypasses the mode bits, so the
|
||||
// fixture cannot produce the refusal there.
|
||||
// a path that is still a symlink. Root with its DAC capabilities
|
||||
// bypasses the mode bits, so the fixture cannot produce the refusal
|
||||
// there.
|
||||
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-perm-'));
|
||||
const outside = mkdtempSync(join(tmpdir(), 'serve-ab-heal-outside-'));
|
||||
const ws = join(parent, 'repo');
|
||||
|
||||
Reference in new issue
Block a user