Skip to content

Commit 8dfdef5

Browse files
CopilotpelikhanCopilot
authored
Harden workflow YAML and scanner file handling (#67473)
* Initial plan * Harden maintenance YAML and scanner file handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Complete descriptor I/O and evidence hardening Handle partial descriptor reads and writes with shared helpers, reject replaced TLC log artifacts, and bound ledger reads despite concurrent growth. Add focused regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: pelikhan <jhalleux@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Peli de Halleux <pelikhan@users.noreply.github.com>
1 parent e2b215a commit 8dfdef5

11 files changed

Lines changed: 283 additions & 37 deletions

‎.github/scripts/work-queue-formal-check.cjs‎

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,36 @@ function writeJSON(file, value) {
1515
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
1616
}
1717

18+
function readFileDescriptor(fd, expectedPath) {
19+
const { size } = fs.fstatSync(fd);
20+
const buffer = Buffer.alloc(size);
21+
let offset = 0;
22+
while (offset < size) {
23+
const bytesRead = fs.readSync(fd, buffer, offset, size - offset, offset);
24+
if (bytesRead === 0) break;
25+
offset += bytesRead;
26+
}
27+
if (expectedPath !== undefined) {
28+
const original = fs.fstatSync(fd);
29+
const current = fs.lstatSync(expectedPath);
30+
if (!current.isFile() || current.dev !== original.dev || current.ino !== original.ino) {
31+
throw new Error("TLC log path no longer identifies the opened evidence file");
32+
}
33+
}
34+
return buffer.subarray(0, offset).toString("utf8");
35+
}
36+
37+
function writeJSONToFileDescriptor(fd, value) {
38+
const buffer = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
39+
fs.ftruncateSync(fd, 0);
40+
let offset = 0;
41+
while (offset < buffer.length) {
42+
const bytesWritten = fs.writeSync(fd, buffer, offset, buffer.length - offset, offset);
43+
if (bytesWritten === 0) throw new Error("Unable to make progress writing JSON evidence");
44+
offset += bytesWritten;
45+
}
46+
}
47+
1848
function classify(exitCode, signal, timedOut, log) {
1949
if (timedOut) return "timed_out";
2050
if (exitCode === 0 && !signal && log.includes("Model checking completed. No error has been found.") && /(?:^|\n)\d+ states generated, \d+ distinct states found, 0 states left on queue\.(?:\r?\n|$)/.test(log)) return "passed";
@@ -162,8 +192,8 @@ async function runVerification(options) {
162192
fs.writeFileSync(path.join(bundleDir, "java-version.txt"), `${version.stdout || ""}${version.stderr || ""}`);
163193
const logPath = path.join(bundleDir, "tlc.log");
164194
const reservePath = path.join(outputDir, "result-space.reserve");
165-
fs.writeFileSync(reservePath, Buffer.alloc(1024 * 1024));
166-
const fd = fs.openSync(logPath, "w");
195+
fs.writeFileSync(reservePath, Buffer.alloc(1024 * 1024), { flag: "wx", mode: 0o600 });
196+
const fd = fs.openSync(logPath, "wx+", 0o600);
167197
let timedOut = false;
168198
let spawnError = null;
169199
/** @type {NodeJS.Timeout | undefined} */
@@ -191,14 +221,17 @@ async function runVerification(options) {
191221
});
192222
clearTimeout(timer);
193223
clearTimeout(killTimer);
194-
fs.closeSync(fd);
195224
let log;
196225
let checkpoints;
197226
try {
198-
log = fs.readFileSync(logPath, "utf8");
227+
log = readFileDescriptor(fd, logPath);
199228
checkpoints = checkpointBundle(stateDir, bundleDir, options.checkpointMaxBytes ?? CHECKPOINT_MAX_BYTES, log.includes("Checkpointing completed"), env);
200229
} finally {
201-
fs.unlinkSync(reservePath);
230+
try {
231+
fs.closeSync(fd);
232+
} finally {
233+
fs.unlinkSync(reservePath);
234+
}
202235
}
203236
const finished = lastMatch(log, /(\d+) states generated, (\d+) distinct states found, (\d+) states left on queue\./g);
204237
const progress = lastMatch(log, /Progress\((\d+)\).*?: ([\d,]+) states generated.*?, ([\d,]+) distinct states found.*?, ([\d,]+) states left on queue\./g);
@@ -261,12 +294,30 @@ if (require.main === module) {
261294
console.error(`Formal verification collection failed: ${error.message}`);
262295
const outputDir = process.env.RESULTS_DIR || "";
263296
const resultPath = path.join(outputDir, "bundle", "result.json");
264-
if (path.isAbsolute(outputDir) && path.resolve(outputDir) !== path.parse(outputDir).root && fs.existsSync(resultPath)) {
265-
const result = JSON.parse(fs.readFileSync(resultPath, "utf8"));
266-
writeJSON(resultPath, { ...result, status: "tool_error", exhausted: false, error: error.message, finished_at: new Date().toISOString() });
297+
if (path.isAbsolute(outputDir) && path.resolve(outputDir) !== path.parse(outputDir).root) {
298+
let resultFd;
299+
try {
300+
resultFd = fs.openSync(resultPath, "r+");
301+
} catch (openError) {
302+
if (!openError || openError.code !== "ENOENT") throw openError;
303+
}
304+
if (resultFd !== undefined) {
305+
try {
306+
const result = JSON.parse(readFileDescriptor(resultFd));
307+
writeJSONToFileDescriptor(resultFd, {
308+
...result,
309+
status: "tool_error",
310+
exhausted: false,
311+
error: error.message,
312+
finished_at: new Date().toISOString(),
313+
});
314+
} finally {
315+
fs.closeSync(resultFd);
316+
}
317+
}
267318
}
268319
process.exitCode = 1;
269320
});
270321
}
271322

272-
module.exports = { TLC_SHA256, classify, inventory, checkpointBundle, runVerification };
323+
module.exports = { TLC_SHA256, classify, inventory, checkpointBundle, runVerification, readFileDescriptor, writeJSONToFileDescriptor };

‎.github/scripts/work-queue-formal-check.test.cjs‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const path = require("node:path");
77
const crypto = require("node:crypto");
88
const { spawnSync } = require("node:child_process");
99
const { test } = require("node:test");
10-
const { TLC_SHA256, classify, checkpointBundle, runVerification } = require("./work-queue-formal-check.cjs");
10+
const { TLC_SHA256, classify, checkpointBundle, runVerification, readFileDescriptor, writeJSONToFileDescriptor } = require("./work-queue-formal-check.cjs");
1111

1212
const SUCCESS = "Model checking completed. No error has been found.\n42 states generated, 21 distinct states found, 0 states left on queue.\nThe depth of the complete state graph search is 7.\n";
1313
const INVARIANT_FALSE = "Error: The invariant of DAGValidity is equal to FALSE\n";
@@ -47,6 +47,12 @@ if (mode === "passed") {
4747
} else if (mode === "violation") {
4848
console.error("Error: Invariant Safety is violated.");
4949
process.exit(12);
50+
} else if (mode === "replace_log") {
51+
const config = process.argv[process.argv.indexOf("-config") + 1];
52+
const logPath = path.join(path.dirname(config), "tlc.log");
53+
fs.unlinkSync(logPath);
54+
fs.symlinkSync(process.env.FORMAL_TEST_REPLACEMENT_FILE, logPath);
55+
process.stdout.write(${JSON.stringify(SUCCESS)});
5056
} else if (mode === "invariant_false") {
5157
process.stderr.write(${JSON.stringify(INVARIANT_FALSE)});
5258
process.exit(151);
@@ -107,6 +113,48 @@ test("only natural exit plus exhaustion is a pass", () => {
107113
assert.equal(classify(150, null, false, "Error: Parsing failed."), "tool_error");
108114
});
109115

116+
test("log collection rejects replaced evidence without modifying the symlink target", async t => {
117+
const options = fixture(t);
118+
const replacement = path.join(path.dirname(options.javaBin), "replacement.log");
119+
fs.writeFileSync(replacement, "replacement content");
120+
options.env.FORMAL_TEST_MODE = "replace_log";
121+
options.env.FORMAL_TEST_REPLACEMENT_FILE = replacement;
122+
123+
await assert.rejects(runVerification(options), /TLC log path no longer identifies/);
124+
125+
assert.equal(fs.readFileSync(replacement, "utf8"), "replacement content");
126+
assert.equal(fs.existsSync(path.join(options.outputDir, "bundle", "summary.md")), false);
127+
assert.equal(fs.existsSync(path.join(options.outputDir, "result-space.reserve")), false);
128+
});
129+
130+
test("descriptor reads consume short reads until the snapshotted size or EOF", t => {
131+
const options = fixture(t);
132+
const fd = fs.openSync(options.jar, "r+");
133+
t.after(() => fs.closeSync(fd));
134+
const readSync = fs.readSync;
135+
t.mock.method(fs, "readSync", (file, buffer, offset, length, position) => readSync(file, buffer, offset, Math.min(length, 3), position));
136+
assert.equal(readFileDescriptor(fd), "fixture, not a real TLC jar");
137+
let size = 100;
138+
t.mock.method(fs, "fstatSync", () => ({ size }));
139+
assert.equal(readFileDescriptor(fd), "fixture, not a real TLC jar");
140+
size = 0;
141+
assert.equal(readFileDescriptor(fd), "");
142+
});
143+
144+
test("descriptor JSON writes consume short writes and overwrite from position zero", t => {
145+
const options = fixture(t);
146+
const fd = fs.openSync(options.jar, "r+");
147+
t.after(() => fs.closeSync(fd));
148+
const writeSync = fs.writeSync;
149+
t.mock.method(fs, "writeSync", (file, buffer, offset, length, position) => writeSync(file, buffer, offset, Math.min(length, 3), position));
150+
for (const value of [{ status: "running", message: "Unicode \u00e9" }, { status: "passed" }]) {
151+
writeJSONToFileDescriptor(fd, value);
152+
assert.equal(readFileDescriptor(fd), `${JSON.stringify(value, null, 2)}\n`);
153+
}
154+
fs.writeSync.mock.mockImplementation(() => 0);
155+
assert.throws(() => writeJSONToFileDescriptor(fd, {}), /Unable to make progress/);
156+
});
157+
110158
for (const { config, moduleName } of CONFIGS) {
111159
test(`${config}: successful run persists machine-readable evidence and exact sources`, async t => {
112160
const options = fixture(t, config);

‎pkg/workflow/maintenance_workflow_generation_fixes_test.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,33 @@ func TestMaintenanceWorkflowJobMetadata(t *testing.T) {
5656
}
5757
}
5858

59+
func TestMaintenanceWorkflowQuotesCompileGitHubToken(t *testing.T) {
60+
token := "${{ secrets.MAINTENANCE_TOKEN }}: 'quoted' # comment \\\n injected: true"
61+
generated, err := buildMaintenanceWorkflowYAML(context.Background(), buildMaintenanceWorkflowYAMLOptions{
62+
cronSchedule: "37 0 * * *", scheduleDesc: "Daily", runsOnValue: "ubuntu-slim",
63+
actionMode: ActionModeDev, version: "dev", defaultBranch: "main",
64+
compileGitHubToken: token,
65+
})
66+
require.NoError(t, err)
67+
68+
var doc struct {
69+
Jobs map[string]struct {
70+
Steps []struct {
71+
Env map[string]string `yaml:"env"`
72+
} `yaml:"steps"`
73+
} `yaml:"jobs"`
74+
}
75+
require.NoError(t, yamlv3.Unmarshal([]byte(generated), &doc))
76+
var got string
77+
for _, step := range doc.Jobs["compile-workflows"].Steps {
78+
if value, ok := step.Env["GH_AW_MAINTENANCE_GITHUB_TOKEN"]; ok {
79+
got = value
80+
break
81+
}
82+
}
83+
require.Equal(t, token, got)
84+
}
85+
5986
func TestMaintenanceWorkflowDisabledManualJobsAcceptedByConfig(t *testing.T) {
6087
for _, job := range []string{
6188
"run_operation", "cleanup-cache-memory", "update_pull_request_branches",

‎pkg/workflow/maintenance_workflow_triggers_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ func TestGenerateMaintenanceWorkflow_PushTrigger(t *testing.T) {
233233
if strings.Contains(jobSection, "contents: write") {
234234
t.Errorf("compile-workflows should not request contents: write in PR mode, got:\n%s", jobSection)
235235
}
236-
if !strings.Contains(yaml, "GH_AW_MAINTENANCE_GITHUB_TOKEN: ${{ secrets.MAINTENANCE_TOKEN }}") {
236+
if !strings.Contains(yaml, `GH_AW_MAINTENANCE_GITHUB_TOKEN: "${{ secrets.MAINTENANCE_TOKEN }}"`) {
237237
t.Errorf("workflow should use configured maintenance github token secret, got:\n%s", yaml)
238238
}
239239
if !strings.Contains(yaml, "github-token: ${{ env.GH_AW_MAINTENANCE_GITHUB_TOKEN }}") {

‎pkg/workflow/maintenance_workflow_yaml_jobs.go‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -651,9 +651,8 @@ func writeMaintenanceCompileWorkflowsTokenStep(b *strings.Builder, opts buildMai
651651
uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + `
652652
`)
653653
if opts.compileGitHubToken != "" {
654-
b.WriteString(` env:
655-
GH_AW_MAINTENANCE_GITHUB_TOKEN: ` + opts.compileGitHubToken + `
656-
`)
654+
b.WriteString(" env:\n")
655+
writeYAMLEnv(b, " ", "GH_AW_MAINTENANCE_GITHUB_TOKEN", opts.compileGitHubToken)
657656
}
658657
b.WriteString(` with:
659658
`)

‎specs/eslint-factory/trace.mjs‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,14 @@ export function validateTrace(trace, entry) {
193193

194194
export function readableTrace(trace, entry) {
195195
const lines = [`# ${entry.name}`, "", `Expected TLC diagnostic: \`${entry.expected}\`.`, "", "| State | Transition | Original | Phase | Proposed / applied | Readback | Scan / flag | Quality |", "|---|---|---|---|---|---|---|---|"];
196-
for (const { number, action, state: s } of trace)
197-
lines.push(
198-
`| ${number} | ${action.replace(/\|/g, "\\|")} | ${s.original.join(",") || "none"} | ${s.phase.join(", ")} | ${s.proposed.join(",")} / ${s.applied.join(",")} | ${s.verified.join(",")} | ${s.scan} / ${s.lintClean} | ${s.quality} |`
199-
);
196+
const escapeCell = value =>
197+
String(value)
198+
.replaceAll("\\", "\\\\")
199+
.replaceAll("|", "\\|")
200+
.replace(/\r\n|\n|\r/g, "<br>");
201+
for (const { number, action, state: s } of trace) {
202+
const cells = [number, action, s.original.join(",") || "none", s.phase.join(", "), `${s.proposed.join(",")} / ${s.applied.join(",")}`, s.verified.join(","), `${s.scan} / ${s.lintClean}`, s.quality];
203+
lines.push(`| ${cells.map(escapeCell).join(" | ")} |`);
204+
}
200205
return lines.join("\n") + "\n";
201206
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { readableTrace } from "./trace.mjs";
4+
5+
test("readable traces escape every cell delimiter and line break", () => {
6+
const trace = [
7+
{
8+
number: 1,
9+
action: "Transition | one\nsecond line",
10+
state: {
11+
original: ["item|one"],
12+
phase: ["scan"],
13+
proposed: ["candidate"],
14+
applied: ["candidate"],
15+
verified: ["readback"],
16+
scan: "clean",
17+
lintClean: ["clean"],
18+
quality: "good\\value",
19+
},
20+
},
21+
];
22+
23+
const output = readableTrace(trace, { name: "fixture", expected: "NeverWitness" });
24+
25+
assert.match(output, /Transition \\| one<br>second line/);
26+
assert.match(output, /item\\\|one/);
27+
assert.match(output, /good\\\\value/);
28+
assert.equal(output.split("\n").filter(line => line.startsWith("| 1 |")).length, 1);
29+
});

‎specs/work-queue/compare-evaluation.mjs‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import fs from "node:fs";
55
import path from "node:path";
66
import { spawnSync } from "node:child_process";
77
import { fileURLToPath } from "node:url";
8-
import { TLC_SHA256, classify } from "../../.github/scripts/work-queue-formal-check.cjs";
8+
import { TLC_SHA256, classify, readFileDescriptor, writeJSONToFileDescriptor } from "../../.github/scripts/work-queue-formal-check.cjs";
99

1010
const root = path.dirname(fileURLToPath(import.meta.url));
1111
const args = process.argv.slice(2);
@@ -19,7 +19,7 @@ const hash = value => crypto.createHash("sha256").update(value).digest("hex");
1919
assert.equal(hash(fs.readFileSync(jar)), TLC_SHA256, "unexpected TLC jar checksum");
2020
assert.notEqual(output, path.parse(output).root, "results must not be the filesystem root");
2121
fs.mkdirSync(output, { recursive: true });
22-
assert(!fs.existsSync(path.join(output, "comparison.json")), "do not overwrite previous comparison evidence");
22+
const comparisonFd = fs.openSync(path.join(output, "comparison.json"), "wx", 0o600);
2323
const cases = [
2424
["WorkQueue", "Recovery"],
2525
["FairWorkQueue", "FairBatch"],
@@ -118,10 +118,14 @@ WorkResubmissionNoOp == Original!WorkResubmissionNoOp /\\ Revised!WorkResubmissi
118118
const command = ["-XX:+UseParallelGC", "-Xmx1g", "-cp", jar, "tlc2.TLC", "-workers", "2", "-seed", "1", "-fp", "0", "-config", "Comparison.cfg", "-metadir", "state", "Comparison.tla"];
119119
const started = Date.now();
120120
const logPath = path.join(directory, "tlc.log");
121-
const fd = fs.openSync(logPath, "w");
121+
const fd = fs.openSync(logPath, "wx+", 0o600);
122122
const checked = spawnSync(java, command, { cwd: directory, timeout: 900_000, killSignal: "SIGINT", stdio: ["ignore", fd, fd] });
123-
fs.closeSync(fd);
124-
const log = fs.readFileSync(logPath, "utf8");
123+
let log;
124+
try {
125+
log = readFileDescriptor(fd, logPath);
126+
} finally {
127+
fs.closeSync(fd);
128+
}
125129
const diagnostic = mutation === "missing-transition" ? "Action property NextEquivalence is violated" : mutation === "changed-normalization" ? "Invariant ReplayEquivalence is violated" : null;
126130
const expectedExit = mutation === "missing-transition" ? 13 : mutation ? 12 : 0;
127131
const status = classify(checked.status, checked.signal, checked.error?.code === "ETIMEDOUT", log);
@@ -148,10 +152,8 @@ WorkResubmissionNoOp == Original!WorkResubmissionNoOp /\\ Revised!WorkResubmissi
148152
};
149153
comparisons.push(result);
150154
const complete = comparisons.length === cases.length;
151-
fs.writeFileSync(
152-
path.join(output, "comparison.json"),
153-
`${JSON.stringify({ complete, passed: complete && comparisons.every(c => c.passed), java_version: javaVersion.stderr, runner_sha256: hash(fs.readFileSync(fileURLToPath(import.meta.url))), comparisons }, null, 2)}\n`
154-
);
155+
writeJSONToFileDescriptor(comparisonFd, { complete, passed: complete && comparisons.every(c => c.passed), java_version: javaVersion.stderr, runner_sha256: hash(fs.readFileSync(fileURLToPath(import.meta.url))), comparisons });
155156
console.log(`${name}: ${result.passed ? "expected result" : "FAILED"} (${result.elapsed_seconds}s)`);
156157
assert(result.passed, `comparison failed; inspect ${logPath}`);
157158
}
159+
fs.closeSync(comparisonFd);

‎specs/work-queue/native_probe.cjs‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,27 @@ const scheduler = require("../../actions/setup/js/work_queue_scheduler.cjs");
1111
const policy = require("../../actions/setup/js/work_queue_policy.cjs");
1212
const limits = require("../../actions/setup/js/work_queue_limits.cjs");
1313

14+
function readLedgerFile(file) {
15+
const maxBytes = 80 * 1024 * 1024;
16+
const fd = fs.openSync(file, "r");
17+
try {
18+
if (fs.fstatSync(fd).size > maxBytes) throw new Error("resource_limit: adapter input exceeds 80 MiB");
19+
const chunks = [];
20+
let total = 0;
21+
while (total <= maxBytes) {
22+
const buffer = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1 - total));
23+
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, total);
24+
if (bytesRead === 0) break;
25+
total += bytesRead;
26+
if (total > maxBytes) throw new Error("resource_limit: adapter input exceeds 80 MiB");
27+
chunks.push(buffer.subarray(0, bytesRead));
28+
}
29+
return Buffer.concat(chunks, total).toString("utf8");
30+
} finally {
31+
fs.closeSync(fd);
32+
}
33+
}
34+
1435
function execute(input) {
1536
if (input.action === "typed_number_literal") {
1637
if (typeof input.literal !== "string" || !/^(?:NaN|[+-]Infinity|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)$/.test(input.literal)) {
@@ -63,8 +84,7 @@ function execute(input) {
6384
}
6485
let data = input.data;
6586
if (input.ledger_file) {
66-
if (fs.statSync(input.ledger_file).size > 80 * 1024 * 1024) throw new Error("resource_limit: adapter input exceeds 80 MiB");
67-
data = fs.readFileSync(input.ledger_file, "utf8");
87+
data = readLedgerFile(input.ledger_file);
6888
}
6989
let start = performance.now();
7090
const commits = queue.parseTransactionLog(data);
@@ -104,11 +124,15 @@ function execute(input) {
104124
return result;
105125
}
106126

107-
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
108-
lines.on("line", line => {
109-
try {
110-
process.stdout.write(`${JSON.stringify(execute(JSON.parse(line)))}\n`);
111-
} catch (error) {
112-
process.stdout.write(`${JSON.stringify({ error: error.message })}\n`);
113-
}
114-
});
127+
if (require.main === module) {
128+
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
129+
lines.on("line", line => {
130+
try {
131+
process.stdout.write(`${JSON.stringify(execute(JSON.parse(line)))}\n`);
132+
} catch (error) {
133+
process.stdout.write(`${JSON.stringify({ error: error.message })}\n`);
134+
}
135+
});
136+
}
137+
138+
module.exports = { readLedgerFile };

0 commit comments

Comments
 (0)