mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 23:25:28 +00:00
* fix(ci): heal a symlinked workspace instead of wedging the runner on it The hardened wipe guard refuses any workspace that canonicalizes outside the runner workspace. That refusal is correct, and it created a permanent failure: when a previous job leaves the workspace replaced by a symlink pointing outside — or by any non-directory — the guard resolves it to the target, refuses, and exits 1 having removed nothing. Nothing else clears that state, so every later job on the runner dies at the same line, forever. The pre-guard code wiped through the link and self-healed by accident. Reproduced against main's own step text before this change. Heal it: the link itself lives inside the runner workspace and is safe to unlink, and only once it is gone can a legitimate wipe proceed. The layer has to sit before canonicalization — afterwards the path has already resolved to the target and the allowlist refuses before any repair can happen — which means it judges a raw path, and that is where the first attempt at this (closed with #9369) went wrong. A raw `"$RWS"/*` match accepts `$RWS/link/sub` as a string while the kernel resolves it through an intermediate symlink to a file outside the runner workspace, so the unlink and the mkdir landed outside and only then did the allowlist refuse the wipe. Here the containment is judged on the canonicalized PARENT — never on $WS, which would resolve through the very link being removed — and the unlink then acts on the raw path, so it takes the link and never follows it. Four more constraints the same review surfaced: the raw trailing-slash strip moves ahead of the predicates (both `[ -L "$WS/" ]` and `[ ! -d "$WS/" ]` resolve through a link and report its target, so one slash hides the corruption); the allowlist root is prepared before the heal, since it bounds it, and an empty $RUNNER_WORKSPACE would degenerate the containment pattern to the match-all `/*`; both the unlink and the mkdir fail closed, because under `-e` a failure that is not the last command of an && list is swallowed and would leave the wipe running on a corrupt path; and the heal logs what it found and where the link pointed, since this incident otherwise leaves no trace at all. All three copies get it — the two triage wipes and the A/B wipe — with per-suite fixtures: the wedge healed (link gone, directory recreated, target's contents intact), the intermediate-symlink attack refused with the outside file unmutated and zero rm calls, the non-directory half, the trailing-slash spelling, the fail-closed unlink, and the ordinary workspace where the heal must not fire at all. Mutation-checked layer by layer; each has a fixture that fails when it is removed. One pre-existing test changes meaning: the canonicalization pin used a symlinked workspace and asserted refusal, which is now the healed path. It moves to a vector the heal does not touch — an intermediate symlink whose far end is a directory — and keeps its mutation strength: with the canonicalization deleted, find resolves the link and hands the outside directory's entries to the rm recorder. Closes #9480 * fix(ci): keep the heal's log out of the workflow-command channel Three findings from the first review round on this layer. The heal logged the symlink's target inside a `:⚠️:` line. The target is bytes a PREVIOUS job chose — on the verify lane that job may have run a contributor's code — and the runner parses `::` at the start of any stdout line as a workflow command, so a target of $'…\n::error::forged' let the step reporting the corruption forge an annotation. The annotation now carries no untrusted bytes: the target is stripped of line breaks, capped, and printed on its own prefixed line, where a leading `::` cannot begin a command. Verified against the real step text — the forged line lands as data, and no output line starts with `::error::`. The mkdir leg's refusal had no executed fixture while its `rm -f` sibling had one. It does not need 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 — so the branch is reachable, and a swallowed failure there would run the wipe against a path that does not exist. Fixtures in both suites, and it runs as root too. And the post-run triage copy's header still said this copy "predates the checkout-heal hardening and never received it" while carrying the whole guard plus the heal directly underneath. That header is the in-code inventory the eventual convergence of these copies will read; understating it is how a sync strips layers in the wrong direction. * test(ci): drive both wipe copies in the remaining single-step heal fixtures * fix(ci): keep the Serve A/B job from timing out on slow runners --------- Co-authored-by: Qwen Autofix <autofix@qwen-code.dev> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
683 lines
27 KiB
JavaScript
683 lines
27 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import {
|
|
chmodSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { basename, 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 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 = {}) =>
|
|
execFileSync('bash', ['-e', '-o', 'pipefail', '-c', wipe.run], {
|
|
encoding: 'utf8',
|
|
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 healthy run lands near twenty minutes, so the
|
|
// old 30-minute bound left a slow runner no headroom and the run timed
|
|
// out as CANCELLED. Pin the floor — dropping it back re-cancels the run.
|
|
it('keeps a job timeout with headroom for two full build cycles', () => {
|
|
expect(job['timeout-minutes']).toBeGreaterThanOrEqual(45);
|
|
});
|
|
|
|
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 wipe stays 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` would silently void that.
|
|
expect(wipe.run).not.toContain('|| true');
|
|
});
|
|
|
|
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 inside the runner workspace',
|
|
() => {
|
|
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');
|
|
try {
|
|
runWipe({ GITHUB_WORKSPACE: ws, RUNNER_WORKSPACE: parent });
|
|
expect(readdirSync(ws)).toEqual([]);
|
|
// 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 });
|
|
}
|
|
},
|
|
);
|
|
|
|
// 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 });
|
|
}
|
|
},
|
|
);
|
|
});
|