fallow 3.36.0 → 3.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/capabilities.json +21 -7
- package/issue-registry.json +8 -4
- package/package.json +14 -11
- package/rule-pack-schema.json +264 -0
- package/schema.json +7 -0
- package/skills/fallow/SKILL.md +2 -2
- package/skills/fallow/references/cli-reference.md +11 -10
- package/skills/fallow/references/gotchas.md +2 -0
- package/types/output-contract.d.ts +59 -11
- package/scripts/lazy-verify.test.js +0 -564
- package/scripts/platform-package.test.js +0 -26
- package/scripts/run-binary.test.js +0 -293
- package/scripts/sentinel-path.test.js +0 -240
- package/scripts/verify-binary.test.js +0 -809
|
@@ -3557,6 +3557,14 @@ total_issues: number
|
|
|
3557
3557
|
* Prop chains whose saved effective severity explicitly enforces error policy.
|
|
3558
3558
|
*/
|
|
3559
3559
|
prop_drilling_chains?: number
|
|
3560
|
+
/**
|
|
3561
|
+
* Thin wrappers whose saved effective severity explicitly enforces error policy.
|
|
3562
|
+
*/
|
|
3563
|
+
thin_wrappers?: number
|
|
3564
|
+
/**
|
|
3565
|
+
* Duplicate prop shapes whose saved effective severity explicitly enforces error policy.
|
|
3566
|
+
*/
|
|
3567
|
+
duplicate_prop_shapes?: number
|
|
3560
3568
|
/**
|
|
3561
3569
|
* Unused source files.
|
|
3562
3570
|
*/
|
|
@@ -6948,11 +6956,11 @@ actions: IssueAction[]
|
|
|
6948
6956
|
*/
|
|
6949
6957
|
introduced?: (AuditIntroduced | null)
|
|
6950
6958
|
/**
|
|
6951
|
-
* Rule severity of this finding.
|
|
6952
|
-
*
|
|
6953
|
-
*
|
|
6954
|
-
*
|
|
6955
|
-
*
|
|
6959
|
+
* Rule severity of this finding. Explicit errors enforce analysis gates;
|
|
6960
|
+
* warnings stay advisory, including under strict flags. Saved reports
|
|
6961
|
+
* retain this value for CI levels without the original config. Absent
|
|
6962
|
+
* in output from older versions. Not part of finding identity, baseline
|
|
6963
|
+
* keys or fingerprints.
|
|
6956
6964
|
*/
|
|
6957
6965
|
effective_severity?: (EffectiveSeverity | null)
|
|
6958
6966
|
}
|
|
@@ -7014,11 +7022,11 @@ actions: IssueAction[]
|
|
|
7014
7022
|
*/
|
|
7015
7023
|
introduced?: (AuditIntroduced | null)
|
|
7016
7024
|
/**
|
|
7017
|
-
* Rule severity of this finding.
|
|
7018
|
-
*
|
|
7019
|
-
*
|
|
7020
|
-
*
|
|
7021
|
-
*
|
|
7025
|
+
* Rule severity of this finding. Explicit errors enforce analysis gates;
|
|
7026
|
+
* warnings stay advisory, including under strict flags. Saved reports
|
|
7027
|
+
* retain this value for CI levels without the original config. Absent
|
|
7028
|
+
* in output from older versions. Not part of finding identity, baseline
|
|
7029
|
+
* keys or fingerprints.
|
|
7022
7030
|
*/
|
|
7023
7031
|
effective_severity?: (EffectiveSeverity | null)
|
|
7024
7032
|
}
|
|
@@ -19914,6 +19922,10 @@ runner?: (MutationRunnerOutput | null)
|
|
|
19914
19922
|
* started.
|
|
19915
19923
|
*/
|
|
19916
19924
|
cache?: (MutationCacheOutput | null)
|
|
19925
|
+
/**
|
|
19926
|
+
* The resume facts. Absent without `--resume`.
|
|
19927
|
+
*/
|
|
19928
|
+
resume?: (MutationResumeOutput | null)
|
|
19917
19929
|
summary: MutationSummary
|
|
19918
19930
|
/**
|
|
19919
19931
|
* Each mutant, ranked mutants first.
|
|
@@ -20048,6 +20060,36 @@ stored: number
|
|
|
20048
20060
|
*/
|
|
20049
20061
|
not_keyed: string[]
|
|
20050
20062
|
}
|
|
20063
|
+
/**
|
|
20064
|
+
* The resume facts of a `--resume` run.
|
|
20065
|
+
*/
|
|
20066
|
+
export interface MutationResumeOutput {
|
|
20067
|
+
/**
|
|
20068
|
+
* The path of the outcome journal, relative to the project root. When
|
|
20069
|
+
* the cache directory is outside the root, the path is absolute: the
|
|
20070
|
+
* report has no other cache path, so no other rule applies. The file
|
|
20071
|
+
* stays only when the run did not complete. A run with
|
|
20072
|
+
* `completion.status: complete` removes it.
|
|
20073
|
+
*/
|
|
20074
|
+
journal: string
|
|
20075
|
+
/**
|
|
20076
|
+
* The mutants whose status came from the journal. They did not run.
|
|
20077
|
+
*/
|
|
20078
|
+
reused: number
|
|
20079
|
+
/**
|
|
20080
|
+
* The journal results whose key does not match this run, because an
|
|
20081
|
+
* input of the mutant changed. These results were not reused from the
|
|
20082
|
+
* journal. The mutant ran again, or its status came from the result
|
|
20083
|
+
* cache.
|
|
20084
|
+
*/
|
|
20085
|
+
rejected: number
|
|
20086
|
+
/**
|
|
20087
|
+
* Why the run did not read a journal and started from the beginning,
|
|
20088
|
+
* for example because no journal exists. Absent when the run read the
|
|
20089
|
+
* journal.
|
|
20090
|
+
*/
|
|
20091
|
+
note?: (string | null)
|
|
20092
|
+
}
|
|
20051
20093
|
/**
|
|
20052
20094
|
* The totals of a run.
|
|
20053
20095
|
*/
|
|
@@ -20173,6 +20215,11 @@ attempts?: MutantAttempt[]
|
|
|
20173
20215
|
* The mutant did not run in this run. `--no-cache` runs each mutant.
|
|
20174
20216
|
*/
|
|
20175
20217
|
cached?: boolean
|
|
20218
|
+
/**
|
|
20219
|
+
* True when the status comes from the journal of an interrupted run
|
|
20220
|
+
* (`--resume`). The mutant did not run in this run.
|
|
20221
|
+
*/
|
|
20222
|
+
resumed?: boolean
|
|
20176
20223
|
/**
|
|
20177
20224
|
* The TypeScript diagnostic codes of the new errors of a
|
|
20178
20225
|
* `compile_error` mutant, sorted, at most 8.
|
|
@@ -20355,7 +20402,8 @@ changed_paths: string[]
|
|
|
20355
20402
|
*/
|
|
20356
20403
|
export interface MutationRunIssue {
|
|
20357
20404
|
/**
|
|
20358
|
-
* A stable kebab-case code: `
|
|
20405
|
+
* A stable kebab-case code: `cache-write-failed`,
|
|
20406
|
+
* `journal-write-failed`, `runtime-error`, `static-map-miss`,
|
|
20359
20407
|
* `test-files-not-discovered`, `typecheck-failed`, `worktree-changed`
|
|
20360
20408
|
* or `worktree-check-failed`.
|
|
20361
20409
|
*/
|
|
@@ -1,564 +0,0 @@
|
|
|
1
|
-
const test = require("node:test");
|
|
2
|
-
const assert = require("node:assert/strict");
|
|
3
|
-
const crypto = require("node:crypto");
|
|
4
|
-
const fs = require("node:fs");
|
|
5
|
-
const os = require("node:os");
|
|
6
|
-
const path = require("node:path");
|
|
7
|
-
|
|
8
|
-
const { ensureVerified, SENTINEL_SCHEMA_VERSION, _resetWarningState } = require("./lazy-verify");
|
|
9
|
-
const { SENTINEL_FILENAME } = require("./sentinel-path");
|
|
10
|
-
const { _verifyWithKey, binaryTargetsForPlatform, SKIP_ENV } = require("./verify-binary");
|
|
11
|
-
|
|
12
|
-
// ---- shared fixtures ------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
const DEFAULT_PLATFORM = "linux";
|
|
15
|
-
const DEFAULT_PACKAGE_NAME = "@fallow-cli/test-platform";
|
|
16
|
-
|
|
17
|
-
function makeKeypair() {
|
|
18
|
-
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
|
19
|
-
const spki = publicKey.export({ format: "der", type: "spki" });
|
|
20
|
-
const rawPub = spki.subarray(spki.length - 32);
|
|
21
|
-
return { privateKey, rawPub };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function packageNameForPlatform(platform) {
|
|
25
|
-
return platform === "win32" ? "@fallow-cli/win32-x64-msvc" : DEFAULT_PACKAGE_NAME;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function binaryNames(platform) {
|
|
29
|
-
const ext = platform === "win32" ? ".exe" : "";
|
|
30
|
-
return [`fallow${ext}`, `fallow-similar-code${ext}`];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function computeDigestsForDir(dir, platform) {
|
|
34
|
-
const out = {};
|
|
35
|
-
for (const base of binaryNames(platform)) {
|
|
36
|
-
const full = path.join(dir, base);
|
|
37
|
-
out[base] = "sha256:" + crypto.createHash("sha256").update(fs.readFileSync(full)).digest("hex");
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function mkPlatformDir(privateKey, options) {
|
|
43
|
-
const opts = options || {};
|
|
44
|
-
const platform = opts.platform || DEFAULT_PLATFORM;
|
|
45
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-test-"));
|
|
46
|
-
for (const base of binaryNames(platform)) {
|
|
47
|
-
const binaryPath = path.join(dir, base);
|
|
48
|
-
const content = Buffer.from(`mock ${base}`);
|
|
49
|
-
fs.writeFileSync(binaryPath, content);
|
|
50
|
-
if (opts.skipSigFor === base) continue;
|
|
51
|
-
const sig = crypto.sign(null, content, privateKey);
|
|
52
|
-
if (opts.corruptSigFor === base) sig[0] ^= 0xff;
|
|
53
|
-
fs.writeFileSync(`${binaryPath}.sig`, sig);
|
|
54
|
-
}
|
|
55
|
-
fs.writeFileSync(
|
|
56
|
-
path.join(dir, "package.json"),
|
|
57
|
-
JSON.stringify({
|
|
58
|
-
name: opts.packageName || packageNameForPlatform(platform),
|
|
59
|
-
version: opts.version || "2.81.0",
|
|
60
|
-
fallowDigests: opts.skipDigests ? undefined : computeDigestsForDir(dir, platform),
|
|
61
|
-
}),
|
|
62
|
-
);
|
|
63
|
-
return dir;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function cleanup(dir) {
|
|
67
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function captureStderr(t) {
|
|
71
|
-
const lines = [];
|
|
72
|
-
// lazy-verify writes diagnostics to fd 2 with fs.writeSync (issue #3276).
|
|
73
|
-
const original = fs.writeSync;
|
|
74
|
-
fs.writeSync = (fd, chunk, ...rest) => {
|
|
75
|
-
if (fd !== 2) return original(fd, chunk, ...rest);
|
|
76
|
-
lines.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
77
|
-
return chunk.length;
|
|
78
|
-
};
|
|
79
|
-
t.after(() => {
|
|
80
|
-
fs.writeSync = original;
|
|
81
|
-
});
|
|
82
|
-
return { lines };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function setupCacheRoot(t) {
|
|
86
|
-
const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-lazy-cache-"));
|
|
87
|
-
t.after(() => cleanup(cacheRoot));
|
|
88
|
-
return cacheRoot;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function baseInput(dir, verifyFn, extras) {
|
|
92
|
-
const manifestPath = path.join(dir, "package.json");
|
|
93
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
94
|
-
return {
|
|
95
|
-
platformPkgDir: dir,
|
|
96
|
-
packageName: manifest.name,
|
|
97
|
-
manifestPath,
|
|
98
|
-
verifyFn,
|
|
99
|
-
env: {},
|
|
100
|
-
platform: DEFAULT_PLATFORM,
|
|
101
|
-
...extras,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ---- happy path: cache miss then cache hit --------------------------------
|
|
106
|
-
|
|
107
|
-
test("ensureVerified verifies on cache miss and writes the sentinel", (t) => {
|
|
108
|
-
_resetWarningState();
|
|
109
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
110
|
-
const dir = mkPlatformDir(privateKey);
|
|
111
|
-
t.after(() => cleanup(dir));
|
|
112
|
-
|
|
113
|
-
const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
114
|
-
assert.equal(result.ok, true);
|
|
115
|
-
assert.equal(result.cached, false);
|
|
116
|
-
assert.equal(result.sentinelPath, path.join(dir, SENTINEL_FILENAME));
|
|
117
|
-
|
|
118
|
-
// Sentinel file exists and validates
|
|
119
|
-
const sentinel = JSON.parse(fs.readFileSync(result.sentinelPath, "utf8"));
|
|
120
|
-
assert.equal(sentinel.schemaVersion, SENTINEL_SCHEMA_VERSION);
|
|
121
|
-
assert.equal(sentinel.packageVersion, "2.81.0");
|
|
122
|
-
assert.equal(sentinel.packageName, "@fallow-cli/test-platform");
|
|
123
|
-
assert.equal(Object.keys(sentinel.binaries).length, 2);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("ensureVerified verifies and caches a win32 executable on any host", (t) => {
|
|
127
|
-
_resetWarningState();
|
|
128
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
129
|
-
const dir = mkPlatformDir(privateKey, { platform: "win32" });
|
|
130
|
-
t.after(() => cleanup(dir));
|
|
131
|
-
const input = baseInput(dir, (binaryPath) => _verifyWithKey(binaryPath, rawPub), {
|
|
132
|
-
platform: "win32",
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
const verified = ensureVerified(input);
|
|
136
|
-
assert.equal(verified.ok, true);
|
|
137
|
-
assert.equal(verified.cached, false);
|
|
138
|
-
const sentinel = JSON.parse(fs.readFileSync(verified.sentinelPath, "utf8"));
|
|
139
|
-
assert.deepEqual(Object.keys(sentinel.binaries), ["fallow.exe", "fallow-similar-code.exe"]);
|
|
140
|
-
|
|
141
|
-
const cached = ensureVerified({
|
|
142
|
-
...input,
|
|
143
|
-
verifyFn: () => assert.fail("signature verification must not run on a cache hit"),
|
|
144
|
-
});
|
|
145
|
-
assert.equal(cached.ok, true);
|
|
146
|
-
assert.equal(cached.cached, true);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("sentinel records the same binaries that verify-binary verifies", (t) => {
|
|
150
|
-
const cases = [
|
|
151
|
-
{ platform: "linux", platformId: "linux-x64-gnu" },
|
|
152
|
-
{ platform: "win32", platformId: "win32-x64-msvc" },
|
|
153
|
-
];
|
|
154
|
-
for (const { platform, platformId } of cases) {
|
|
155
|
-
_resetWarningState();
|
|
156
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
157
|
-
const dir = mkPlatformDir(privateKey, { platform });
|
|
158
|
-
t.after(() => cleanup(dir));
|
|
159
|
-
const input = baseInput(dir, (binaryPath) => _verifyWithKey(binaryPath, rawPub), {
|
|
160
|
-
platform,
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
const result = ensureVerified(input);
|
|
164
|
-
assert.equal(result.ok, true);
|
|
165
|
-
const sentinel = JSON.parse(fs.readFileSync(result.sentinelPath, "utf8"));
|
|
166
|
-
const verified = binaryTargetsForPlatform(platformId).map((target) => target.binary);
|
|
167
|
-
assert.deepEqual(Object.keys(sentinel.binaries), verified);
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("ensureVerified returns cached:true on a valid sentinel", (t) => {
|
|
172
|
-
_resetWarningState();
|
|
173
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
174
|
-
const dir = mkPlatformDir(privateKey);
|
|
175
|
-
t.after(() => cleanup(dir));
|
|
176
|
-
|
|
177
|
-
// First call writes sentinel
|
|
178
|
-
ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
179
|
-
|
|
180
|
-
// Second call should hit cache (verifyFn must NOT be called)
|
|
181
|
-
let verifyCallCount = 0;
|
|
182
|
-
const result = ensureVerified(
|
|
183
|
-
baseInput(dir, (p) => {
|
|
184
|
-
verifyCallCount += 1;
|
|
185
|
-
return _verifyWithKey(p, rawPub);
|
|
186
|
-
}),
|
|
187
|
-
);
|
|
188
|
-
assert.equal(result.ok, true);
|
|
189
|
-
assert.equal(result.cached, true);
|
|
190
|
-
assert.equal(verifyCallCount, 0, "sig verify should NOT have run on a cache hit");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
// ---- cache invalidation modes ---------------------------------------------
|
|
194
|
-
|
|
195
|
-
test("ensureVerified invalidates sentinel on mtime drift", (t) => {
|
|
196
|
-
_resetWarningState();
|
|
197
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
198
|
-
const dir = mkPlatformDir(privateKey);
|
|
199
|
-
t.after(() => cleanup(dir));
|
|
200
|
-
|
|
201
|
-
ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
202
|
-
|
|
203
|
-
// Bump the mtime of one binary; sentinel should now be stale.
|
|
204
|
-
const newTime = new Date(Date.now() + 10_000);
|
|
205
|
-
fs.utimesSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]), newTime, newTime);
|
|
206
|
-
|
|
207
|
-
let verifyCallCount = 0;
|
|
208
|
-
const result = ensureVerified(
|
|
209
|
-
baseInput(dir, (p) => {
|
|
210
|
-
verifyCallCount += 1;
|
|
211
|
-
return _verifyWithKey(p, rawPub);
|
|
212
|
-
}),
|
|
213
|
-
);
|
|
214
|
-
assert.equal(result.ok, true);
|
|
215
|
-
assert.equal(result.cached, false);
|
|
216
|
-
assert.equal(
|
|
217
|
-
verifyCallCount,
|
|
218
|
-
binaryNames(DEFAULT_PLATFORM).length,
|
|
219
|
-
"verify should rerun for every shipped binary",
|
|
220
|
-
);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test("ensureVerified invalidates sentinel on packageVersion drift", (t) => {
|
|
224
|
-
_resetWarningState();
|
|
225
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
226
|
-
const dir = mkPlatformDir(privateKey);
|
|
227
|
-
t.after(() => cleanup(dir));
|
|
228
|
-
|
|
229
|
-
ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
230
|
-
|
|
231
|
-
// Rewrite manifest with a different version.
|
|
232
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
233
|
-
manifest.version = "2.81.1";
|
|
234
|
-
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(manifest));
|
|
235
|
-
|
|
236
|
-
const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
237
|
-
assert.equal(result.cached, false);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test("ensureVerified invalidates sentinel on packageName drift", (t) => {
|
|
241
|
-
_resetWarningState();
|
|
242
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
243
|
-
const dir = mkPlatformDir(privateKey, { packageName: "@fallow-cli/x" });
|
|
244
|
-
t.after(() => cleanup(dir));
|
|
245
|
-
|
|
246
|
-
ensureVerified({
|
|
247
|
-
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
248
|
-
packageName: "@fallow-cli/x",
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
// Now claim a different package name; sentinel becomes stale.
|
|
252
|
-
const result = ensureVerified({
|
|
253
|
-
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
254
|
-
packageName: "@fallow-cli/y",
|
|
255
|
-
});
|
|
256
|
-
// Manifest still says @fallow-cli/x, so sentinel validates against manifest
|
|
257
|
-
// but the sentinel was originally written for x. Since we pass packageName=y
|
|
258
|
-
// for the cache lookup but the manifest still says x, the sentinel
|
|
259
|
-
// .packageName=x matches the manifest.name=x. We must rewrite manifest too
|
|
260
|
-
// to truly drift. Skip this case and instead force sentinel rewrite to
|
|
261
|
-
// have a different name:
|
|
262
|
-
fs.writeFileSync(
|
|
263
|
-
result.sentinelPath || path.join(dir, SENTINEL_FILENAME),
|
|
264
|
-
JSON.stringify({
|
|
265
|
-
schemaVersion: SENTINEL_SCHEMA_VERSION,
|
|
266
|
-
verifiedAt: new Date().toISOString(),
|
|
267
|
-
packageVersion: "2.81.0",
|
|
268
|
-
packageName: "@fallow-cli/wrong-name",
|
|
269
|
-
binaries: {
|
|
270
|
-
[binaryNames(DEFAULT_PLATFORM)[0]]: {
|
|
271
|
-
mtimeMs: fs.statSync(path.join(dir, binaryNames(DEFAULT_PLATFORM)[0])).mtimeMs,
|
|
272
|
-
},
|
|
273
|
-
},
|
|
274
|
-
}),
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
const result2 = ensureVerified({
|
|
278
|
-
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
279
|
-
packageName: "@fallow-cli/x",
|
|
280
|
-
});
|
|
281
|
-
assert.equal(result2.cached, false);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
test("ensureVerified invalidates sentinel on malformed JSON", (t) => {
|
|
285
|
-
_resetWarningState();
|
|
286
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
287
|
-
const dir = mkPlatformDir(privateKey);
|
|
288
|
-
t.after(() => cleanup(dir));
|
|
289
|
-
|
|
290
|
-
ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
291
|
-
fs.writeFileSync(path.join(dir, SENTINEL_FILENAME), "not-json-at-all");
|
|
292
|
-
|
|
293
|
-
const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
294
|
-
assert.equal(result.cached, false);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test("ensureVerified invalidates sentinel on schemaVersion drift", (t) => {
|
|
298
|
-
_resetWarningState();
|
|
299
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
300
|
-
const dir = mkPlatformDir(privateKey);
|
|
301
|
-
t.after(() => cleanup(dir));
|
|
302
|
-
|
|
303
|
-
ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
304
|
-
const sentinelPath = path.join(dir, SENTINEL_FILENAME);
|
|
305
|
-
const data = JSON.parse(fs.readFileSync(sentinelPath, "utf8"));
|
|
306
|
-
data.schemaVersion = 999;
|
|
307
|
-
fs.writeFileSync(sentinelPath, JSON.stringify(data));
|
|
308
|
-
|
|
309
|
-
const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
310
|
-
assert.equal(result.cached, false);
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// ---- failure modes --------------------------------------------------------
|
|
314
|
-
|
|
315
|
-
test("ensureVerified returns sig-invalid on a tampered signature", (t) => {
|
|
316
|
-
_resetWarningState();
|
|
317
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
318
|
-
const dir = mkPlatformDir(privateKey, { corruptSigFor: binaryNames(DEFAULT_PLATFORM)[0] });
|
|
319
|
-
t.after(() => cleanup(dir));
|
|
320
|
-
|
|
321
|
-
const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
322
|
-
assert.equal(result.ok, false);
|
|
323
|
-
assert.equal(result.code, "sig-invalid");
|
|
324
|
-
assert.match(result.binary, /fallow/);
|
|
325
|
-
// Sentinel must NOT have been written on failure
|
|
326
|
-
assert.equal(fs.existsSync(path.join(dir, SENTINEL_FILENAME)), false);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("ensureVerified returns digest-unavailable on a pre-#597 manifest", (t) => {
|
|
330
|
-
_resetWarningState();
|
|
331
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
332
|
-
const dir = mkPlatformDir(privateKey, { skipDigests: true });
|
|
333
|
-
t.after(() => cleanup(dir));
|
|
334
|
-
|
|
335
|
-
const result = ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
336
|
-
assert.equal(result.ok, false);
|
|
337
|
-
assert.equal(result.code, "digest-unavailable");
|
|
338
|
-
assert.match(result.message, /predates fallow 2\.78\.1/);
|
|
339
|
-
assert.match(result.message, new RegExp(SKIP_ENV));
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
// ---- cache-dir cascade ----------------------------------------------------
|
|
343
|
-
|
|
344
|
-
test("ensureVerified honors FALLOW_VERIFY_CACHE_DIR when platform pkg dir is non-writable", (t) => {
|
|
345
|
-
_resetWarningState();
|
|
346
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
347
|
-
const dir = mkPlatformDir(privateKey);
|
|
348
|
-
const cacheRoot = setupCacheRoot(t);
|
|
349
|
-
t.after(() => cleanup(dir));
|
|
350
|
-
|
|
351
|
-
const result = ensureVerified({
|
|
352
|
-
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
353
|
-
env: { FALLOW_VERIFY_CACHE_DIR: cacheRoot },
|
|
354
|
-
isWritable: (candidate) => candidate !== dir,
|
|
355
|
-
});
|
|
356
|
-
assert.equal(result.ok, true);
|
|
357
|
-
assert.equal(result.cached, false);
|
|
358
|
-
assert.match(result.sentinelPath, new RegExp(cacheRoot.replace(/\\/g, "\\\\")));
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
test("ensureVerified emits a single warning when no sentinel location is writable", (t) => {
|
|
362
|
-
_resetWarningState();
|
|
363
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
364
|
-
const dir = mkPlatformDir(privateKey);
|
|
365
|
-
const stderr = captureStderr(t);
|
|
366
|
-
t.after(() => cleanup(dir));
|
|
367
|
-
const input = {
|
|
368
|
-
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
369
|
-
homedir: undefined,
|
|
370
|
-
isWritable: () => false,
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
const result = ensureVerified(input);
|
|
374
|
-
assert.equal(result.sentinelPath, null);
|
|
375
|
-
const warnings = stderr.lines.filter((line) => line.includes("no writable cache location"));
|
|
376
|
-
assert.equal(warnings.length, 1);
|
|
377
|
-
|
|
378
|
-
ensureVerified(input);
|
|
379
|
-
const repeatedWarnings = stderr.lines.filter((line) =>
|
|
380
|
-
line.includes("no writable cache location"),
|
|
381
|
-
);
|
|
382
|
-
assert.equal(repeatedWarnings.length, 1);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
// ---- FALLOW_SKIP_BINARY_VERIFY -------------------------------------------
|
|
386
|
-
|
|
387
|
-
test("ensureVerified short-circuits when FALLOW_SKIP_BINARY_VERIFY is set", () => {
|
|
388
|
-
_resetWarningState();
|
|
389
|
-
const result = ensureVerified({
|
|
390
|
-
platformPkgDir: "/this/path/does/not/exist",
|
|
391
|
-
packageName: "@fallow-cli/x",
|
|
392
|
-
manifestPath: "/also/missing",
|
|
393
|
-
env: { [SKIP_ENV]: "1" },
|
|
394
|
-
});
|
|
395
|
-
assert.equal(result.ok, true);
|
|
396
|
-
assert.equal(result.skipped, true);
|
|
397
|
-
assert.match(result.reason, new RegExp(SKIP_ENV));
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
// ---- FALLOW_VERIFY_LOG ----------------------------------------------------
|
|
401
|
-
|
|
402
|
-
test("ensureVerified emits one stderr line per outcome when FALLOW_VERIFY_LOG=1", (t) => {
|
|
403
|
-
_resetWarningState();
|
|
404
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
405
|
-
const dir = mkPlatformDir(privateKey);
|
|
406
|
-
t.after(() => cleanup(dir));
|
|
407
|
-
|
|
408
|
-
const stderr = captureStderr(t);
|
|
409
|
-
// First invocation: cache miss
|
|
410
|
-
ensureVerified({
|
|
411
|
-
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
412
|
-
env: { FALLOW_VERIFY_LOG: "1" },
|
|
413
|
-
});
|
|
414
|
-
// Second invocation: cache hit
|
|
415
|
-
ensureVerified({
|
|
416
|
-
...baseInput(dir, (p) => _verifyWithKey(p, rawPub)),
|
|
417
|
-
env: { FALLOW_VERIFY_LOG: "1" },
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
const logs = stderr.lines.filter((l) => l.startsWith("fallow-verify "));
|
|
421
|
-
assert.equal(logs.length, 2);
|
|
422
|
-
assert.match(logs[0], /outcome=ok cache=miss/);
|
|
423
|
-
assert.match(logs[1], /outcome=ok cache=hit/);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
test("ensureVerified does not log when FALLOW_VERIFY_LOG is unset", (t) => {
|
|
427
|
-
_resetWarningState();
|
|
428
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
429
|
-
const dir = mkPlatformDir(privateKey);
|
|
430
|
-
t.after(() => cleanup(dir));
|
|
431
|
-
|
|
432
|
-
const stderr = captureStderr(t);
|
|
433
|
-
ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
434
|
-
const logs = stderr.lines.filter((l) => l.startsWith("fallow-verify "));
|
|
435
|
-
assert.equal(logs.length, 0);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
// ---- concurrency ----------------------------------------------------------
|
|
439
|
-
|
|
440
|
-
test("ensureVerified is idempotent under concurrent first-runs", async (t) => {
|
|
441
|
-
_resetWarningState();
|
|
442
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
443
|
-
const dir = mkPlatformDir(privateKey);
|
|
444
|
-
t.after(() => cleanup(dir));
|
|
445
|
-
|
|
446
|
-
const calls = await Promise.all([
|
|
447
|
-
Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
|
|
448
|
-
Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
|
|
449
|
-
Promise.resolve().then(() => ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)))),
|
|
450
|
-
]);
|
|
451
|
-
|
|
452
|
-
for (const r of calls) assert.equal(r.ok, true);
|
|
453
|
-
// Sentinel exists and is valid JSON
|
|
454
|
-
const sentinel = JSON.parse(fs.readFileSync(path.join(dir, SENTINEL_FILENAME), "utf8"));
|
|
455
|
-
assert.equal(sentinel.schemaVersion, SENTINEL_SCHEMA_VERSION);
|
|
456
|
-
assert.equal(sentinel.packageName, "@fallow-cli/test-platform");
|
|
457
|
-
|
|
458
|
-
// No leftover .tmp files in the dir
|
|
459
|
-
const files = fs.readdirSync(dir);
|
|
460
|
-
const tmps = files.filter((f) => f.includes(".tmp"));
|
|
461
|
-
assert.deepEqual(tmps, [], "no leftover temp files from concurrent writes");
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
// ---- cross-install sentinel reuse (security regression test) ----------
|
|
465
|
-
|
|
466
|
-
test("ensureVerified rejects a sentinel written for a different install dir", (t) => {
|
|
467
|
-
_resetWarningState();
|
|
468
|
-
// Two installs of the same package + version. install A is clean and writes
|
|
469
|
-
// a sentinel to a shared cache; install B has a tampered binary at the same
|
|
470
|
-
// package name + version. B must NOT trust A's sentinel via cache hit even
|
|
471
|
-
// when the recorded mtimes happen to match B's binary mtimes.
|
|
472
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
473
|
-
const installA = mkPlatformDir(privateKey);
|
|
474
|
-
const installB = mkPlatformDir(privateKey);
|
|
475
|
-
// Tamper install B's fallow binary AFTER mkPlatformDir wrote a valid sig
|
|
476
|
-
// for the original bytes (mkPlatformDir does not expose a corrupt-binary
|
|
477
|
-
// option, so simulate the attack by overwriting bytes here).
|
|
478
|
-
fs.writeFileSync(
|
|
479
|
-
path.join(installB, binaryNames(DEFAULT_PLATFORM)[0]),
|
|
480
|
-
Buffer.from("tampered bytes"),
|
|
481
|
-
);
|
|
482
|
-
const sharedCache = fs.mkdtempSync(path.join(os.tmpdir(), "fallow-shared-cache-"));
|
|
483
|
-
const isWritable = (candidate) => candidate !== installA && candidate !== installB;
|
|
484
|
-
|
|
485
|
-
t.after(() => {
|
|
486
|
-
cleanup(installA);
|
|
487
|
-
cleanup(installB);
|
|
488
|
-
cleanup(sharedCache);
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
// Install A: clean verify. Sentinel lands in the shared cache because the
|
|
492
|
-
// platform pkg dir is read-only.
|
|
493
|
-
const resultA = ensureVerified({
|
|
494
|
-
...baseInput(installA, (p) => _verifyWithKey(p, rawPub)),
|
|
495
|
-
env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
|
|
496
|
-
isWritable,
|
|
497
|
-
});
|
|
498
|
-
assert.equal(resultA.ok, true);
|
|
499
|
-
assert.match(resultA.sentinelPath, new RegExp(sharedCache.replace(/\\/g, "\\\\")));
|
|
500
|
-
|
|
501
|
-
// Attacker on install B copies install A's mtimes onto B's binaries so the
|
|
502
|
-
// mtime pre-filter would have matched. With only mtime + name + version
|
|
503
|
-
// gates this would produce a cache hit and skip verify; the platformPkgDir
|
|
504
|
-
// + SHA-256 binding must prevent that.
|
|
505
|
-
for (const name of binaryNames()) {
|
|
506
|
-
const aStat = fs.statSync(path.join(installA, name));
|
|
507
|
-
fs.utimesSync(path.join(installB, name), aStat.atime, aStat.mtime);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
let verifyCallCount = 0;
|
|
511
|
-
const resultB = ensureVerified({
|
|
512
|
-
...baseInput(installB, (p) => {
|
|
513
|
-
verifyCallCount += 1;
|
|
514
|
-
return _verifyWithKey(p, rawPub);
|
|
515
|
-
}),
|
|
516
|
-
env: { FALLOW_VERIFY_CACHE_DIR: sharedCache },
|
|
517
|
-
isWritable,
|
|
518
|
-
});
|
|
519
|
-
assert.equal(resultB.ok, false);
|
|
520
|
-
assert.equal(resultB.code, "sig-invalid");
|
|
521
|
-
assert.ok(verifyCallCount > 0, "expected re-verify on cross-install sentinel read");
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
test("ensureVerified rejects a sentinel where bytes drift but mtime stays", (t) => {
|
|
525
|
-
_resetWarningState();
|
|
526
|
-
const { privateKey, rawPub } = makeKeypair();
|
|
527
|
-
const dir = mkPlatformDir(privateKey);
|
|
528
|
-
t.after(() => cleanup(dir));
|
|
529
|
-
|
|
530
|
-
// First invocation writes a sentinel with the clean SHA-256.
|
|
531
|
-
ensureVerified(baseInput(dir, (p) => _verifyWithKey(p, rawPub)));
|
|
532
|
-
|
|
533
|
-
// Tamper the binary in place AND restore the prior mtime, so the mtime
|
|
534
|
-
// pre-filter matches but the bytes do not.
|
|
535
|
-
const binPath = path.join(dir, binaryNames(DEFAULT_PLATFORM)[0]);
|
|
536
|
-
const before = fs.statSync(binPath);
|
|
537
|
-
fs.writeFileSync(binPath, Buffer.from("tampered"));
|
|
538
|
-
fs.utimesSync(binPath, before.atime, before.mtime);
|
|
539
|
-
|
|
540
|
-
let verifyCallCount = 0;
|
|
541
|
-
const result = ensureVerified(
|
|
542
|
-
baseInput(dir, (p) => {
|
|
543
|
-
verifyCallCount += 1;
|
|
544
|
-
return _verifyWithKey(p, rawPub);
|
|
545
|
-
}),
|
|
546
|
-
);
|
|
547
|
-
assert.equal(result.ok, false);
|
|
548
|
-
assert.equal(result.code, "sig-invalid");
|
|
549
|
-
assert.ok(verifyCallCount > 0, "expected re-verify when bytes diverge from sentinel SHA");
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
// ---- FALLOW_SKIP_BINARY_VERIFY warning (regression test for documented contract) ----
|
|
553
|
-
|
|
554
|
-
test("ensureVerified warns once on stderr when FALLOW_SKIP_BINARY_VERIFY is set", (t) => {
|
|
555
|
-
_resetWarningState();
|
|
556
|
-
const stderr = captureStderr(t);
|
|
557
|
-
const env = { [SKIP_ENV]: "1" };
|
|
558
|
-
ensureVerified({ platformPkgDir: "/x", packageName: "@fallow-cli/y", manifestPath: "/z", env });
|
|
559
|
-
ensureVerified({ platformPkgDir: "/x", packageName: "@fallow-cli/y", manifestPath: "/z", env });
|
|
560
|
-
const warnings = stderr.lines.filter(
|
|
561
|
-
(l) => l.includes(`${SKIP_ENV} is set`) && l.includes("verification is skipped"),
|
|
562
|
-
);
|
|
563
|
-
assert.equal(warnings.length, 1, "warning should fire exactly once per process");
|
|
564
|
-
});
|