Skip to content

Commit af84d53

Browse files
committed
ci: add build provenance attestation and property-based tests
Signed-Releases (Scorecard 0 -> 10): - Add actions/attest-build-provenance to the release workflow - Generates SLSA provenance for the .vsix artifact - Add id-token:write and attestations:write permissions Fuzzing (Scorecard 0 -> 10): - Add fast-check property-based tests (13 new tests) - Covers parsePatchloomVersion, comparePatchloomVersions, formatError, formatCliOutput, parseManagedInstallChecksumFile - Tests mathematical properties: reflexivity, antisymmetry, monotonicity, type safety on arbitrary input - Scorecard detects fast-check as a JavaScript fuzzing framework Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 8e20298 commit af84d53

4 files changed

Lines changed: 241 additions & 2 deletions

File tree

.github/workflows/release.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ jobs:
4141
timeout-minutes: 15
4242
permissions:
4343
contents: write
44+
id-token: write
45+
attestations: write
4446
env:
4547
VSCE_PAT: ${{ secrets.VSCE_PAT }}
4648
OVSX_PAT: ${{ secrets.OVSX_PAT }}
@@ -55,6 +57,10 @@ jobs:
5557
- uses: ./.github/actions/setup-node
5658
- run: npm run check
5759
- run: npx @vscode/vsce package --out patchloom.vsix
60+
- name: Attest build provenance
61+
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
62+
with:
63+
subject-path: patchloom.vsix
5864
- name: Upload .vsix to GitHub Release
5965
env:
6066
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

package-lock.json

Lines changed: 43 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@
159159
"@types/vscode": "^1.90.0",
160160
"@vscode/test-electron": "^2.5.2",
161161
"@vscode/vsce": "^3.0.0",
162+
"fast-check": "^4.8.0",
162163
"ovsx": "^1.0.0",
163164
"typescript": "^6.0.3",
164165
"vscode-extension-tester": "^8.23.0"

