@bongos/core 1.19.832 → 1.19.833
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/.bongos-core.json +25 -20
- package/docs/module-api-changelog.md +2 -0
- package/modules/security/artifact-scan/quarantine.js +120 -4
- package/modules/security/artifact-scan/sanitize.js +6 -2
- package/modules/security/tests/mirror.mjs +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +13 -3
- package/scripts/gds/migration-namespace.js +101 -0
- package/src/module-api.js +1 -1
- package/tests/artifact_staleness.mjs +6 -1
- package/tests/migration_namespace.mjs +138 -0
- package/tests/quarantine_canonical.mjs +245 -0
- package/tests/search_index_poller.mjs +6 -1
- package/tests/search_isolation.mjs +16 -1
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.833",
|
|
6
|
+
"core_contract": "1.19.833",
|
|
7
|
+
"source_commit": "2b0af43d93e1f2c1cce0e30cf7b2f0893b5d7e51",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-17T15:
|
|
9
|
+
"built_at": "2026-09-17T15:53:43.843Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 492,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2245,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2763,
|
|
20
|
+
"tree_sha256": "c96978c58f5f47d2eb821f10b23aa5feafd029cd7a55fa921ab7f4db9f201c21",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2557,7 +2557,7 @@
|
|
|
2557
2557
|
{
|
|
2558
2558
|
"path": "docs/module-api-changelog.md",
|
|
2559
2559
|
"mode": "0000644",
|
|
2560
|
-
"sha256": "
|
|
2560
|
+
"sha256": "0e95a54bd91d5d34612789b30d12a1dc5a6dc679716438727170e65d14c009b4"
|
|
2561
2561
|
},
|
|
2562
2562
|
{
|
|
2563
2563
|
"path": "docs/modules-contract.md",
|
|
@@ -7187,7 +7187,7 @@
|
|
|
7187
7187
|
{
|
|
7188
7188
|
"path": "modules/security/artifact-scan/quarantine.js",
|
|
7189
7189
|
"mode": "0000644",
|
|
7190
|
-
"sha256": "
|
|
7190
|
+
"sha256": "af3f54455c88b9299a1eb4132709071cf7c92707ece4a947c9e18db86db8f7f1"
|
|
7191
7191
|
},
|
|
7192
7192
|
{
|
|
7193
7193
|
"path": "modules/security/artifact-scan/rebuild-spec.js",
|
|
@@ -7202,7 +7202,7 @@
|
|
|
7202
7202
|
{
|
|
7203
7203
|
"path": "modules/security/artifact-scan/sanitize.js",
|
|
7204
7204
|
"mode": "0000644",
|
|
7205
|
-
"sha256": "
|
|
7205
|
+
"sha256": "e70485e457a496bc518fc8b26dfaa0ceca1768158cfb95407668c659a6f2b24b"
|
|
7206
7206
|
},
|
|
7207
7207
|
{
|
|
7208
7208
|
"path": "modules/security/migrations/security_001_tool_scans.sql",
|
|
@@ -7257,7 +7257,7 @@
|
|
|
7257
7257
|
{
|
|
7258
7258
|
"path": "modules/security/tests/mirror.mjs",
|
|
7259
7259
|
"mode": "0000644",
|
|
7260
|
-
"sha256": "
|
|
7260
|
+
"sha256": "fbded0d3f137a62c9f6255297b2aa5752e0dc2a6175ce0e344d461cf59bc53b1"
|
|
7261
7261
|
},
|
|
7262
7262
|
{
|
|
7263
7263
|
"path": "modules/security/tests/sanitize_rebuild.mjs",
|
|
@@ -7842,12 +7842,12 @@
|
|
|
7842
7842
|
{
|
|
7843
7843
|
"path": "package-lock.json",
|
|
7844
7844
|
"mode": "0000644",
|
|
7845
|
-
"sha256": "
|
|
7845
|
+
"sha256": "85dfac1e9f80f5a7b407616bacbad449156497c602357254482150f7f65e8725"
|
|
7846
7846
|
},
|
|
7847
7847
|
{
|
|
7848
7848
|
"path": "package.json",
|
|
7849
7849
|
"mode": "0000644",
|
|
7850
|
-
"sha256": "
|
|
7850
|
+
"sha256": "f55eb207a04e67907fb715c75ca37cdaf74f9463389c4e2c9c19b781666cf934"
|
|
7851
7851
|
},
|
|
7852
7852
|
{
|
|
7853
7853
|
"path": "public-docs/index.html",
|
|
@@ -8387,7 +8387,7 @@
|
|
|
8387
8387
|
{
|
|
8388
8388
|
"path": "scripts/gds/fitness.js",
|
|
8389
8389
|
"mode": "0000644",
|
|
8390
|
-
"sha256": "
|
|
8390
|
+
"sha256": "42c49bcab43be3367f05b99f4dfded590edf89ae10c1a3df2d9312b6e0d33fc9"
|
|
8391
8391
|
},
|
|
8392
8392
|
{
|
|
8393
8393
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -8627,7 +8627,7 @@
|
|
|
8627
8627
|
{
|
|
8628
8628
|
"path": "scripts/gds/migration-namespace.js",
|
|
8629
8629
|
"mode": "0000644",
|
|
8630
|
-
"sha256": "
|
|
8630
|
+
"sha256": "d5a4715d0e062a2fa9aabdfa19cf668521907df421f4a3532dbfe3d26c9cab11"
|
|
8631
8631
|
},
|
|
8632
8632
|
{
|
|
8633
8633
|
"path": "scripts/gds/mirror-lineage-check.js",
|
|
@@ -9767,7 +9767,7 @@
|
|
|
9767
9767
|
{
|
|
9768
9768
|
"path": "src/module-api.js",
|
|
9769
9769
|
"mode": "0000644",
|
|
9770
|
-
"sha256": "
|
|
9770
|
+
"sha256": "f98bb83cf9fd6b0cc03f1e1d69b546a9fbf79e9e63c6e9121f4e225a898e7ea8"
|
|
9771
9771
|
},
|
|
9772
9772
|
{
|
|
9773
9773
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10027,7 +10027,7 @@
|
|
|
10027
10027
|
{
|
|
10028
10028
|
"path": "tests/artifact_staleness.mjs",
|
|
10029
10029
|
"mode": "0000644",
|
|
10030
|
-
"sha256": "
|
|
10030
|
+
"sha256": "95156a6c54388afca8894ca6eb16d2472ede78e2ae3cf34ff00942c4551548c3"
|
|
10031
10031
|
},
|
|
10032
10032
|
{
|
|
10033
10033
|
"path": "tests/artist_gate.mjs",
|
|
@@ -12097,7 +12097,7 @@
|
|
|
12097
12097
|
{
|
|
12098
12098
|
"path": "tests/migration_namespace.mjs",
|
|
12099
12099
|
"mode": "0000644",
|
|
12100
|
-
"sha256": "
|
|
12100
|
+
"sha256": "42266f01d6ce88fdcdfddf45c2a11bb4599a87ea05b603d41120674900a59c95"
|
|
12101
12101
|
},
|
|
12102
12102
|
{
|
|
12103
12103
|
"path": "tests/migrations_forward_safe.mjs",
|
|
@@ -12759,6 +12759,11 @@
|
|
|
12759
12759
|
"mode": "0000644",
|
|
12760
12760
|
"sha256": "aea6f9ec053bd9477288025b58ed76c166c2d59a24d7ea3481edadba90113887"
|
|
12761
12761
|
},
|
|
12762
|
+
{
|
|
12763
|
+
"path": "tests/quarantine_canonical.mjs",
|
|
12764
|
+
"mode": "0000644",
|
|
12765
|
+
"sha256": "d8a2376c5ff96d77378626c9b4d1c32660e8412afb2531cf93085564890ffdef"
|
|
12766
|
+
},
|
|
12762
12767
|
{
|
|
12763
12768
|
"path": "tests/rank_floor_wiring.mjs",
|
|
12764
12769
|
"mode": "0000644",
|
|
@@ -12982,7 +12987,7 @@
|
|
|
12982
12987
|
{
|
|
12983
12988
|
"path": "tests/search_index_poller.mjs",
|
|
12984
12989
|
"mode": "0000644",
|
|
12985
|
-
"sha256": "
|
|
12990
|
+
"sha256": "5015bb705395336000e60ef957a48376ad7267bfeb74e3727213684dd30632e7"
|
|
12986
12991
|
},
|
|
12987
12992
|
{
|
|
12988
12993
|
"path": "tests/search_ingest.mjs",
|
|
@@ -13002,7 +13007,7 @@
|
|
|
13002
13007
|
{
|
|
13003
13008
|
"path": "tests/search_isolation.mjs",
|
|
13004
13009
|
"mode": "0000644",
|
|
13005
|
-
"sha256": "
|
|
13010
|
+
"sha256": "225bebacb2660252bae17dd2274aac4ad1e322348480a781ee13f6b1060714b4"
|
|
13006
13011
|
},
|
|
13007
13012
|
{
|
|
13008
13013
|
"path": "tests/search_route.mjs",
|
|
@@ -2138,5 +2138,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2138
2138
|
landed since 1.19.830 with no explicit bump. run 35238952391. (task 1002620)
|
|
2139
2139
|
1.19.832 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2140
2140
|
landed since 1.19.831 with no explicit bump. run 35240512571. (task 1002620)
|
|
2141
|
+
1.19.833 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2142
|
+
landed since 1.19.832 with no explicit bump. run 35243115057. (task 1002620)
|
|
2141
2143
|
---------------------------------------------------------------------------
|
|
2142
2144
|
```
|
|
@@ -58,11 +58,123 @@ function isSyncBacked(p) {
|
|
|
58
58
|
.some((seg) => SYNC_SEGMENTS.includes(seg.toLowerCase()));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
61
|
+
// CANONICALISE BEFORE COMPARING (task 1003848). Windows hands out 8.3 short names:
|
|
62
|
+
// os.tmpdir() here returns C:\Users\GMK1_P~1\AppData\Local\Temp while other sides
|
|
63
|
+
// report C:\Users\GMK1_ppl51\..., and path.resolve does not reconcile the two — so
|
|
64
|
+
// a purely lexical comparison called the same directory 'outside' itself.
|
|
65
|
+
//
|
|
66
|
+
// fs.realpathSync.native expands 8.3; plain fs.realpathSync does NOT. That single
|
|
67
|
+
// fact is the whole fix, and it is worth stating because the plain form looks like
|
|
68
|
+
// it should work.
|
|
69
|
+
//
|
|
70
|
+
// THIS IS NOT ONLY A TEST BUG. siteRefusal below asks isInside(REPO_ROOT, site) to
|
|
71
|
+
// refuse a quarantine site inside the repository. Lexically, a site spelled with a
|
|
72
|
+
// short name would not match the long-form REPO_ROOT — so the refusal could be
|
|
73
|
+
// evaded by spelling, on the one platform that hands out both spellings. Comparing
|
|
74
|
+
// canonical paths closes that as well as the false negative in the pen check.
|
|
75
|
+
//
|
|
76
|
+
// Falls back to the lexical answer when a path does not exist yet, which is the
|
|
77
|
+
// ordinary case for a derived path being validated BEFORE it is written: realpath
|
|
78
|
+
// throws on a missing leaf, so the nearest existing ancestor is canonicalised and
|
|
79
|
+
// the remainder appended.
|
|
80
|
+
// MEMOISED, and that is not an optimisation detail — isInside() is called twice per
|
|
81
|
+
// entry inside mirror.js's per-file loop (capped at 5,000 files), so an unmemoised
|
|
82
|
+
// realpath here would put 20,000 sync syscalls on a path whose own header is explicit
|
|
83
|
+
// about staying cheap. A directory cannot move under a running scan, so the answer is
|
|
84
|
+
// stable for the life of the process.
|
|
85
|
+
//
|
|
86
|
+
// Keyed on the RESOLVED input, so the two spellings of one path — which is the entire
|
|
87
|
+
// problem being solved — share an entry only after they canonicalise to the same value,
|
|
88
|
+
// never before.
|
|
89
|
+
const canonicalCache = new Map();
|
|
90
|
+
|
|
91
|
+
function canonicalUncached(p) {
|
|
92
|
+
let abs = path.resolve(p);
|
|
93
|
+
const tail = [];
|
|
94
|
+
for (;;) {
|
|
95
|
+
try {
|
|
96
|
+
const real = fs.realpathSync.native(abs);
|
|
97
|
+
// `exact` is true only when the FULL path resolved — nothing was walked off.
|
|
98
|
+
// That is the whole cacheability test; see canonical() below.
|
|
99
|
+
return { value: path.join(real, ...tail.reverse()), exact: tail.length === 0 };
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// ENOENT is the ordinary case: a derived path being validated BEFORE it is
|
|
102
|
+
// written. Walk up to the nearest ancestor that exists and re-attach the tail.
|
|
103
|
+
//
|
|
104
|
+
// ANY OTHER ERROR (EACCES, EPERM, ELOOP) used to return the raw lexical path,
|
|
105
|
+
// which silently re-opened the 8.3 gap this function exists to close — an
|
|
106
|
+
// unreadable leaf would have made a short-name spelling pass containment again.
|
|
107
|
+
// Walking up instead keeps as much canonical prefix as we can actually read, so
|
|
108
|
+
// the comparison degrades gradually rather than reverting.
|
|
109
|
+
const parent = path.dirname(abs);
|
|
110
|
+
// hit the root and found nothing real — a lexical answer, never cacheable
|
|
111
|
+
if (parent === abs) return { value: path.resolve(p), exact: false };
|
|
112
|
+
tail.push(path.basename(abs));
|
|
113
|
+
abs = parent;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ONLY AN EXACT ANSWER IS CACHEABLE (task 1003848, second pass).
|
|
119
|
+
//
|
|
120
|
+
// The first pass cached every answer, including the provisional one computed by
|
|
121
|
+
// walking up to the nearest EXISTING ancestor. That is the answer for a path being
|
|
122
|
+
// validated BEFORE it is written — and it is only true until the path is written.
|
|
123
|
+
// Create it as a symlink (or, on Windows, a junction) pointing out of the pen and the
|
|
124
|
+
// cache keeps serving the pre-creation answer, so isInside() still calls it INSIDE and
|
|
125
|
+
// the path_escape refusal is lost. That is the containment evasion this function
|
|
126
|
+
// exists to close, re-opened one layer down by its own memoisation.
|
|
127
|
+
//
|
|
128
|
+
// Proven by control: with a warm cache isInside(pen, probed) answered true; after
|
|
129
|
+
// clearCanonicalCache() the same call answered false, with nothing else changed.
|
|
130
|
+
//
|
|
131
|
+
// So a walked-up answer is returned and NOT stored. This keeps every bit of the
|
|
132
|
+
// memoisation that mattered — mirror.js's hot loop canonicalises EXISTING directories
|
|
133
|
+
// over and over, and those still resolve exactly and still cache on first touch — while
|
|
134
|
+
// a path that does not exist yet is re-read each time, which is precisely when the
|
|
135
|
+
// filesystem is allowed to disagree with the last answer.
|
|
136
|
+
function canonical(p) {
|
|
137
|
+
const key = path.resolve(p);
|
|
138
|
+
const hit = canonicalCache.get(key);
|
|
139
|
+
if (hit !== undefined) return hit;
|
|
140
|
+
const { value, exact } = canonicalUncached(key);
|
|
141
|
+
if (exact) canonicalCache.set(key, value);
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// For tests, and for a caller that genuinely needs to re-read the filesystem.
|
|
146
|
+
function clearCanonicalCache() { canonicalCache.clear(); }
|
|
147
|
+
|
|
148
|
+
// Containment: is `candidate` equal to, or under, `root`? Directional on purpose —
|
|
149
|
+
// path.relative escaping upward ('..') or returning an absolute path both mean
|
|
150
|
+
// "outside". Used to reject a quarantine site, and by callers to keep derived paths
|
|
151
|
+
// inside the pen.
|
|
65
152
|
function isInside(root, candidate) {
|
|
153
|
+
const rel = path.relative(canonical(root), canonical(candidate));
|
|
154
|
+
if (rel === '') return true;
|
|
155
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// LEXICAL containment — path arithmetic only, never the filesystem (task 1003848,
|
|
159
|
+
// grade round 2).
|
|
160
|
+
//
|
|
161
|
+
// isInside() above canonicalises, which RESOLVES SYMLINKS. That is right for a
|
|
162
|
+
// physical check ("where does this actually point?") and wrong for a lexical one
|
|
163
|
+
// ("does this manifest path try to climb out?"). sanitize.js runs both, in that
|
|
164
|
+
// order, on purpose: lexical first to reject a traversing manifest path, then
|
|
165
|
+
// physical to reject a link whose target escapes, then lstat to reject a link the
|
|
166
|
+
// manifest called a file — each with its own refusal message.
|
|
167
|
+
//
|
|
168
|
+
// Canonicalising the lexical arm collapsed those three into one: a symlink inside
|
|
169
|
+
// pristine/ resolved to its target outside, so the LEXICAL arm threw "escapes the
|
|
170
|
+
// pristine tree" and pre-empted the two later refusals. The artifact was still
|
|
171
|
+
// refused — containment never weakened — but the diagnosis became wrong, and the
|
|
172
|
+
// two tests pinning those messages failed. Those tests only run where symlinks can
|
|
173
|
+
// be created, so Windows skips them and the regression is invisible off-CI.
|
|
174
|
+
//
|
|
175
|
+
// There is deliberately no canonical() call here: this answer must not depend on
|
|
176
|
+
// what the leaf currently points at.
|
|
177
|
+
function isInsideLexical(root, candidate) {
|
|
66
178
|
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
|
67
179
|
if (rel === '') return true;
|
|
68
180
|
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
@@ -140,6 +252,10 @@ function createQuarantine({ tmpRoot = os.tmpdir(), forbiddenRoots } = {}) {
|
|
|
140
252
|
}
|
|
141
253
|
|
|
142
254
|
module.exports = {
|
|
255
|
+
canonical,
|
|
256
|
+
isInsideLexical,
|
|
257
|
+
canonicalUncached,
|
|
258
|
+
clearCanonicalCache,
|
|
143
259
|
CAPS,
|
|
144
260
|
REPO_ROOT,
|
|
145
261
|
createQuarantine,
|
|
@@ -38,7 +38,7 @@ const crypto = require('node:crypto');
|
|
|
38
38
|
const fsp = require('node:fs/promises');
|
|
39
39
|
const path = require('node:path');
|
|
40
40
|
|
|
41
|
-
const { isInside } = require('./quarantine');
|
|
41
|
+
const { isInside, isInsideLexical } = require('./quarantine');
|
|
42
42
|
const { excerpt } = require('./detectors/shared');
|
|
43
43
|
|
|
44
44
|
const SANITIZED_DIRNAME = 'sanitized';
|
|
@@ -240,8 +240,12 @@ async function buildSanitizedCopy({ pristineDir, quarantineRoot, entries, remova
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
// Confinement, same discipline as mirror.js: lexical first, then physical.
|
|
243
|
+
// isInsideLexical, NOT isInside: this arm asks whether the MANIFEST PATH climbs
|
|
244
|
+
// out, which is pure path arithmetic. isInside canonicalises and so follows a
|
|
245
|
+
// symlink to its target, which would make this arm fire for a link that escapes
|
|
246
|
+
// and pre-empt the two specific refusals below (task 1003848).
|
|
243
247
|
if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
|
|
244
|
-
|| !
|
|
248
|
+
|| !isInsideLexical(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
|
|
245
249
|
throw new Error(`Manifest path "${excerpt(String(rel), 300)}" escapes the pristine tree — refusing to build a sanitized copy over it.`);
|
|
246
250
|
}
|
|
247
251
|
const abs = path.resolve(pristineDir, ...rel.split('/'));
|
|
@@ -24,6 +24,12 @@ import path from 'node:path';
|
|
|
24
24
|
|
|
25
25
|
const require = createRequire(import.meta.url);
|
|
26
26
|
const { buildMirror, MIRROR_CAPS } = require('../artifact-scan/mirror.js');
|
|
27
|
+
// Containment is asked of the module's own predicate, not of String.startsWith
|
|
28
|
+
// (task 1003848). A prefix match is both too weak - '/pen-evil' starts with '/pen' -
|
|
29
|
+
// and, on Windows, wrong: os.tmpdir() hands back an 8.3 short name while the built
|
|
30
|
+
// path carries the long form, so the two spellings of ONE directory did not share a
|
|
31
|
+
// prefix and the pen looked like it was outside itself.
|
|
32
|
+
const { isInside } = require('../artifact-scan/quarantine.js');
|
|
27
33
|
const fenceLocal = require('../artifact-scan/fence-local.js');
|
|
28
34
|
const { scrubUntrusted, newFenceDelim, FENCE_MARKER, DANGEROUS_CHARS } = fenceLocal;
|
|
29
35
|
|
|
@@ -322,7 +328,7 @@ async function run() {
|
|
|
322
328
|
pristineDir: pen.pristineDir, mirrorDir: pen.mirrorDir, quarantineRoot: pen.root,
|
|
323
329
|
entries: [fileEntry('ok.txt', 4), fileEntry('bad.bin', 3)],
|
|
324
330
|
});
|
|
325
|
-
assert.ok(
|
|
331
|
+
assert.ok(isInside(pen.root, res.indexPath), 'index lives inside the pen');
|
|
326
332
|
const idx = JSON.parse(fs.readFileSync(res.indexPath, 'utf8'));
|
|
327
333
|
assert.equal(idx.mirrored.length, 1);
|
|
328
334
|
assert.equal(idx.stubs.length, 1);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.833",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.833",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.833",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -26,7 +26,7 @@ const { execFileSync } = require('node:child_process');
|
|
|
26
26
|
const { checkRankGuards, checkModulePinKeysResolve } = require('./fitness-checks-route-pins.js'); // Checks 4 + 4b — carved out by task 1003192 (the 1,500-line ratchet)
|
|
27
27
|
const genApiDocs = require('./gen-api-docs'); // R12 (#1999): reuse the route model (readsBody/validated) that drives the OpenAPI spec
|
|
28
28
|
const { parseRoutineFrontmatter } = require('./run-routine'); // System 4 (ADR 0078): deterministic-routine boundary
|
|
29
|
-
const { filenameViolation, stemCollisions, CORE_NS, INSTANCE_NS } = require('./migration-namespace'); // ADR 0108 §5: cross-repo migration namespacing
|
|
29
|
+
const { filenameViolation, stemCollisions, numberCollisions, CORE_NS, INSTANCE_NS } = require('./migration-namespace'); // ADR 0108 §5: cross-repo migration namespacing
|
|
30
30
|
const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-config'); // ADR 0108 §1: configurable-root
|
|
31
31
|
|
|
32
32
|
// task 1003206 (goal 1000079): fitness.js was 2,004 lines. Four cohesive pieces now
|
|
@@ -965,13 +965,23 @@ function checkMigrationNamespacing() {
|
|
|
965
965
|
violations.push(`migration stem "${c.stem}" is produced by more than one source (${c.files.join(', ')}) — schema_migrations is stem-keyed, so one applies and the rest are SILENTLY SKIPPED; namespace them (ADR 0108 §5).`);
|
|
966
966
|
}
|
|
967
967
|
|
|
968
|
+
// Two DIFFERENT stems at one number inside ONE series (task 1003646). Not the
|
|
969
|
+
// silent-skip above — all of them apply — but migrate.sh orders by basename, so
|
|
970
|
+
// their relative order is the alphabetical accident of the slug, and a from-scratch
|
|
971
|
+
// replay can order them differently from the merge order prod applied. Renumbering an
|
|
972
|
+
// APPLIED migration re-runs it, so the fix for a new one is to renumber BEFORE it
|
|
973
|
+
// lands; the single pre-existing case is grandfathered in migration-namespace.js.
|
|
974
|
+
for (const c of numberCollisions(files)) {
|
|
975
|
+
violations.push(`migration number ${String(c.number).padStart(3, '0')} is used by ${c.files.length} different migrations in one series (${c.series}): ${c.files.join(', ')} — each was allocated max+1 from its own branch point. migrate.sh applies in BASENAME order, so their order is decided by the slug alphabetically and a fresh replay can differ from the order prod applied. Renumber the one that has NOT landed yet (renaming an applied migration re-runs it) (task 1003646).`);
|
|
976
|
+
}
|
|
977
|
+
|
|
968
978
|
return {
|
|
969
|
-
name: 'migration filenames namespaced + no stem collision (ADR 0108 §5)',
|
|
979
|
+
name: 'migration filenames namespaced + no stem/number collision (ADR 0108 §5)',
|
|
970
980
|
ok: violations.length === 0,
|
|
971
981
|
hardFail: violations.length > 0,
|
|
972
982
|
violations,
|
|
973
983
|
warnings: [],
|
|
974
|
-
note: `${coreFiles.length} core + ${instFiles.length} instance + ${moduleCount} module migration file(s): new core→core_, new module→<key>_, instance bare; no cross-root stem collision.`,
|
|
984
|
+
note: `${coreFiles.length} core + ${instFiles.length} instance + ${moduleCount} module migration file(s): new core→core_, new module→<key>_, instance bare; no cross-root stem collision, and no two migrations share a number within one series.`,
|
|
975
985
|
};
|
|
976
986
|
}
|
|
977
987
|
|
|
@@ -154,6 +154,106 @@ function stemCollisions(files) {
|
|
|
154
154
|
return out.sort((a, b) => a.stem.localeCompare(b.stem));
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// ---- same-series number-collision detector ---------------------------------
|
|
158
|
+
|
|
159
|
+
// The stem detector above catches the CROSS-source hazard (one stem, two sources →
|
|
160
|
+
// migrate.sh applies one and silently skips the rest). It deliberately does not care
|
|
161
|
+
// that `core_012_x` and `platform_identity_012_y` share a number: they are separate
|
|
162
|
+
// allocators with separate namespaces, and the comment on lineageReport says so.
|
|
163
|
+
//
|
|
164
|
+
// THE GAP (task 1003646). Two DIFFERENT stems sharing a number inside ONE series is a
|
|
165
|
+
// different bug, and nothing watched it. next-number.js allocates max+1 by scanning the
|
|
166
|
+
// series as it stands at the caller's BRANCH POINT, so two claim sessions that branch
|
|
167
|
+
// before either lands both compute the same "next". On 2026-08-28 that put three files
|
|
168
|
+
// at platform_identity_012 on main. stemCollisions() returns [] for them — the stems
|
|
169
|
+
// differ — so CI was green.
|
|
170
|
+
//
|
|
171
|
+
// WHY IT MATTERS, given nothing is skipped. migrate.sh applies in BASENAME order
|
|
172
|
+
// (scripts/migrate.sh: core+instance merge-sorted by basename; modules by shell glob),
|
|
173
|
+
// so a colliding pair's relative order is decided by the alphabetical accident of its
|
|
174
|
+
// SLUG. On prod they applied in the order the branches merged. On a from-scratch replay
|
|
175
|
+
// — a restore, a fresh instance, the CI replay guard — they apply alphabetically. When
|
|
176
|
+
// those two orders differ, a rebuilt database is not the database that was built, which
|
|
177
|
+
// is the exact hazard migrations/README.md names as "the one rule that has actually
|
|
178
|
+
// bitten us". Nothing detects the divergence, because both databases report every
|
|
179
|
+
// migration applied.
|
|
180
|
+
//
|
|
181
|
+
// The fix is a guard, not a renumber: renaming an applied migration changes its stem,
|
|
182
|
+
// which is the schema_migrations key, so migrate.sh would see it as unapplied and RE-RUN
|
|
183
|
+
// it — the same reason LEGACY_BARE_MODULES and RENAMED_MODULE_PREFIXES exist above.
|
|
184
|
+
// The one pre-existing collision is therefore grandfathered by name, and the guard binds
|
|
185
|
+
// on everything new.
|
|
186
|
+
// Keyed series/number → the exact STEMS that are exempt. Deliberately not a bare
|
|
187
|
+
// series/number exemption: that would also wave through a FOURTH file landing at 012,
|
|
188
|
+
// which is the very bug this detects. Only the already-applied files are forgiven, so
|
|
189
|
+
// the number stays closed to anything new.
|
|
190
|
+
// Seven races that already landed. Every one of these files is applied on prod, and
|
|
191
|
+
// renaming an applied migration changes its schema_migrations stem, so migrate.sh would
|
|
192
|
+
// see it as unapplied and RE-RUN it — the same constraint that produced
|
|
193
|
+
// LEGACY_BARE_MODULES and RENAMED_MODULE_PREFIXES above. They are recorded rather than
|
|
194
|
+
// fixed, and the guard binds on everything new.
|
|
195
|
+
//
|
|
196
|
+
// These are history, not a pattern to copy: the number is the SECOND-oldest of these
|
|
197
|
+
// (067) through to 2026-08-28 (platform_identity 012), i.e. the race has been happening
|
|
198
|
+
// for as long as parallel claims have, which is why a keyword-free structural check is
|
|
199
|
+
// worth having at all.
|
|
200
|
+
const GRANDFATHERED_NUMBER_COLLISIONS = new Map([
|
|
201
|
+
['core/(bare)/067', new Set(['067_builder_boxes', '067_discord_bot_foundation'])],
|
|
202
|
+
['core/(bare)/176', new Set(['176_promotion_respects_open_blockers', '176_ui_discipline'])],
|
|
203
|
+
['core/(bare)/187', new Set(['187_goal_sort_order', '187_tasks_kind_check_add_verify'])],
|
|
204
|
+
['core/core/189', new Set(['core_189_core_upgrades_ledger', 'core_189_credit_log_task_id_index'])],
|
|
205
|
+
['core/core/223', new Set(['core_223_blocker_resolve_respects_other_open_blockers', 'core_223_task_visual'])],
|
|
206
|
+
['core/core/240', new Set(['core_240_artist_gate_indexes', 'core_240_goal_members_drop_legacy_role'])],
|
|
207
|
+
['module:platform-identity/platform_identity/012', new Set([
|
|
208
|
+
// 2026-08-28, the collision task 1003646 was filed for: three parallel sessions each
|
|
209
|
+
// allocated 012 from its own branch point. Order between them does not matter — they
|
|
210
|
+
// alter three different tables (platform_identity_accounts, _projects,
|
|
211
|
+
// _builder_project_activity), so the basename-order vs merge-order divergence has
|
|
212
|
+
// nothing to bite on here.
|
|
213
|
+
'platform_identity_012_account_privacy',
|
|
214
|
+
'platform_identity_012_project_template',
|
|
215
|
+
'platform_identity_012_recent_ships',
|
|
216
|
+
])],
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
// Given the same flat { source, moduleKey?, basename } list stemCollisions() takes,
|
|
220
|
+
// return [{ series, number, files:[…] }] for every number carrying >1 distinct stem
|
|
221
|
+
// within ONE series. Grandfathered entries are omitted.
|
|
222
|
+
function numberCollisions(files) {
|
|
223
|
+
const bySeriesNumber = new Map();
|
|
224
|
+
for (const f of files || []) {
|
|
225
|
+
const p = parseMigrationName(f.basename);
|
|
226
|
+
if (!p.ok) continue;
|
|
227
|
+
// A SERIES is one allocator's number space, and that is identified by the filename
|
|
228
|
+
// PREFIX, not by the directory. A renamed module keeps its historical prefix for
|
|
229
|
+
// already-applied files (RENAMED_MODULE_PREFIXES) and restarts the new prefix at 001,
|
|
230
|
+
// so modules/government/ holds governance_001…007 beside government_001…007. Those are
|
|
231
|
+
// two namespaces sharing a directory, not a race: the prefixes sort into separate
|
|
232
|
+
// contiguous blocks, so their order is fixed and identical on prod and on replay.
|
|
233
|
+
// Keying by directory would report all seven as collisions and the check would be
|
|
234
|
+
// wrong seven times before it was right once. moduleKey still participates so that two
|
|
235
|
+
// LEGACY BARE modules (game, demo) each numbering from 001 do not collide with each other.
|
|
236
|
+
const owner = f.source === 'module' ? `module:${f.moduleKey}` : f.source;
|
|
237
|
+
const series = `${owner}/${p.bare ? '(bare)' : p.prefix}`;
|
|
238
|
+
const key = `${series}/${pad3(p.number)}`;
|
|
239
|
+
if (!bySeriesNumber.has(key)) bySeriesNumber.set(key, { series, number: p.number, stems: new Map() });
|
|
240
|
+
// Keyed by STEM, so the same file counted twice is not a collision — only two
|
|
241
|
+
// genuinely different migrations at one number are.
|
|
242
|
+
bySeriesNumber.get(key).stems.set(f.basename.replace(/\.sql$/, ''), f.basename);
|
|
243
|
+
}
|
|
244
|
+
const out = [];
|
|
245
|
+
for (const [key, v] of bySeriesNumber) {
|
|
246
|
+
if (v.stems.size < 2) continue;
|
|
247
|
+
// Exempt only when EVERY stem at this number is one of the already-applied files.
|
|
248
|
+
// One unrecognised stem re-opens the whole group, because that new file is exactly
|
|
249
|
+
// what must not be allowed to sit on a taken number.
|
|
250
|
+
const allowed = GRANDFATHERED_NUMBER_COLLISIONS.get(key);
|
|
251
|
+
if (allowed && [...v.stems.keys()].every((stem) => allowed.has(stem))) continue;
|
|
252
|
+
out.push({ series: v.series, number: v.number, files: [...v.stems.values()].sort() });
|
|
253
|
+
}
|
|
254
|
+
return out.sort((a, b) => (a.series === b.series ? a.number - b.number : a.series.localeCompare(b.series)));
|
|
255
|
+
}
|
|
256
|
+
|
|
157
257
|
// ---- numbering / lineage (mirrors the public-mirror pattern) ---------------
|
|
158
258
|
|
|
159
259
|
// next free = max + 1; empty starts at `start` (same shape as next-number.js nextFree,
|
|
@@ -207,6 +307,7 @@ module.exports = {
|
|
|
207
307
|
migrationNumberOf,
|
|
208
308
|
filenameViolation,
|
|
209
309
|
stemCollisions,
|
|
310
|
+
numberCollisions,
|
|
210
311
|
nextInNamespace,
|
|
211
312
|
lineageReport,
|
|
212
313
|
};
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.833'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// Run: node --test tests/artifact_staleness.mjs
|
|
22
22
|
|
|
23
23
|
import { test } from 'node:test';
|
|
24
|
+
import path from 'node:path';
|
|
24
25
|
import assert from 'node:assert/strict';
|
|
25
26
|
import { createRequire } from 'node:module';
|
|
26
27
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
@@ -123,7 +124,11 @@ test('a generator that cannot answer is UNKNOWN, never stale — it is not a fin
|
|
|
123
124
|
|
|
124
125
|
const runFor = (byGenerator) => (cmd, args) => {
|
|
125
126
|
if (cmd === 'git') return fakeGit()(cmd, args);
|
|
126
|
-
|
|
127
|
+
// path.basename, not split('/') — the checker passes an ABSOLUTE generator path,
|
|
128
|
+
// which is backslash-separated on Windows, so this returned the whole path, matched
|
|
129
|
+
// no key in byGenerator, and every generator reported `status: undefined` -> "exit
|
|
130
|
+
// undefined". Three suites carried this same idiom (task 1003848).
|
|
131
|
+
const which = path.basename(String(args[0] || ''));
|
|
127
132
|
return { status: byGenerator[which], stdout: '', stderr: '' };
|
|
128
133
|
};
|
|
129
134
|
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// while grandfathering the existing bare files (core ≤ 187; game/demo modules).
|
|
9
9
|
// 3. stemCollisions catches the real bug: two sources sharing a schema_migrations stem.
|
|
10
10
|
// 4. nextInNamespace / lineageReport count both forms so an allocator can't reuse a number.
|
|
11
|
+
// 5. numberCollisions catches the SAME-SERIES race stemCollisions is blind to: two
|
|
12
|
+
// different stems on one number, which parallel claims produce (task 1003646).
|
|
11
13
|
//
|
|
12
14
|
// No fs/git — the helpers are pure. Run: node tests/migration_namespace.mjs
|
|
13
15
|
|
|
@@ -150,6 +152,142 @@ t('stemCollisions: namespacing REMOVES the collision', () => {
|
|
|
150
152
|
assert.deepEqual(ns.stemCollisions(files), []);
|
|
151
153
|
});
|
|
152
154
|
|
|
155
|
+
// ---- numberCollisions (task 1003646) ---------------------------------------
|
|
156
|
+
// The SAME-SERIES hazard, which stemCollisions is blind to by design: two different
|
|
157
|
+
// stems at one number inside one series. Nothing is skipped — but migrate.sh applies in
|
|
158
|
+
// BASENAME order, so the pair's order is the alphabetical accident of its slug, and a
|
|
159
|
+
// from-scratch replay can order them differently from the merge order prod applied.
|
|
160
|
+
t('numberCollisions: none when each number is used once in its series', () => {
|
|
161
|
+
const files = [
|
|
162
|
+
{ source: 'core', basename: 'core_188_a.sql' },
|
|
163
|
+
{ source: 'core', basename: 'core_189_b.sql' },
|
|
164
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_001_x.sql' },
|
|
165
|
+
];
|
|
166
|
+
assert.deepEqual(ns.numberCollisions(files), []);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
t('numberCollisions: the SAME number in DIFFERENT series is not a collision', () => {
|
|
170
|
+
// The deliberate tolerance lineageReport documents: separate allocators, separate
|
|
171
|
+
// namespaces, distinct stems. core_012 and economy_012 are unrelated.
|
|
172
|
+
const files = [
|
|
173
|
+
{ source: 'core', basename: 'core_012_a.sql' },
|
|
174
|
+
{ source: 'instance', basename: '012_b.sql' },
|
|
175
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_012_c.sql' },
|
|
176
|
+
{ source: 'module', moduleKey: 'ideas', basename: 'ideas_012_d.sql' },
|
|
177
|
+
];
|
|
178
|
+
assert.deepEqual(ns.numberCollisions(files), []);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
t('numberCollisions: catches two stems at one number in ONE series (the 1003646 bug)', () => {
|
|
182
|
+
const files = [
|
|
183
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
|
|
184
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_012_beta.sql' },
|
|
185
|
+
];
|
|
186
|
+
const c = ns.numberCollisions(files);
|
|
187
|
+
assert.equal(c.length, 1);
|
|
188
|
+
assert.equal(c[0].series, 'module:economy/economy');
|
|
189
|
+
assert.equal(c[0].number, 12);
|
|
190
|
+
assert.deepEqual(c[0].files, ['economy_012_alpha.sql', 'economy_012_beta.sql']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
t('numberCollisions: stemCollisions is blind to it — the two detectors are not redundant', () => {
|
|
194
|
+
// If this ever starts failing because stemCollisions caught it, one of the two is
|
|
195
|
+
// doing the other's job and the pair should be revisited.
|
|
196
|
+
const files = [
|
|
197
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
|
|
198
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_012_beta.sql' },
|
|
199
|
+
];
|
|
200
|
+
assert.deepEqual(ns.stemCollisions(files), [], 'different stems — not a stem collision');
|
|
201
|
+
assert.equal(ns.numberCollisions(files).length, 1, 'but it IS a number collision');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
t('numberCollisions: core and instance series are checked too, not just modules', () => {
|
|
205
|
+
const files = [
|
|
206
|
+
{ source: 'core', basename: 'core_190_a.sql' },
|
|
207
|
+
{ source: 'core', basename: 'core_190_b.sql' },
|
|
208
|
+
{ source: 'instance', basename: '042_x.sql' },
|
|
209
|
+
{ source: 'instance', basename: '042_y.sql' },
|
|
210
|
+
];
|
|
211
|
+
const c = ns.numberCollisions(files);
|
|
212
|
+
assert.equal(c.length, 2);
|
|
213
|
+
assert.deepEqual(c.map((x) => x.series), ['core/core', 'instance/(bare)']);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
t('numberCollisions: a RENAMED module\'s two prefixes are two series, not a collision', () => {
|
|
217
|
+
// modules/government/ really holds governance_001…007 beside government_001…007: the
|
|
218
|
+
// key was renamed (RENAMED_MODULE_PREFIXES, ADR 0174) and the new prefix restarted at
|
|
219
|
+
// 001, while the applied files keep the old one because the stem is the
|
|
220
|
+
// schema_migrations key. Keying a series by DIRECTORY reported all seven as collisions
|
|
221
|
+
// — the check would have been wrong seven times and right once. It is keyed by PREFIX
|
|
222
|
+
// for exactly this reason, and the prefixes sort into separate contiguous blocks
|
|
223
|
+
// (governance_ < government_), so their order is fixed on prod and on replay alike.
|
|
224
|
+
const files = [
|
|
225
|
+
{ source: 'module', moduleKey: 'government', basename: 'governance_001_tables.sql' },
|
|
226
|
+
{ source: 'module', moduleKey: 'government', basename: 'government_001_rename_from_governance.sql' },
|
|
227
|
+
{ source: 'module', moduleKey: 'government', basename: 'governance_007_delegate_archon_ops_to_metic.sql' },
|
|
228
|
+
{ source: 'module', moduleKey: 'government', basename: 'government_007_amendments.sql' },
|
|
229
|
+
];
|
|
230
|
+
assert.deepEqual(ns.numberCollisions(files), []);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
t('numberCollisions: two LEGACY BARE modules numbering from 001 do not collide', () => {
|
|
234
|
+
// game and demo both predate the <key>_ convention, so both hold a bare 001_. They are
|
|
235
|
+
// different directories and different allocators — moduleKey keeps them apart.
|
|
236
|
+
const files = [
|
|
237
|
+
{ source: 'module', moduleKey: 'game', basename: '001_initial.sql' },
|
|
238
|
+
{ source: 'module', moduleKey: 'demo', basename: '001_demo_pings.sql' },
|
|
239
|
+
];
|
|
240
|
+
assert.deepEqual(ns.numberCollisions(files), []);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
t('numberCollisions: the SAME file listed twice is not a collision', () => {
|
|
244
|
+
// Keyed by stem, so a double-scan of one directory cannot manufacture a violation.
|
|
245
|
+
const files = [
|
|
246
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
|
|
247
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
|
|
248
|
+
];
|
|
249
|
+
assert.deepEqual(ns.numberCollisions(files), []);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
t('numberCollisions: the one pre-existing collision is grandfathered', () => {
|
|
253
|
+
// platform_identity_012 x3 is applied on prod. Renaming an applied migration changes
|
|
254
|
+
// its schema_migrations stem, so migrate.sh would RE-RUN it — the same reason
|
|
255
|
+
// LEGACY_BARE_MODULES and RENAMED_MODULE_PREFIXES exist. It is exempt BY NAME, so a
|
|
256
|
+
// fourth file at that number still fails.
|
|
257
|
+
const three = ['account_privacy', 'project_template', 'recent_ships'].map((s) => ({
|
|
258
|
+
source: 'module', moduleKey: 'platform-identity', basename: `platform_identity_012_${s}.sql`,
|
|
259
|
+
}));
|
|
260
|
+
assert.deepEqual(ns.numberCollisions(three), [], 'the known trio is exempt');
|
|
261
|
+
|
|
262
|
+
// The exemption names STEMS, not the number, so 012 stays CLOSED. A fourth file
|
|
263
|
+
// landing there is the bug this check exists for and must still fail — a bare
|
|
264
|
+
// series/number exemption would have waved it through.
|
|
265
|
+
const four = [...three, {
|
|
266
|
+
source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_012_new.sql',
|
|
267
|
+
}];
|
|
268
|
+
const c4 = ns.numberCollisions(four);
|
|
269
|
+
assert.equal(c4.length, 1, 'a NEW fourth file at the grandfathered number still fails');
|
|
270
|
+
assert.ok(c4[0].files.includes('platform_identity_012_new.sql'),
|
|
271
|
+
'and the new file is named in the violation');
|
|
272
|
+
|
|
273
|
+
// …but a DIFFERENT number in the same module is not exempt — the grandfather is not
|
|
274
|
+
// a blanket pass for platform-identity.
|
|
275
|
+
const other = [
|
|
276
|
+
{ source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_013_a.sql' },
|
|
277
|
+
{ source: 'module', moduleKey: 'platform-identity', basename: 'platform_identity_013_b.sql' },
|
|
278
|
+
];
|
|
279
|
+
assert.equal(ns.numberCollisions(other).length, 1, '013 in the same module still fails');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
t('numberCollisions: non-migration filenames are ignored', () => {
|
|
283
|
+
const files = [
|
|
284
|
+
{ source: 'module', moduleKey: 'economy', basename: 'README.md' },
|
|
285
|
+
{ source: 'module', moduleKey: 'economy', basename: '.keep' },
|
|
286
|
+
{ source: 'module', moduleKey: 'economy', basename: 'economy_012_alpha.sql' },
|
|
287
|
+
];
|
|
288
|
+
assert.deepEqual(ns.numberCollisions(files), []);
|
|
289
|
+
});
|
|
290
|
+
|
|
153
291
|
// ---- nextInNamespace / lineageReport ---------------------------------------
|
|
154
292
|
t('nextInNamespace: counts bare AND prefixed forms (no number reuse)', () => {
|
|
155
293
|
assert.equal(ns.nextInNamespace(['003_a.sql', 'core_188_b.sql', 'core_190_c.sql']), 191,
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// tests/quarantine_canonical.mjs — containment compares WHERE a path points, not how it
|
|
2
|
+
// is spelled (task 1003848).
|
|
3
|
+
//
|
|
4
|
+
// Windows hands out 8.3 short names: os.tmpdir() here is C:\Users\GMK1_P~1\AppData\Local\Temp
|
|
5
|
+
// while the same directory reports as C:\Users\GMK1_ppl51\... elsewhere, and path.resolve
|
|
6
|
+
// does not reconcile them. fs.realpathSync.native expands 8.3; plain fs.realpathSync does NOT.
|
|
7
|
+
//
|
|
8
|
+
// This is not only a test bug. siteRefusal() asks isInside(REPO_ROOT, site) to refuse a
|
|
9
|
+
// quarantine site inside the repository — so on the one platform that hands out two
|
|
10
|
+
// spellings, that refusal was evadable by spelling.
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const q = require('../modules/security/artifact-scan/quarantine.js');
|
|
21
|
+
|
|
22
|
+
const SHORT = os.tmpdir();
|
|
23
|
+
const LONG = fs.realpathSync.native(SHORT);
|
|
24
|
+
|
|
25
|
+
test('canonical expands an 8.3 short name — plain realpathSync does not', () => {
|
|
26
|
+
assert.equal(q.canonical(SHORT), LONG);
|
|
27
|
+
// The premise, stated so this test explains itself on a machine where it does not apply:
|
|
28
|
+
// if the two spellings are identical here, the rest is trivially true rather than wrong.
|
|
29
|
+
if (SHORT === LONG) return;
|
|
30
|
+
assert.notEqual(fs.realpathSync(SHORT), LONG, 'this is the fact the obvious fix gets wrong');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('the two spellings of ONE directory contain each other', () => {
|
|
34
|
+
assert.equal(q.isInside(SHORT, path.join(LONG, 'x')), true);
|
|
35
|
+
assert.equal(q.isInside(LONG, path.join(SHORT, 'x')), true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a genuinely outside path is still outside — the fix narrows nothing', () => {
|
|
39
|
+
assert.equal(q.isInside(LONG, path.resolve('/definitely/elsewhere')), false);
|
|
40
|
+
// and a sibling whose name merely starts with the root is not inside it: this is what
|
|
41
|
+
// a String.startsWith containment check gets wrong, which is how security/mirror failed.
|
|
42
|
+
assert.equal(q.isInside(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a path that does not exist yet still canonicalises via its nearest real ancestor', () => {
|
|
46
|
+
// The ordinary case: a derived destination is validated BEFORE it is written.
|
|
47
|
+
const notYet = path.join(SHORT, 'no-such-dir-1003848', 'deep', 'leaf.txt');
|
|
48
|
+
assert.equal(q.canonical(notYet), path.join(LONG, 'no-such-dir-1003848', 'deep', 'leaf.txt'));
|
|
49
|
+
assert.equal(q.isInside(SHORT, notYet), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a fully nonexistent absolute path degrades to the lexical answer rather than throwing', () => {
|
|
53
|
+
const nowhere = path.resolve('/no-such-root-1003848/a/b');
|
|
54
|
+
assert.equal(typeof q.canonical(nowhere), 'string');
|
|
55
|
+
assert.equal(q.isInside(path.resolve('/no-such-root-1003848'), nowhere), true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('canonical is MEMOISED for paths that EXIST — the hot-loop case', () => {
|
|
59
|
+
// mirror.js's header is explicit about staying cheap; an unmemoised realpath here would
|
|
60
|
+
// put ~20,000 sync syscalls on its 5,000-file loop.
|
|
61
|
+
//
|
|
62
|
+
// BOTH OPERANDS MUST EXIST. Only an EXACT resolution is cacheable (see canonical()):
|
|
63
|
+
// an answer reached by walking up to the nearest existing ancestor is deliberately
|
|
64
|
+
// NOT stored, because the leaf can still be created as a link that escapes. So a
|
|
65
|
+
// not-yet-existing path re-reads every call BY DESIGN, and asserting memoisation
|
|
66
|
+
// against one asserts the opposite of the rule.
|
|
67
|
+
//
|
|
68
|
+
// The first version of this test used <tmpdir>/x and passed only because a stray
|
|
69
|
+
// file of that name happened to exist on the author's box; on CI it did not, the
|
|
70
|
+
// walk-up path was taken, and 50 iterations cost ~100 syscalls instead of 2.
|
|
71
|
+
const dir = fs.mkdtempSync(path.join(LONG, 'memo-'));
|
|
72
|
+
try {
|
|
73
|
+
q.clearCanonicalCache();
|
|
74
|
+
const orig = fs.realpathSync.native;
|
|
75
|
+
let calls = 0;
|
|
76
|
+
fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
|
|
77
|
+
try {
|
|
78
|
+
for (let i = 0; i < 50; i += 1) q.isInside(SHORT, dir);
|
|
79
|
+
} finally {
|
|
80
|
+
fs.realpathSync.native = orig;
|
|
81
|
+
}
|
|
82
|
+
assert.ok(calls <= 4, `50 containment checks over existing paths should not cost ${calls} realpath syscalls`);
|
|
83
|
+
} finally {
|
|
84
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a path that does NOT exist is re-read every time — the cache must not answer for it', () => {
|
|
89
|
+
// The companion rule, and a security property rather than a performance one: a
|
|
90
|
+
// walked-up answer is provisional. Cache it and creating the leaf as an escaping
|
|
91
|
+
// link would keep serving "inside" forever. Pinning it here stops a future
|
|
92
|
+
// optimisation from caching provisional answers to make the test above cheaper.
|
|
93
|
+
const notYet = path.join(LONG, 'no-such-memo-1003848', 'leaf');
|
|
94
|
+
q.clearCanonicalCache();
|
|
95
|
+
const orig = fs.realpathSync.native;
|
|
96
|
+
let calls = 0;
|
|
97
|
+
fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
|
|
98
|
+
try {
|
|
99
|
+
q.canonical(notYet);
|
|
100
|
+
q.canonical(notYet);
|
|
101
|
+
} finally {
|
|
102
|
+
fs.realpathSync.native = orig;
|
|
103
|
+
}
|
|
104
|
+
assert.ok(calls >= 4, `two lookups of a non-existent path must both hit the filesystem, saw ${calls}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a path that does NOT exist yet is never cached — the escape must stay visible', () => {
|
|
108
|
+
// THE SECURITY PROPERTY, and the one the first pass of this fix got wrong.
|
|
109
|
+
//
|
|
110
|
+
// A derived destination is canonicalised BEFORE it is written, so its answer comes from
|
|
111
|
+
// walking up to the nearest existing ancestor. That answer is true only until the path
|
|
112
|
+
// is written. Cache it and the window never closes: create the path as a symlink (a
|
|
113
|
+
// junction on Windows) pointing out of the pen and isInside() keeps returning the
|
|
114
|
+
// pre-creation verdict — INSIDE — so mirror.js's path_escape refusal is silently lost.
|
|
115
|
+
// The containment check this file exists to harden is then evadable one layer down, by
|
|
116
|
+
// its own memoisation.
|
|
117
|
+
//
|
|
118
|
+
// Asserted as the MECHANISM rather than by building a symlink, deliberately: creating
|
|
119
|
+
// one needs privileges this suite does not have on Windows, so a symlink-shaped test
|
|
120
|
+
// would skip on the very platform whose 8.3 spellings motivated the whole change. The
|
|
121
|
+
// mechanism is platform-free — a missing path must cost a fresh filesystem read every
|
|
122
|
+
// time — and modules/security/tests/mirror.mjs covers the real symlink end-to-end
|
|
123
|
+
// wherever symlinks can be made.
|
|
124
|
+
q.clearCanonicalCache();
|
|
125
|
+
const missing = path.join(SHORT, 'not-written-yet-1003848', 'leaf.txt');
|
|
126
|
+
|
|
127
|
+
const orig = fs.realpathSync.native;
|
|
128
|
+
let calls = 0;
|
|
129
|
+
fs.realpathSync.native = (p2) => { calls += 1; return orig(p2); };
|
|
130
|
+
try {
|
|
131
|
+
q.canonical(missing);
|
|
132
|
+
const afterFirst = calls;
|
|
133
|
+
q.canonical(missing);
|
|
134
|
+
assert.ok(calls > afterFirst,
|
|
135
|
+
'a non-existent path must be re-read, not served from cache — otherwise a symlink '
|
|
136
|
+
+ 'created there afterwards keeps the stale INSIDE verdict');
|
|
137
|
+
} finally {
|
|
138
|
+
fs.realpathSync.native = orig;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('an EXISTING directory is still cached — the fix keeps the memoisation that mattered', () => {
|
|
143
|
+
// The counterweight to the test above: mirror.js's hot loop canonicalises real
|
|
144
|
+
// directories over and over, and those answers are stable (a directory cannot move
|
|
145
|
+
// under a running scan). Only the provisional ones are re-read.
|
|
146
|
+
q.clearCanonicalCache();
|
|
147
|
+
q.canonical(SHORT);
|
|
148
|
+
const orig = fs.realpathSync.native;
|
|
149
|
+
let calls = 0;
|
|
150
|
+
fs.realpathSync.native = (p2) => { calls += 1; return orig(p2); };
|
|
151
|
+
try {
|
|
152
|
+
for (let i = 0; i < 20; i += 1) q.canonical(SHORT);
|
|
153
|
+
} finally {
|
|
154
|
+
fs.realpathSync.native = orig;
|
|
155
|
+
}
|
|
156
|
+
assert.equal(calls, 0, 'an existing directory resolved once must not be resolved again');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('clearing the cache makes it read the filesystem again', () => {
|
|
160
|
+
q.clearCanonicalCache();
|
|
161
|
+
const orig = fs.realpathSync.native;
|
|
162
|
+
let calls = 0;
|
|
163
|
+
fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
|
|
164
|
+
try { q.canonical(SHORT); } finally { fs.realpathSync.native = orig; }
|
|
165
|
+
assert.ok(calls >= 1, 'a cleared cache must not keep answering from memory');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// LEXICAL vs PHYSICAL containment (task 1003848, grade round 2).
|
|
170
|
+
//
|
|
171
|
+
// sanitize.js runs three confinement arms in order, each with its own refusal:
|
|
172
|
+
// lexical (does the manifest path climb out?), then physical (does it RESOLVE
|
|
173
|
+
// out?), then lstat (is it a link the manifest called a file?). Canonicalising
|
|
174
|
+
// the lexical arm made it follow the link and fire first, pre-empting the other
|
|
175
|
+
// two — the artifact was still refused, but with the wrong diagnosis, and the two
|
|
176
|
+
// tests pinning those messages went red.
|
|
177
|
+
//
|
|
178
|
+
// The existing cases live in modules/security/tests/sanitize_rebuild.mjs and are
|
|
179
|
+
// SKIPPED wherever symlinks cannot be created — which is this platform, so that
|
|
180
|
+
// regression was invisible locally and only CI saw it. This case therefore falls
|
|
181
|
+
// back to a DIRECTORY JUNCTION, which Windows allows without elevation, so the
|
|
182
|
+
// divergence is pinned on both platforms.
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
const linkKind = (() => {
|
|
186
|
+
const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'linkprobe-'));
|
|
187
|
+
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'linktarget-'));
|
|
188
|
+
try {
|
|
189
|
+
for (const kind of ['dir', 'junction']) {
|
|
190
|
+
try { fs.symlinkSync(target, path.join(probe, `l-${kind}`), kind); return kind; }
|
|
191
|
+
catch { /* try the next form */ }
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
} finally {
|
|
195
|
+
fs.rmSync(probe, { recursive: true, force: true });
|
|
196
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
})();
|
|
199
|
+
|
|
200
|
+
test('a link that escapes is LEXICALLY inside but PHYSICALLY outside', { skip: linkKind ? false : 'this platform cannot create links' }, () => {
|
|
201
|
+
const pen = fs.mkdtempSync(path.join(os.tmpdir(), 'pen-'));
|
|
202
|
+
const pristine = path.join(pen, 'pristine');
|
|
203
|
+
fs.mkdirSync(pristine);
|
|
204
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'));
|
|
205
|
+
try {
|
|
206
|
+
const link = path.join(pristine, 'escape');
|
|
207
|
+
fs.symlinkSync(outside, link, linkKind);
|
|
208
|
+
q.clearCanonicalCache();
|
|
209
|
+
|
|
210
|
+
// The lexical arm must NOT fire: nothing in the SPELLING of this path climbs
|
|
211
|
+
// out. If it fired, sanitize.js would report "escapes the pristine tree" and
|
|
212
|
+
// never reach the two refusals that describe what is actually wrong.
|
|
213
|
+
assert.equal(q.isInsideLexical(pristine, link), true);
|
|
214
|
+
|
|
215
|
+
// The physical arm is what catches it — this is the containment that matters.
|
|
216
|
+
assert.equal(q.isInside(pristine, link), false);
|
|
217
|
+
} finally {
|
|
218
|
+
fs.rmSync(pen, { recursive: true, force: true });
|
|
219
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('on ordinary paths the two predicates agree — the split narrows nothing', () => {
|
|
224
|
+
const inside = path.join(LONG, 'a', 'b');
|
|
225
|
+
assert.equal(q.isInsideLexical(LONG, inside), q.isInside(LONG, inside));
|
|
226
|
+
assert.equal(q.isInsideLexical(LONG, inside), true);
|
|
227
|
+
const climbs = path.join(LONG, '..', 'elsewhere');
|
|
228
|
+
assert.equal(q.isInsideLexical(LONG, climbs), false);
|
|
229
|
+
assert.equal(q.isInside(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
|
|
230
|
+
assert.equal(q.isInsideLexical(path.join(LONG, 'pen'), path.join(LONG, 'pen-evil', 'f')), false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('the lexical predicate never touches the filesystem', () => {
|
|
234
|
+
// The whole point: its answer must not depend on what the leaf points at right
|
|
235
|
+
// now. A realpath call here would reintroduce exactly the coupling being removed.
|
|
236
|
+
const orig = fs.realpathSync.native;
|
|
237
|
+
let calls = 0;
|
|
238
|
+
fs.realpathSync.native = (p) => { calls += 1; return orig(p); };
|
|
239
|
+
try {
|
|
240
|
+
q.isInsideLexical(LONG, path.join(LONG, 'whatever', 'deep.txt'));
|
|
241
|
+
} finally {
|
|
242
|
+
fs.realpathSync.native = orig;
|
|
243
|
+
}
|
|
244
|
+
assert.equal(calls, 0, 'isInsideLexical must be pure path arithmetic');
|
|
245
|
+
});
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// Run: node tests/search_index_poller.mjs
|
|
14
14
|
|
|
15
15
|
import { strict as assert } from 'node:assert';
|
|
16
|
+
import path from 'node:path';
|
|
16
17
|
import { createRequire } from 'node:module';
|
|
17
18
|
|
|
18
19
|
const require = createRequire(import.meta.url);
|
|
@@ -104,7 +105,11 @@ function fakePool(seq) {
|
|
|
104
105
|
await test('the sweep runs BOTH ingesters — one failing must not skip the other', async () => {
|
|
105
106
|
const called = [];
|
|
106
107
|
const exec = (_p, args, _o, cb) => {
|
|
107
|
-
|
|
108
|
+
// path.basename, not split('/') — the sweep passes an ABSOLUTE path, and on
|
|
109
|
+
// Windows that is backslash-separated, so splitting on a forward slash returned
|
|
110
|
+
// the whole path and this assertion compared a path against a basename (task
|
|
111
|
+
// 1003848). The production code was never wrong; the test helper was.
|
|
112
|
+
called.push(path.basename(args[0]));
|
|
108
113
|
cb(args[0].includes('md') ? Object.assign(new Error('x'), { code: 1 }) : null, '', 'boom');
|
|
109
114
|
};
|
|
110
115
|
const r = await sweep({ pool: fakePool([{ chunks: 0, last: null }]), log: quietLog, execFileImpl: exec, now: NOW });
|
|
@@ -161,6 +161,14 @@ const client = await dbReachable();
|
|
|
161
161
|
if (!client) {
|
|
162
162
|
console.log(' SKIP no reachable Postgres — Layer C runs in the integration gate / on a dev box.');
|
|
163
163
|
console.log(' (The same scoping cases were proven on the prod DB in a rolled-back transaction at ship time.)');
|
|
164
|
+
// END THE POOL ON THE SKIP PATH TOO (task 1003848). Only the else-branch below
|
|
165
|
+
// ended it, so when Postgres was unreachable the Pool survived with libuv handles
|
|
166
|
+
// still closing from its failed connect — and the process.exit() at the bottom of
|
|
167
|
+
// this file then tore down the loop underneath them. On Windows that surfaces as
|
|
168
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`, exit
|
|
169
|
+
// 0xC0000409: a process-teardown crash AFTER '5 passed, 0 failed', which reads as
|
|
170
|
+
// a failing suite in the unit gate while every assertion actually passed.
|
|
171
|
+
if (pool) await pool.end().catch(() => {});
|
|
164
172
|
} else {
|
|
165
173
|
try {
|
|
166
174
|
await client.query('BEGIN');
|
|
@@ -204,4 +212,11 @@ if (!client) {
|
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
207
|
-
process.exit(
|
|
215
|
+
// EXIT CODE, NOT process.exit() (task 1003848). A hard exit tears the event loop down
|
|
216
|
+
// underneath any handle that is still closing — and pg leaves one behind after a
|
|
217
|
+
// FAILED connect, which is exactly the skip path. On Windows that surfaced as
|
|
218
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c`
|
|
219
|
+
// and exit 0xC0000409 AFTER "5 passed, 0 failed" — a process-teardown crash that the
|
|
220
|
+
// unit gate can only read as a failing suite. Setting exitCode lets node drain the
|
|
221
|
+
// handles and then exit with the same status.
|
|
222
|
+
process.exitCode = failed === 0 ? 0 : 1;
|