mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-09-11 11:18:36 +00:00
* ci: tolerate shared runner scheduling delays Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(ci): inherit ECS contention budgets Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): extend PTY request budget on ECS Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): synchronize ACP archive race Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(ci): isolate visuals publisher fixture modules Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(ci): cover hostile parent package scopes Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(sdk-java): isolate finite SSE fixture connections Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): make timer-range deadline deterministic Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(sdk-java): tolerate shared CI scheduling delays Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(sdk-java): preserve timeout contract on CI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(sdk-java): include request submission in timeout bound Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * ci: extend no-AK shared runner timeout Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(ci): guard no-AK integration budget Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * ci(sdk-java): serialize ECS test execution Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(sdk-java): relax stalled-response setup on CI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(ci): align no-AK timeout contract Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * ci: extend Serve A/B shared runner budget Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * ci: scope extended ceilings to ECS routing The extended job ceilings (test 60->90, integration_no_ak 30->60) were unconditional even though runs-on falls back to GitHub-hosted ubuntu-latest for fork PRs from untrusted authors and when MAINTAINER_ECS_RUNNER_DISABLED=true. Derive each ceiling from the runner classify_pr selected so hosted paths keep the pre-contention bound and a genuine hang there does not burn the extra 30 minutes. The lane contract now evaluates the real timeout expression for both routings instead of pinning a bare number, matching the substitute-then-evaluate technique ci-runner-routing.test.mjs uses on runs-on. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ci): address closeout review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
842 lines
33 KiB
JavaScript
842 lines
33 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { basename, dirname, join } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { parse } from 'yaml';
|
|
|
|
const workflow = readFileSync('.github/workflows/serve-ab.yml', 'utf8');
|
|
|
|
const job = parse(workflow).jobs['ab'];
|
|
const steps = job.steps;
|
|
const WIPE = 'Wipe stale workspace except the shared .git before checkout';
|
|
const wipe = steps.find((s) => s.name === WIPE);
|
|
|
|
// Runs the real wipe script under the runner's shell flags: this job sets
|
|
// `defaults.run.shell: bash`, which GitHub Actions executes with
|
|
// `-eo pipefail`, so the exec tests must reproduce that instead of hiding
|
|
// it behind bare `bash -c`.
|
|
const runWipe = (env, options = {}) => {
|
|
// GitHub Actions starts the step with its CWD in GITHUB_WORKSPACE, so
|
|
// the harness does too — a symlinked root then opens the link's target
|
|
// as the CWD, exactly the shape the kept-.git tail must contain. The
|
|
// heal fixtures hand over a workspace that is a file or a dangling
|
|
// symlink — no cwd to stand in — so pin the parent instead: a mkdtemp
|
|
// dir, never a repo.
|
|
let cwd = env.GITHUB_WORKSPACE;
|
|
try {
|
|
if (!statSync(cwd).isDirectory()) cwd = dirname(cwd);
|
|
} catch {
|
|
cwd = dirname(cwd);
|
|
}
|
|
return execFileSync('bash', ['-e', '-o', 'pipefail', '-c', wipe.run], {
|
|
encoding: 'utf8',
|
|
cwd,
|
|
env: { ...process.env, ...env },
|
|
...options,
|
|
});
|
|
};
|
|
|
|
// `realpath -m` (the script's canonicalization line) is a GNU coreutils
|
|
// extension. Probe the host before asserting GNU-specific path behavior.
|
|
const hasGnuRealpath =
|
|
spawnSync('realpath', ['-m', '--', '/'], { stdio: 'ignore' }).status === 0;
|
|
|
|
describe('serve-ab pre-checkout workspace wipe', () => {
|
|
it('runs the wipe before both checkouts', () => {
|
|
// Both checkouts clone into the wiped workspace; a wipe ordered after
|
|
// either one deletes what was just cloned, and whichever build runs
|
|
// first runs on the previous run's leftovers — the exact cross-PR
|
|
// bleed the step exists to prevent. The sister qwen-triage suite pins
|
|
// the same property.
|
|
const names = steps.map((stepItem) => stepItem.name);
|
|
const wipeAt = names.indexOf(WIPE);
|
|
expect(wipeAt).toBeGreaterThanOrEqual(0);
|
|
expect(wipeAt).toBeLessThan(names.indexOf('Checkout PR head'));
|
|
expect(wipeAt).toBeLessThan(names.indexOf('Checkout the merge-base'));
|
|
});
|
|
|
|
it('runs only on self-hosted runners, where workspace state persists', () => {
|
|
expect(wipe).toBeTruthy();
|
|
// Hosted runners are ephemeral; the wipe (and its guard) exist for the
|
|
// reusable ECS pool only.
|
|
expect(wipe.if).toBe("${{ runner.environment == 'self-hosted' }}");
|
|
});
|
|
|
|
// The job drives BOTH checkouts end-to-end (npm ci, full monorepo build,
|
|
// daemon drive, each); a contended shared runner exhausted 45 minutes near
|
|
// the end of the second cycle. Pin the floor so that cancellation does not
|
|
// recur.
|
|
it('keeps a job timeout with headroom for two full build cycles', () => {
|
|
expect(job['timeout-minutes']).toBeGreaterThanOrEqual(60);
|
|
});
|
|
|
|
it('carries the full checkout-heal guard (#9220, #9265)', () => {
|
|
// Before the port this step had NO guard: under a mangled env even
|
|
// `/home` or an empty string reached `find … -exec rm -rf {} +`.
|
|
// Pin each ported layer textually, mirroring the reference guard in
|
|
// qwen-code-pr-review.yml; the exec tests below prove the behavior.
|
|
expect(wipe.run).toContain('GITHUB_WORKSPACE:?');
|
|
expect(wipe.run).toContain('realpath -m');
|
|
expect(wipe.run).toContain('realpath -m -- "$RWS"');
|
|
expect(wipe.run).toContain('refusing to wipe suspicious workspace path');
|
|
expect(wipe.run).toContain('RUNNER_WORKSPACE:?');
|
|
expect(wipe.run).toContain('"$RWS"/*');
|
|
// RWS-side layers: the '..' arm and the degenerate-root refusal that
|
|
// keeps a stripped-empty runner workspace from degenerating the
|
|
// allowlist pattern to `/*`.
|
|
expect(wipe.run).toContain(
|
|
"refusing runner workspace path containing '..'",
|
|
);
|
|
expect(wipe.run).toContain('runner workspace resolved to /');
|
|
// Exit contract: the guard and the destructive lines stay bare on
|
|
// purpose — under the job's `-eo pipefail` a wipe that cannot clear the
|
|
// workspace fails the job here instead of building both checkouts on
|
|
// top of the leftovers. `|| true` may appear only on the kept-.git
|
|
// defang scrub — the config.worktree defang pair and the allowlist
|
|
// sweep — mirroring qwen-triage.yml's config-sanitize.
|
|
const loosened = wipe.run
|
|
.split('\n')
|
|
.filter((line) => line.includes('|| true'));
|
|
expect(loosened).toHaveLength(3);
|
|
expect(loosened[0]).toContain('--git-path config.worktree');
|
|
expect(loosened[1]).toContain('--unset-all extensions.worktreeConfig');
|
|
expect(loosened[2]).toContain('config --local --name-only --list');
|
|
});
|
|
|
|
it('carries the symlink heal, ordered and bounded (#9480)', () => {
|
|
// The heal has to sit BEFORE the canonicalization: afterwards the path
|
|
// has already resolved to the link's target, the allowlist refuses it,
|
|
// and that refusal removes nothing — the wedge. Order is the property,
|
|
// so it is asserted as one, not as the presence of two strings.
|
|
const healAt = wipe.run.indexOf('[ -L "$WS" ] || [ ! -d "$WS" ]');
|
|
const canonAt = wipe.run.indexOf('realpath -m -- "$WS"');
|
|
const rwsAt = wipe.run.indexOf('RWS="${RUNNER_WORKSPACE:?}"');
|
|
expect(healAt).toBeGreaterThan(-1);
|
|
expect(healAt).toBeLessThan(canonAt);
|
|
// …and AFTER the allowlist root is prepared, since that root is what
|
|
// bounds the heal. A raw, empty $RUNNER_WORKSPACE would degenerate the
|
|
// containment pattern to the match-all `/*`.
|
|
expect(rwsAt).toBeLessThan(healAt);
|
|
// The raw strip has to precede the predicates: `[ -L "$WS/" ]` and
|
|
// `[ ! -d "$WS/" ]` both resolve THROUGH the link and report its target.
|
|
expect(wipe.run.indexOf('while [ "${WS%/}" != "$WS" ]')).toBeLessThan(
|
|
healAt,
|
|
);
|
|
// Containment is judged on the canonical PARENT, never on $WS itself —
|
|
// resolving $WS would follow the very link being removed, and a raw
|
|
// match cannot see intermediate symlink components.
|
|
expect(wipe.run).toContain(
|
|
'HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)"',
|
|
);
|
|
expect(wipe.run).toContain('"$RWS"|"$RWS"/*)');
|
|
expect(wipe.run).toContain('refusing to heal workspace outside');
|
|
// Both legs fail closed: under `-e` a failure that is not the last
|
|
// command of an && list is swallowed, and a swallowed one here leaves
|
|
// the wipe running against a corrupt path.
|
|
expect(wipe.run).toContain('rm -f -- "$WS" || {');
|
|
expect(wipe.run).toContain('mkdir -- "$WS" || {');
|
|
// The incident leaves no other trace.
|
|
expect(wipe.run).toContain('::warning::healing workspace');
|
|
});
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'wipes a legitimate workspace but keeps and defangs the shared .git',
|
|
() => {
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-ok-'));
|
|
const ws = join(parent, 'repo');
|
|
// Leftovers shaped like the real ones: the two checkout subtrees plus
|
|
// a stale build artifact.
|
|
mkdirSync(join(ws, 'head'), { recursive: true });
|
|
mkdirSync(join(ws, 'base'), { recursive: true });
|
|
writeFileSync(join(ws, 'head', 'package.json'), '{}');
|
|
writeFileSync(join(ws, 'bundle.tgz'), 'x');
|
|
// A shared root .git shaped like the real one: objects are the reason
|
|
// it is kept; hooks, info/attributes, and non-allowlisted local
|
|
// config are the exec vectors that must not survive into the next
|
|
// job's checkout.
|
|
execFileSync('git', ['init', '--quiet', ws]);
|
|
mkdirSync(join(ws, '.git', 'objects', 'pack'), { recursive: true });
|
|
writeFileSync(join(ws, '.git', 'objects', 'pack', 'sentinel'), 'x');
|
|
writeFileSync(
|
|
join(ws, '.git', 'hooks', 'post-checkout'),
|
|
'#!/bin/sh\necho pwned\n',
|
|
);
|
|
writeFileSync(join(ws, '.git', 'info', 'attributes'), '* filter=x\n');
|
|
execFileSync('git', ['config', '--local', 'alias.pwned', '!echo pwned'], {
|
|
cwd: ws,
|
|
});
|
|
execFileSync('git', ['config', '--local', 'safe.directory', ws], {
|
|
cwd: ws,
|
|
});
|
|
try {
|
|
runWipe({ GITHUB_WORKSPACE: ws, RUNNER_WORKSPACE: parent });
|
|
expect(readdirSync(ws)).toEqual(['.git']);
|
|
expect(
|
|
readFileSync(join(ws, '.git', 'objects', 'pack', 'sentinel'), 'utf8'),
|
|
).toBe('x');
|
|
expect(existsSync(join(ws, '.git', 'hooks'))).toBe(false);
|
|
expect(existsSync(join(ws, '.git', 'info', 'attributes'))).toBe(false);
|
|
expect(() =>
|
|
execFileSync('git', ['config', '--local', 'alias.pwned'], {
|
|
cwd: ws,
|
|
stdio: 'pipe',
|
|
}),
|
|
).toThrow();
|
|
expect(
|
|
execFileSync('git', ['config', '--local', 'safe.directory'], {
|
|
cwd: ws,
|
|
encoding: 'utf8',
|
|
}).trim(),
|
|
).toBe(ws);
|
|
// The directory itself survives: the checkouts clone into it next.
|
|
expect(wipe.run).toContain('-mindepth 1 -maxdepth 1');
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'defangs the worktreeConfig split-config bypass',
|
|
() => {
|
|
// extensions.worktreeConfig activates .git/config.worktree, a second
|
|
// local file that `git config --local` neither lists nor unsets — a
|
|
// planted exec vector there survives the allowlist sweep and fires on
|
|
// the next job's checkout. The defang deletes the file and drops the
|
|
// extension, mirroring qwen-triage.yml's hardened config-sanitize.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-wtcfg-'));
|
|
const ws = join(parent, 'repo');
|
|
execFileSync('git', ['init', '--quiet', ws]);
|
|
execFileSync(
|
|
'git',
|
|
['config', '--local', 'extensions.worktreeConfig', 'true'],
|
|
{ cwd: ws },
|
|
);
|
|
execFileSync(
|
|
'git',
|
|
['config', '--worktree', 'core.hooksPath', join(parent, 'evil-hooks')],
|
|
{ cwd: ws },
|
|
);
|
|
try {
|
|
runWipe({ GITHUB_WORKSPACE: ws, RUNNER_WORKSPACE: parent });
|
|
expect(existsSync(join(ws, '.git', 'config.worktree'))).toBe(false);
|
|
expect(() =>
|
|
execFileSync(
|
|
'git',
|
|
['config', '--local', 'extensions.worktreeConfig'],
|
|
{ cwd: ws, stdio: 'pipe' },
|
|
),
|
|
).toThrow();
|
|
// Full-scope resolution: the planted exec vector no longer resolves.
|
|
expect(
|
|
spawnSync('git', ['-C', ws, 'config', '--get', 'core.hooksPath'], {
|
|
encoding: 'utf8',
|
|
}).status,
|
|
).not.toBe(0);
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'never scrubs a repo outside a healed workspace',
|
|
() => {
|
|
// The heal unlinks the workspace symlink and recreates it as an empty
|
|
// real dir, but the step's CWD was opened through the link and still
|
|
// IS the target repo. The kept-.git tail must stay anchored to
|
|
// $WS/.git: discovering the repo from the CWD here unsets the
|
|
// target's local config and deletes its config.worktree — writes
|
|
// outside the workspace. The target carries the full split-config
|
|
// shape so every anchored line has its own witness.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-scrub-'));
|
|
const target = mkdtempSync(join(tmpdir(), 'serve-ab-heal-target-'));
|
|
execFileSync('git', ['init', '--quiet', target]);
|
|
execFileSync(
|
|
'git',
|
|
['config', '--local', 'extensions.worktreeConfig', 'true'],
|
|
{ cwd: target },
|
|
);
|
|
execFileSync(
|
|
'git',
|
|
['config', '--worktree', 'core.hooksPath', join(target, 'evil')],
|
|
{ cwd: target },
|
|
);
|
|
execFileSync('git', ['config', '--local', 'alias.pwned', '!echo pwned'], {
|
|
cwd: target,
|
|
});
|
|
const ws = join(parent, 'repo');
|
|
symlinkSync(target, ws);
|
|
try {
|
|
runWipe({ GITHUB_WORKSPACE: ws, RUNNER_WORKSPACE: parent });
|
|
// The workspace heals to an empty real dir...
|
|
expect(lstatSync(ws).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(ws).isDirectory()).toBe(true);
|
|
expect(readdirSync(ws)).toEqual([]);
|
|
// ...and the link target's repo is untouched: the split file, the
|
|
// extension, and the hostile local key all survive.
|
|
expect(existsSync(join(target, '.git', 'config.worktree'))).toBe(true);
|
|
expect(
|
|
execFileSync(
|
|
'git',
|
|
['config', '--local', 'extensions.worktreeConfig'],
|
|
{ cwd: target, encoding: 'utf8' },
|
|
).trim(),
|
|
).toBe('true');
|
|
expect(
|
|
execFileSync('git', ['config', '--local', 'alias.pwned'], {
|
|
cwd: target,
|
|
encoding: 'utf8',
|
|
}).trim(),
|
|
).toBe('!echo pwned');
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
rmSync(target, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
// The guard must be exercised with the REAL dangerous paths, so `rm` is
|
|
// stubbed to a recorder on PATH: the destructive primitive cannot fire
|
|
// here under ANY edit, and the assertion is on the decision rather than
|
|
// on filesystem effects — with the guard gone the recorder shows an
|
|
// attempted delete and the test fails, having deleted nothing.
|
|
it('refuses suspicious workspace paths without invoking rm', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-guard-'));
|
|
try {
|
|
const calls = join(dir, 'rm-calls');
|
|
writeFileSync(
|
|
join(dir, 'rm'),
|
|
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexit 0\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
|
|
// Canonical roots, the non-canonical spellings the canonicalize and
|
|
// strip layers exist for, and /tmp + /opt which only the allowlist
|
|
// refuses (the denylist has no arm for them).
|
|
for (const bad of [
|
|
'/',
|
|
'/usr',
|
|
'/etc',
|
|
'/var',
|
|
'/root',
|
|
'/home',
|
|
'',
|
|
'/home/',
|
|
'/root/',
|
|
'/var/',
|
|
'//',
|
|
'/home//',
|
|
'/home/.',
|
|
'/home/..',
|
|
'//usr',
|
|
'//home',
|
|
'/tmp',
|
|
'/opt',
|
|
]) {
|
|
writeFileSync(calls, '');
|
|
const guard = spawnSync(
|
|
'bash',
|
|
['-e', '-o', 'pipefail', '-c', wipe.run],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dir}:${process.env.PATH}`,
|
|
GITHUB_WORKSPACE: bad,
|
|
// The recorder dir doubles as the allowlist root: every bad
|
|
// path sits outside it, so the refusal is the guard's, not
|
|
// a side effect of the fixture layout.
|
|
RUNNER_WORKSPACE: dir,
|
|
},
|
|
},
|
|
);
|
|
expect(
|
|
guard.status,
|
|
`path ${bad || '<empty>'} was not refused`,
|
|
).not.toBe(0);
|
|
expect(
|
|
readFileSync(calls, 'utf8'),
|
|
`rm was invoked for ${bad || '<empty>'}`,
|
|
).toBe('');
|
|
}
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'refuses an allowlist-escaping path reached through an intermediate symlink',
|
|
() => {
|
|
// Pins the canonicalization line. The bad paths above all sit outside
|
|
// the recorder dir, so the allowlist refuses them identically whether
|
|
// canonicalization runs or not, and a raw '..' spelling is refused by
|
|
// the '..' arm first — neither can pin it. The vector that can is a
|
|
// path whose INTERMEDIATE component is a link out of the runner
|
|
// workspace: it matches "$RWS"/* as a string and names a directory
|
|
// outside it.
|
|
//
|
|
// It is deliberately a directory at the far end, not the link itself:
|
|
// a workspace that IS a link is now healed rather than refused
|
|
// (#9480), and this test exists for the refusal, not the heal.
|
|
// Executed mutant (canonicalization line deleted): the raw path
|
|
// passes the allowlist and find, resolving the link through the
|
|
// kernel, hands the outside directory's entries to the rm recorder.
|
|
const dir = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-escape-'));
|
|
const outside = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-outside-'));
|
|
mkdirSync(join(outside, 'sub'));
|
|
writeFileSync(join(outside, 'sub', 'canary'), 'x');
|
|
symlinkSync(outside, join(dir, 'link'));
|
|
try {
|
|
const calls = join(dir, 'rm-calls');
|
|
writeFileSync(calls, '');
|
|
writeFileSync(
|
|
join(dir, 'rm'),
|
|
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexit 0\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const res = spawnSync(
|
|
'bash',
|
|
['-e', '-o', 'pipefail', '-c', wipe.run],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dir}:${process.env.PATH}`,
|
|
GITHUB_WORKSPACE: join(dir, 'link', 'sub'),
|
|
RUNNER_WORKSPACE: dir,
|
|
},
|
|
},
|
|
);
|
|
expect(res.status).not.toBe(0);
|
|
expect(res.stdout + res.stderr).toContain(
|
|
'outside the runner workspace',
|
|
);
|
|
expect(readFileSync(calls, 'utf8')).toBe('');
|
|
expect(readdirSync(join(outside, 'sub'))).toEqual(['canary']);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
// Fronts PATH with a failing realpath so the script must fail closed instead
|
|
// of matching and wiping a raw, potentially misleading spelling.
|
|
const stubRealpath = () => {
|
|
const bin = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-bin-'));
|
|
writeFileSync(join(bin, 'realpath'), '#!/bin/sh\nexit 1\n');
|
|
chmodSync(join(bin, 'realpath'), 0o755);
|
|
return bin;
|
|
};
|
|
|
|
it('refuses to wipe when realpath is absent', () => {
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-rws-'));
|
|
const ws = join(parent, 'repo');
|
|
mkdirSync(ws);
|
|
writeFileSync(join(ws, 'leftover'), 'x');
|
|
const bin = stubRealpath();
|
|
try {
|
|
const res = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', wipe.run], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
GITHUB_WORKSPACE: ws,
|
|
RUNNER_WORKSPACE: `${parent}/`,
|
|
PATH: `${bin}:${process.env.PATH}`,
|
|
},
|
|
});
|
|
expect(res.status).not.toBe(0);
|
|
expect(readdirSync(ws)).toEqual(['leftover']);
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
rmSync(bin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('refuses a trailing-slash GITHUB_WORKSPACE when realpath is absent', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-ws-'));
|
|
const bin = stubRealpath();
|
|
try {
|
|
const calls = join(dir, 'rm-calls');
|
|
writeFileSync(calls, '');
|
|
writeFileSync(
|
|
join(dir, 'rm'),
|
|
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexit 0\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const res = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', wipe.run], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dir}:${bin}:${process.env.PATH}`,
|
|
GITHUB_WORKSPACE: '/home/',
|
|
RUNNER_WORKSPACE: '/home',
|
|
},
|
|
});
|
|
expect(res.status).not.toBe(0);
|
|
expect(readFileSync(calls, 'utf8')).toBe('');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
rmSync(bin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('refuses an allowlist-escaping .. path when realpath is absent', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-fallback-'));
|
|
const outside = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-outside-'));
|
|
const bin = stubRealpath();
|
|
mkdirSync(join(dir, 'sub'));
|
|
try {
|
|
const calls = join(dir, 'rm-calls');
|
|
writeFileSync(calls, '');
|
|
writeFileSync(
|
|
join(dir, 'rm'),
|
|
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexit 0\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const res = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', wipe.run], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dir}:${bin}:${process.env.PATH}`,
|
|
GITHUB_WORKSPACE: `${dir}/sub/../../${basename(outside)}`,
|
|
RUNNER_WORKSPACE: dir,
|
|
},
|
|
});
|
|
expect(res.status).not.toBe(0);
|
|
expect(readFileSync(calls, 'utf8')).toBe('');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
rmSync(outside, { recursive: true, force: true });
|
|
rmSync(bin, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// The degenerate-root arm keeps a stripped-empty RUNNER_WORKSPACE from
|
|
// turning the allowlist pattern into `/*` (which admits every absolute
|
|
// path). The reference suite covers the review workflow; this copy needs
|
|
// its own case — deleting the arm ships green otherwise.
|
|
it('refuses a runner workspace that resolves to / without invoking rm', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-root-'));
|
|
const ws = join(dir, 'repo');
|
|
mkdirSync(ws);
|
|
writeFileSync(join(ws, 'leftover'), 'x');
|
|
try {
|
|
const calls = join(dir, 'rm-calls');
|
|
writeFileSync(calls, '');
|
|
writeFileSync(
|
|
join(dir, 'rm'),
|
|
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexit 0\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const res = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', wipe.run], {
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${dir}:${process.env.PATH}`,
|
|
GITHUB_WORKSPACE: ws,
|
|
RUNNER_WORKSPACE: '/',
|
|
},
|
|
});
|
|
expect(res.status).not.toBe(0);
|
|
expect(readFileSync(calls, 'utf8')).toBe('');
|
|
expect(readdirSync(ws)).toEqual(['leftover']);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// The RWS realpath line has no refusal of its own to observe, so pin it
|
|
// from the happy side: a RUNNER_WORKSPACE spelled with '..' that
|
|
// canonicalizes back to the real parent must still be allowed to wipe.
|
|
// Deleting the RWS realpath line leaves the raw spelling to the '..'
|
|
// arm, which refuses — and this test fails on that mutant.
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'canonicalizes a ..-spelled runner workspace instead of refusing it',
|
|
() => {
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-wipe-rwsdot-'));
|
|
const ws = join(parent, 'repo');
|
|
mkdirSync(ws);
|
|
writeFileSync(join(ws, 'leftover'), 'x');
|
|
try {
|
|
runWipe({
|
|
GITHUB_WORKSPACE: ws,
|
|
RUNNER_WORKSPACE: join(ws, '..'),
|
|
});
|
|
expect(readdirSync(ws)).toEqual([]);
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
// The wedge this heal exists for, and the hole the first attempt at it
|
|
// shipped. Both are exec fixtures: the guard runs for real, and where a
|
|
// regression would delete something, `rm` is a PATH-fronted recorder so
|
|
// the assertion is on the decision and nothing on the machine can be lost.
|
|
const rmRecorder = (dir) => {
|
|
const calls = join(dir, 'rm-calls');
|
|
writeFileSync(calls, '');
|
|
writeFileSync(
|
|
join(dir, 'rm'),
|
|
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexit 0\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
return calls;
|
|
};
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'heals a workspace a previous job replaced with a symlink',
|
|
() => {
|
|
// Without the heal this is a permanent wedge: canonicalization
|
|
// resolves the link to its target, the allowlist refuses, the step
|
|
// exits 1 having removed nothing, and every later job on the runner
|
|
// dies at the same line. The unlink must take the LINK and leave the
|
|
// target alone.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-'));
|
|
const outside = mkdtempSync(join(tmpdir(), 'serve-ab-heal-outside-'));
|
|
const ws = join(parent, 'repo');
|
|
writeFileSync(join(outside, 'canary'), 'x');
|
|
symlinkSync(outside, ws);
|
|
try {
|
|
const out = runWipe({ GITHUB_WORKSPACE: ws, RUNNER_WORKSPACE: parent });
|
|
expect(out).toContain('healing workspace');
|
|
expect(out).toContain(outside);
|
|
expect(lstatSync(ws).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(ws).isDirectory()).toBe(true);
|
|
expect(readdirSync(ws)).toEqual([]);
|
|
// The link was removed, not followed: the target keeps its contents.
|
|
expect(readdirSync(outside)).toEqual(['canary']);
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'refuses to heal through an intermediate symlink, before touching anything',
|
|
() => {
|
|
// The defect the first attempt at this layer kept through three
|
|
// rounds: it matched the RAW path, and a raw `"$RWS"/*` accepts
|
|
// `$RWS/link/sub` as a string while the kernel resolves it to a file
|
|
// outside the runner workspace — so the unlink and the mkdir landed
|
|
// OUTSIDE, and only then did canonicalization and the allowlist
|
|
// refuse the wipe.
|
|
//
|
|
// What this fixture pins is the containment arm: deleting it lets the
|
|
// heal act here. It does NOT discriminate parent-from-self — resolving
|
|
// $WS itself also lands outside and also refuses. The fixture that
|
|
// separates the two is the legitimate heal above, where judging $WS
|
|
// resolves through the link being removed and refuses a repair that
|
|
// must succeed.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-inter-'));
|
|
const outside = mkdtempSync(join(tmpdir(), 'serve-ab-heal-outside-'));
|
|
writeFileSync(join(outside, 'sub'), 'canary');
|
|
symlinkSync(outside, join(parent, 'link'));
|
|
try {
|
|
const calls = rmRecorder(parent);
|
|
const res = spawnSync(
|
|
'bash',
|
|
['-e', '-o', 'pipefail', '-c', wipe.run],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${parent}:${process.env.PATH}`,
|
|
GITHUB_WORKSPACE: join(parent, 'link', 'sub'),
|
|
RUNNER_WORKSPACE: parent,
|
|
},
|
|
},
|
|
);
|
|
expect(res.status).not.toBe(0);
|
|
expect(res.stdout + res.stderr).toContain(
|
|
'refusing to heal workspace outside the runner workspace',
|
|
);
|
|
// Nothing was deleted, and the file at the resolved target is still
|
|
// a file — the mutation the old shape performed before refusing.
|
|
expect(readFileSync(calls, 'utf8')).toBe('');
|
|
expect(lstatSync(join(outside, 'sub')).isFile()).toBe(true);
|
|
expect(readFileSync(join(outside, 'sub'), 'utf8')).toBe('canary');
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'heals a workspace that is not a directory',
|
|
() => {
|
|
// The other half of the predicate: a leftover regular file where the
|
|
// workspace should be wedges the step exactly the same way, and it is
|
|
// the half the first attempt left untested.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-file-'));
|
|
const ws = join(parent, 'repo');
|
|
writeFileSync(ws, 'not a directory');
|
|
try {
|
|
const out = runWipe({ GITHUB_WORKSPACE: ws, RUNNER_WORKSPACE: parent });
|
|
expect(out).toContain('it was not a directory');
|
|
expect(lstatSync(ws).isDirectory()).toBe(true);
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'sees the corruption through a trailing-slash spelling',
|
|
() => {
|
|
// `[ -L "$WS/" ]` is false and `[ ! -d "$WS/" ]` resolves through the
|
|
// link, so without the raw strip ahead of the predicates the heal
|
|
// never fires and the wedge survives one keystroke.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-slash-'));
|
|
const outside = mkdtempSync(join(tmpdir(), 'serve-ab-heal-outside-'));
|
|
const ws = join(parent, 'repo');
|
|
writeFileSync(join(outside, 'canary'), 'x');
|
|
symlinkSync(outside, ws);
|
|
try {
|
|
runWipe({ GITHUB_WORKSPACE: `${ws}/`, RUNNER_WORKSPACE: parent });
|
|
expect(lstatSync(ws).isSymbolicLink()).toBe(false);
|
|
expect(readdirSync(outside)).toEqual(['canary']);
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'leaves an ordinary workspace untouched by the heal',
|
|
() => {
|
|
// The heal must cost nothing on the path every real run takes.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-noop-'));
|
|
const ws = join(parent, 'repo');
|
|
mkdirSync(ws);
|
|
writeFileSync(join(ws, 'leftover'), 'x');
|
|
try {
|
|
const out = runWipe({ GITHUB_WORKSPACE: ws, RUNNER_WORKSPACE: parent });
|
|
expect(out).not.toContain('healing workspace');
|
|
expect(readdirSync(ws)).toEqual([]);
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)(
|
|
'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.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-perm-'));
|
|
const outside = mkdtempSync(join(tmpdir(), 'serve-ab-heal-outside-'));
|
|
const ws = join(parent, 'repo');
|
|
writeFileSync(join(outside, 'canary'), 'x');
|
|
symlinkSync(outside, ws);
|
|
chmodSync(parent, 0o555);
|
|
try {
|
|
const res = spawnSync(
|
|
'bash',
|
|
['-e', '-o', 'pipefail', '-c', wipe.run],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
GITHUB_WORKSPACE: ws,
|
|
RUNNER_WORKSPACE: parent,
|
|
},
|
|
},
|
|
);
|
|
expect(res.status).not.toBe(0);
|
|
expect(res.stdout + res.stderr).toContain('could not remove');
|
|
expect(lstatSync(ws).isSymbolicLink()).toBe(true);
|
|
expect(readdirSync(outside)).toEqual(['canary']);
|
|
} finally {
|
|
chmodSync(parent, 0o755);
|
|
rmSync(parent, { recursive: true, force: true });
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'keeps a forged workflow command in the symlink target out of the log',
|
|
() => {
|
|
// The target is bytes a previous job chose, and the runner parses `::`
|
|
// at the start of ANY stdout line as a workflow command — so a target
|
|
// of $'…\n::error::forged' would forge an annotation from a step that
|
|
// is reporting corruption. The annotation carries no untrusted bytes
|
|
// and the target is printed on a prefixed line with its newlines gone.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-inject-'));
|
|
const outside = mkdtempSync(join(tmpdir(), 'serve-ab-heal-outside-'));
|
|
const ws = join(parent, 'repo');
|
|
symlinkSync(`${outside}\n::error::forged-annotation`, ws);
|
|
try {
|
|
const out = runWipe({ GITHUB_WORKSPACE: ws, RUNNER_WORKSPACE: parent });
|
|
expect(out).toContain('healing workspace');
|
|
// The target is still reported — just never as a command.
|
|
expect(out).toContain('pointed at');
|
|
expect(out).toContain(basename(outside));
|
|
for (const line of out.split('\n')) {
|
|
expect(line.startsWith('::error::')).toBe(false);
|
|
}
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasGnuRealpath)(
|
|
'fails closed when the healed workspace cannot be recreated',
|
|
() => {
|
|
// The mkdir leg's own refusal, reachable without a permission trick:
|
|
// `rm -f` returns 0 for a path whose parent is not a directory (it
|
|
// reads as "already absent"), and the mkdir that follows cannot
|
|
// succeed. Swallowed, the wipe would then run against a path that
|
|
// does not exist.
|
|
const parent = mkdtempSync(join(tmpdir(), 'serve-ab-heal-mkdir-'));
|
|
writeFileSync(join(parent, 'file'), 'not a directory');
|
|
try {
|
|
const res = spawnSync(
|
|
'bash',
|
|
['-e', '-o', 'pipefail', '-c', wipe.run],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
GITHUB_WORKSPACE: join(parent, 'file', 'sub'),
|
|
RUNNER_WORKSPACE: parent,
|
|
},
|
|
},
|
|
);
|
|
expect(res.status).not.toBe(0);
|
|
expect(res.stdout + res.stderr).toContain('could not recreate');
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
});
|