test/unit/propertyBased.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { describe, it } from "node:test";
2+
import * as assert from "node:assert/strict";
3+
import * as fc from "fast-check";
4+
5+
import { parsePatchloomVersion, comparePatchloomVersions } from "../../src/binary/patchloom.js";
6+
import { formatError, formatCliOutput } from "../../src/util.js";
7+
import { parseManagedInstallChecksumFile } from "../../src/install/managed.js";
8+
9+
describe("parsePatchloomVersion property-based tests", () => {
10+
it("never throws on arbitrary input", () => {
11+
fc.assert(
12+
fc.property(fc.string(), (input) => {
13+
const result = parsePatchloomVersion(input);
14+
assert.ok(result === undefined || typeof result === "string");
15+
})
16+
);
17+
});
18+
19+
it("always extracts the version from a well-formed semver string", () => {
20+
fc.assert(
21+
fc.property(
22+
fc.nat({ max: 999 }),
23+
fc.nat({ max: 999 }),
24+
fc.nat({ max: 999 }),
25+
(major, minor, patch) => {
26+
const version = `${major}.${minor}.${patch}`;
27+
const result = parsePatchloomVersion(`patchloom ${version} (abc123)`);
28+
assert.equal(result, version);
29+
}
30+
)
31+
);
32+
});
33+
34+
it("strips the leading v prefix from parsed versions", () => {
35+
fc.assert(
36+
fc.property(
37+
fc.nat({ max: 999 }),
38+
fc.nat({ max: 999 }),
39+
fc.nat({ max: 999 }),
40+
(major, minor, patch) => {
41+
const version = `v${major}.${minor}.${patch}`;
42+
const result = parsePatchloomVersion(version);
43+
assert.ok(result !== undefined && !result.startsWith("v"));
44+
}
45+
)
46+
);
47+
});
48+
});
49+
50+
describe("comparePatchloomVersions property-based tests", () => {
51+
it("is reflexive: compare(a, a) === 0", () => {
52+
fc.assert(
53+
fc.property(
54+
fc.nat({ max: 999 }),
55+
fc.nat({ max: 999 }),
56+
fc.nat({ max: 999 }),
57+
(major, minor, patch) => {
58+
const v = `${major}.${minor}.${patch}`;
59+
assert.equal(comparePatchloomVersions(v, v), 0);
60+
}
61+
)
62+
);
63+
});
64+
65+
it("is antisymmetric: sign(compare(a, b)) === -sign(compare(b, a))", () => {
66+
fc.assert(
67+
fc.property(
68+
fc.nat({ max: 999 }),
69+
fc.nat({ max: 999 }),
70+
fc.nat({ max: 999 }),
71+
fc.nat({ max: 999 }),
72+
fc.nat({ max: 999 }),
73+
fc.nat({ max: 999 }),
74+
(ma, mi, pa, mb, mib, pb) => {
75+
const a = `${ma}.${mi}.${pa}`;
76+
const b = `${mb}.${mib}.${pb}`;
77+
const cmp = comparePatchloomVersions(a, b);
78+
const rev = comparePatchloomVersions(b, a);
79+
assert.equal(Math.sign(cmp), -Math.sign(rev));
80+
}
81+
)
82+
);
83+
});
84+
85+
it("respects numeric ordering for major versions", () => {
86+
fc.assert(
87+
fc.property(
88+
fc.nat({ max: 998 }),
89+
fc.nat({ max: 999 }),
90+
fc.nat({ max: 999 }),
91+
(major, minor, patch) => {
92+
const a = `${major}.${minor}.${patch}`;
93+
const b = `${major + 1}.${minor}.${patch}`;
94+
assert.ok(comparePatchloomVersions(a, b) < 0);
95+
}
96+
)
97+
);
98+
});
99+
});
100+
101+
describe("formatError property-based tests", () => {
102+
it("never throws on arbitrary input", () => {
103+
fc.assert(
104+
fc.property(fc.anything(), (input) => {
105+
const result = formatError(input);
106+
assert.equal(typeof result, "string");
107+
})
108+
);
109+
});
110+
111+
it("returns the message for Error instances with non-empty messages", () => {
112+
fc.assert(
113+
fc.property(
114+
fc.string({ minLength: 1 }),
115+
(msg) => {
116+
assert.equal(formatError(new Error(msg)), msg);
117+
}
118+
)
119+
);
120+
});
121+
});
122+
123+
describe("formatCliOutput property-based tests", () => {
124+
it("always returns a non-empty string", () => {
125+
fc.assert(
126+
fc.property(fc.integer(), fc.string(), fc.string(), (exitCode, stdout, stderr) => {
127+
const result = formatCliOutput({ exitCode, stdout, stderr });
128+
assert.ok(result.length > 0);
129+
})
130+
);
131+
});
132+
133+
it("includes exit code when both streams are whitespace-only", () => {
134+
fc.assert(
135+
fc.property(fc.integer({ min: 0, max: 255 }), (exitCode) => {
136+
const result = formatCliOutput({ exitCode, stdout: " ", stderr: "\n" });
137+
assert.ok(result.includes(`exit code ${exitCode}`));
138+
})
139+
);
140+
});
141+
});
142+
143+
describe("parseManagedInstallChecksumFile property-based tests", () => {
144+
it("returns an array or throws a verification error on arbitrary input", () => {
145+
fc.assert(
146+
fc.property(fc.string(), (input) => {
147+
try {
148+
const result = parseManagedInstallChecksumFile(input);
149+
assert.ok(Array.isArray(result));
150+
} catch (error: unknown) {
151+
assert.ok(error instanceof Error);
152+
assert.ok("reason" in error, "thrown error should have a reason field");
153+
}
154+
})
155+
);
156+
});
157+
158+
it("parses well-formed checksum lines into entries", () => {
159+
const hexChar = fc.mapToConstant(
160+
{ num: 10, build: (v) => String.fromCharCode(48 + v) },
161+
{ num: 6, build: (v) => String.fromCharCode(97 + v) }
162+
);
163+
const hex64 = fc.string({ unit: hexChar, minLength: 64, maxLength: 64 });
164+
fc.assert(
165+
fc.property(
166+
hex64,
167+
fc.stringMatching(/^[a-z][a-z0-9._-]{0,39}$/),
168+
(hash, filename) => {
169+
const content = `${hash} ${filename}`;
170+
const entries = parseManagedInstallChecksumFile(content);
171+
assert.ok(entries.length >= 1);
172+
assert.equal(entries[0].sha256, hash);
173+
assert.equal(entries[0].fileName, filename);
174+
}
175+
)
176+
);
177+
});
178+
179+
it("returns empty array for blank input", () => {
180+
const whitespaceChar = fc.constantFrom(" ", "\t", "\n", "\r");
181+
fc.assert(
182+
fc.property(
183+
fc.string({ unit: whitespaceChar, minLength: 0, maxLength: 50 }),
184+
(whitespace) => {
185+
const result = parseManagedInstallChecksumFile(whitespace);
186+
assert.equal(result.length, 0);
187+
}
188+
)
189+
);
190+
});
191+
});

0 commit comments

Comments
 (0)