border-customs 0.1.0 → 0.3.1
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 +256 -40
- package/dist/assets/residue-src/crates.ts +228 -0
- package/dist/assets/residue-src/npm.ts +294 -0
- package/dist/assets/residue-src/pypi.ts +333 -0
- package/dist/assets/residue-src/residue.ts +348 -0
- package/dist/assets/residue-src/residueGem.ts +371 -0
- package/dist/assets/residue-src/residueMatchers.ts +417 -0
- package/dist/assets/residue-src/residuePy.ts +26 -0
- package/dist/assets/residue-src/residueRust.ts +252 -0
- package/dist/assets/residue-src/rubygems.ts +278 -0
- package/dist/index.js +5126 -2825
- package/package.json +6 -2
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// provenance: .omo/plans/border-residue-gate.md R3b (rubygems leg) — extconf.rb/gemspec-extensions
|
|
2
|
+
// scan + T4 artifact-wide pass over .gem data; semantics frozen by src/artifacts/RESIDUE-CONTRACT.md
|
|
3
|
+
// §8 R3b amendment. Structural template: residueRust.ts (dedicated per-family leg; unparseable ⇒
|
|
4
|
+
// violation; closed tables live ONLY in ../rules/residueMatchers.ts). Container bytes (metadata.gz /
|
|
5
|
+
// data.tar.gz layout, extensions YAML shape) measured with gem 3.6.7, probe 2026-09-09; the caller
|
|
6
|
+
// (src/artifacts/rubygems.ts) unpacks with the existing extract shim and feeds this pure scanner.
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
GEM_ABSOLUTE_LITERAL,
|
|
11
|
+
GEM_ASSIGNMENT,
|
|
12
|
+
GEM_BARE_IDENTIFIER,
|
|
13
|
+
GEM_CHDIR_CALL,
|
|
14
|
+
GEM_EXTENSIONS_KEYS,
|
|
15
|
+
GEM_EXTCONF_BASENAME,
|
|
16
|
+
GEM_HOOK_CALL,
|
|
17
|
+
GEM_OUT_OF_TREE_MARKS,
|
|
18
|
+
GEM_PERCENT_LITERAL,
|
|
19
|
+
GEM_PERCENT_PAIRS,
|
|
20
|
+
GEM_PERSISTENCE_BASENAMES,
|
|
21
|
+
GEM_ROOTED_SLASH_ARG,
|
|
22
|
+
GEM_WRITE_CALL,
|
|
23
|
+
GEM_YAML_COMMENT,
|
|
24
|
+
GEM_YAML_KEY_TERM,
|
|
25
|
+
GEM_YAML_SEQ_ITEM,
|
|
26
|
+
RESIDUE_GEM_UNMATCHED_EXT_RULE,
|
|
27
|
+
RESIDUE_T4_RULE,
|
|
28
|
+
RESIDUE_T3_RULE,
|
|
29
|
+
} from "../rules/residueMatchers.ts";
|
|
30
|
+
import { type FamilyWording, type ResidueHit, evidenceLine, familyHits, readText, t4HitFor } from "./residue.ts";
|
|
31
|
+
|
|
32
|
+
/** familyHits wording for the gem lane (contract §8 "Message wording parameterization"). */
|
|
33
|
+
export const GEM_WORDING: FamilyWording = { what: "gem install-time", ledger: "gem" };
|
|
34
|
+
|
|
35
|
+
/** One T4 row per file for the auto-load / hook-registration lanes (plan R3b (b)): the plugin
|
|
36
|
+
* basename outranks the token lane when a file is both (they would double up on one (rule,file)). */
|
|
37
|
+
function gemPersistenceHit(rel: string, text: string): ResidueHit | null {
|
|
38
|
+
if (GEM_PERSISTENCE_BASENAMES.includes(basename(rel))) {
|
|
39
|
+
return {
|
|
40
|
+
rule: RESIDUE_T4_RULE,
|
|
41
|
+
severity: "CRITICAL",
|
|
42
|
+
message: `packed file '${rel}' is a RubyGems plugin: rubygems_plugin.rb is auto-loaded by EVERY later gem/ruby process after install — persistence outlives the install session, the worst shape in the ledger family (T4)`,
|
|
43
|
+
fileRel: rel,
|
|
44
|
+
patternId: "gem-autoload-plugin",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (GEM_HOOK_CALL.test(text)) {
|
|
48
|
+
return {
|
|
49
|
+
rule: RESIDUE_T4_RULE,
|
|
50
|
+
severity: "CRITICAL",
|
|
51
|
+
message: `packed file '${rel}' registers Gem installer hooks: "${evidenceLine(text, GEM_HOOK_CALL)}" — the callback runs on every future install/uninstall in this rubygems process (T4)`,
|
|
52
|
+
fileRel: rel,
|
|
53
|
+
patternId: "gem-hook-token",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Slice the write/exec argument span after a GEM_WRITE_CALL match: `(`-opening calls read the WHOLE
|
|
60
|
+
* argument list (BLK-5 parity: `FileUtils.cp(src, "/usr/local/bin/dst")` escapes through its
|
|
61
|
+
* destination operand, so confinement judges every argument, not just the first); a call whose verb
|
|
62
|
+
* match ends in a non-word, non-space delimiter (bare `system "cmd"`, a backtick, or any BLK-6 %x
|
|
63
|
+
* opener) scans to that delimiter's closer — paired for ( [ { <, the char itself otherwise,
|
|
64
|
+
* mirroring GEM_PERCENT_PAIRS; a bare verb + `%` operand (BLK-R3bF4-B: `system %q{…}`, `exec %w[…]`)
|
|
65
|
+
* skips its designator letters, resolves the closer through the same pair table and returns the
|
|
66
|
+
* QUOTED PAYLOAD only (BLK-R3bF5-A: the `%<desig><delim>` prefix must be stripped exactly like the
|
|
67
|
+
* %x arm's own slice, or a rooted token (`system %x|/usr/…|`) hides behind the delimiter and the
|
|
68
|
+
* ROOTED `^`-anchor plus the expansion regex both miss it — box ruby 3.3.8 executed all four
|
|
69
|
+
* `%x|…| %x!…! %x%…% %x@…@` bare-verb payloads live, 0 rows, probe 2026-09-09). For %q/%Q the quoted
|
|
70
|
+
* payload is byte-identical to what gemExpandPercentLiterals produced from the full literal, so ONE
|
|
71
|
+
* expansion path judges it; anything else
|
|
72
|
+
* (paren-less verb + unknown operand) ⇒ null ⇒ fail-closed. */
|
|
73
|
+
function gemVerbSpan(text: string, verbEnd: number, verb: string): string | null {
|
|
74
|
+
const last = verb.charAt(verb.length - 1);
|
|
75
|
+
if (last === "(") return gemCallArgs(text, verbEnd - 1);
|
|
76
|
+
if (last === "%" && !verb.startsWith("%")) {
|
|
77
|
+
let i = verbEnd;
|
|
78
|
+
while (i < text.length && /[A-Za-z]/u.test(text.charAt(i))) i += 1;
|
|
79
|
+
const delim = text.charAt(i);
|
|
80
|
+
if (delim === "") return null;
|
|
81
|
+
const closer = GEM_PERCENT_PAIRS[delim] ?? delim;
|
|
82
|
+
for (let j = i + 1; j < text.length; j += 1) {
|
|
83
|
+
if (text.charAt(j) === "\\") j += 1;
|
|
84
|
+
else if (text.charAt(j) === closer) return `"${text.slice(i + 1, j)}"`;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (/^[^\w\s(]$/u.test(last)) {
|
|
89
|
+
const closer = last === "[" ? "]" : last === "{" ? "}" : last === "<" ? ">" : last;
|
|
90
|
+
for (let i = verbEnd; i < text.length; i += 1) {
|
|
91
|
+
const ch = text.charAt(i);
|
|
92
|
+
if (ch === "\\") i += 1;
|
|
93
|
+
else if (ch === closer) return text.slice(verbEnd, i);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
let i = verbEnd;
|
|
98
|
+
while (i < text.length && (text.charAt(i) === " " || text.charAt(i) === "\t")) i += 1;
|
|
99
|
+
return text.charAt(i) === "(" ? gemCallArgs(text, i) : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Full argument text of a call whose `(` sits at `open`: quote-aware scan to the matching close
|
|
103
|
+
* paren (nested (), [], {} tracked); unbalanced ⇒ null so the caller fails closed (R3b-F shape). */
|
|
104
|
+
function gemCallArgs(text: string, open: number): string | null {
|
|
105
|
+
let depth = 0;
|
|
106
|
+
let quote: string | null = null;
|
|
107
|
+
for (let i = open; i < text.length; i += 1) {
|
|
108
|
+
const ch = text.charAt(i);
|
|
109
|
+
if (quote !== null) {
|
|
110
|
+
if (ch === "\\") i += 1;
|
|
111
|
+
else if (ch === quote) quote = null;
|
|
112
|
+
} else if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
113
|
+
else if (ch === "(" || ch === "[" || ch === "{") depth += 1;
|
|
114
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
115
|
+
depth -= 1;
|
|
116
|
+
if (depth === 0) return text.slice(open + 1, i);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** BLK-4b: rewrite `%q{…}` / `%Q(…)` percent literals as quoted strings so the shared mark tests see
|
|
123
|
+
* their literal text; an unterminated literal is passed through unchanged (other arms still fail
|
|
124
|
+
* closed on it). Scan is single-level (nested same-delimiter pairs cost a row via null spans). */
|
|
125
|
+
function gemExpandPercentLiterals(s: string): string {
|
|
126
|
+
let out = "";
|
|
127
|
+
let i = 0;
|
|
128
|
+
while (i < s.length) {
|
|
129
|
+
const open = GEM_PERCENT_LITERAL.exec(s.slice(i));
|
|
130
|
+
if (open === null || open.index === undefined) {
|
|
131
|
+
out += s.slice(i);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
out += s.slice(i, i + open.index);
|
|
135
|
+
const delim = open[1] as string;
|
|
136
|
+
const closer = GEM_PERCENT_PAIRS[delim] ?? delim;
|
|
137
|
+
let j = i + open.index + 3;
|
|
138
|
+
while (j < s.length && s.charAt(j) !== closer) {
|
|
139
|
+
if (s.charAt(j) === "\\") j += 1;
|
|
140
|
+
j += 1;
|
|
141
|
+
}
|
|
142
|
+
if (j >= s.length) {
|
|
143
|
+
out += s.slice(i + open.index, j);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
out += `"${s.slice(i + open.index + 3, j)}"`;
|
|
147
|
+
i = j + 1;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The shared escape predicate: out-of-tree mark (BLK-R3bF2-A: now RegExp, covering the live `Dir::home`
|
|
153
|
+
* / `ENV::[]` / `ENV::fetch` colon spellings), rooted-quoted literal, or (BLK-4a + BLK-R3bF2-C) a
|
|
154
|
+
* rooted BARE token after whitespace OR an opening bracket (`%(/usr/…)`, `%w[/usr/…]`) — all judged on
|
|
155
|
+
* the percent-literal-expanded text. No /g flags on the mark table: .test() must stay stateless. */
|
|
156
|
+
const gemSpanEscapes = (span: string): boolean => {
|
|
157
|
+
const eff = gemExpandPercentLiterals(span);
|
|
158
|
+
return GEM_OUT_OF_TREE_MARKS.some((k) => k.test(eff)) || GEM_ABSOLUTE_LITERAL.test(eff) || GEM_ROOTED_SLASH_ARG.test(eff);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** BLK-2 (R3a ALL-bindings doctrine): capture EVERY single-`=` assignment line `id = rhs` in the
|
|
162
|
+
* extconf (re-assignments keep all variants; `==`/`=~`/`+=` shapes never match the grammar). */
|
|
163
|
+
function gemStringBindings(text: string): Map<string, string[]> {
|
|
164
|
+
const map = new Map<string, string[]>();
|
|
165
|
+
for (const line of text.split("\n")) {
|
|
166
|
+
const mm = GEM_ASSIGNMENT.exec(line);
|
|
167
|
+
if (mm === null) continue;
|
|
168
|
+
const id = mm[1] as string;
|
|
169
|
+
const rhs = (mm[2] as string).trim();
|
|
170
|
+
const prev = map.get(id);
|
|
171
|
+
if (prev === undefined) map.set(id, [rhs]);
|
|
172
|
+
else prev.push(rhs);
|
|
173
|
+
}
|
|
174
|
+
return map;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Reference pattern for a binding id inside rhs texts (BLK-8): plain word-only ids (locals,
|
|
178
|
+
* CONSTANTS) keep \\b anchors; ids carrying @/$/? sigils escape the metacharacters and anchor with
|
|
179
|
+
* lookarounds, since `\\b` is undefined at a non-word sigil edge (`\\b@o\\b` never matches). */
|
|
180
|
+
const gemRefPattern = (id: string, flags: "u" | "gu"): RegExp => {
|
|
181
|
+
const esc = id.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
182
|
+
return new RegExp(/^[\w]+$/u.test(id) ? `\\b${esc}\\b` : `(?<![\\w])${esc}(?![\\w])`, flags);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Resolve an id to the concatenation of its bound texts, substituting other bound ids recursively
|
|
186
|
+
* (transitive). Cycle, self-reference, or re-entry ⇒ null = unresolvable (caller fail-closes). */
|
|
187
|
+
function gemResolveBinding(map: Map<string, string[]>, id: string, seen: ReadonlySet<string>): string | null {
|
|
188
|
+
if (seen.has(id)) return null;
|
|
189
|
+
const rhss = map.get(id);
|
|
190
|
+
if (rhss === undefined) return null;
|
|
191
|
+
const next = new Set(seen);
|
|
192
|
+
next.add(id);
|
|
193
|
+
const selfRef = gemRefPattern(id, "u");
|
|
194
|
+
const parts: string[] = [];
|
|
195
|
+
for (const rhs of rhss) {
|
|
196
|
+
if (selfRef.test(rhs)) return null;
|
|
197
|
+
let out = rhs;
|
|
198
|
+
for (const other of map.keys()) {
|
|
199
|
+
if (other === id) continue;
|
|
200
|
+
if (!gemRefPattern(other, "u").test(out)) continue;
|
|
201
|
+
const nested = gemResolveBinding(map, other, next);
|
|
202
|
+
if (nested === null) return null;
|
|
203
|
+
out = out.replace(gemRefPattern(other, "gu"), nested);
|
|
204
|
+
}
|
|
205
|
+
parts.push(out);
|
|
206
|
+
}
|
|
207
|
+
return parts.join(" ; ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Plan R3b (a) + BLK-2/BLK-4/BLK-5/BLK-8 + BLK-R3bF2-A/B/C/D/E: an extconf.rb write/exec span (the
|
|
211
|
+
* WHOLE argument list, BLK-5) is confinement-legitimate ONLY when the (percent-literal-expanded) text
|
|
212
|
+
* carries no out-of-tree mark ($HOME, Dir.home/Dir::home, every ENV lookup spelling, `~/`), no rooted
|
|
213
|
+
* quoted literal and no rooted BARE token after whitespace or an opening bracket. EVERY bare-identifier
|
|
214
|
+
* fragment — local, @ivar, @@cvar, $global/$0-style builtin, or CONSTANT — resolves through the file's
|
|
215
|
+
* ALL-bindings map: escaping resolution ⇒ HIGH; a heredoc (`<<`-starting) RHS is never certifiable ⇒
|
|
216
|
+
* HIGH unconditionally (BLK-R3bF2-E); unresolvable (never bound — @@cvars and special globals are not
|
|
217
|
+
* LHS-grammar keys) while a rooted literal sits anywhere in the file ⇒ HIGH (fail closed);
|
|
218
|
+
* relative/in-tree ⇒ confined. Dir.chdir (either separator, BLK-5) anywhere in the file is itself a
|
|
219
|
+
* confinement break ⇒ one HIGH row. Marks beat everything (R3a F1 doctrine); an unparseable span rows
|
|
220
|
+
* HIGH. Method-return bindings and `+=` churn still escape — named in §8 Residual limitations. */
|
|
221
|
+
function gemConfinementHits(rel: string, text: string): ResidueHit[] {
|
|
222
|
+
const hits: ResidueHit[] = [];
|
|
223
|
+
if (GEM_CHDIR_CALL.test(text)) {
|
|
224
|
+
hits.push({
|
|
225
|
+
rule: RESIDUE_T3_RULE,
|
|
226
|
+
severity: "HIGH",
|
|
227
|
+
message: `gem install-time code in '${rel}' calls Dir.chdir — the extconf CWD moves, so every later relative write follows it out of the build tree: "${evidenceLine(text, GEM_CHDIR_CALL)}" — exotic-operand cost doctrine: any chdir rows, in-tree or not (T3)`,
|
|
228
|
+
fileRel: rel,
|
|
229
|
+
patternId: "gem-confinement",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const bindings = gemStringBindings(text);
|
|
233
|
+
const rootedLiteralPresent = GEM_ABSOLUTE_LITERAL.test(text);
|
|
234
|
+
for (const m of text.matchAll(GEM_WRITE_CALL)) {
|
|
235
|
+
const verb = m[0] as string;
|
|
236
|
+
const span = gemVerbSpan(text, (m.index ?? 0) + verb.length, verb);
|
|
237
|
+
let escaped = false;
|
|
238
|
+
let evidence = "";
|
|
239
|
+
if (span === null) {
|
|
240
|
+
escaped = true;
|
|
241
|
+
evidence = "unparseable write/exec argument (fail closed)";
|
|
242
|
+
} else if (gemSpanEscapes(span)) {
|
|
243
|
+
escaped = true;
|
|
244
|
+
evidence = `"${span.trim().slice(0, 120)}"`;
|
|
245
|
+
} else {
|
|
246
|
+
for (const frag of span.split(",")) {
|
|
247
|
+
const operand = frag.trim();
|
|
248
|
+
if (!GEM_BARE_IDENTIFIER.test(operand)) continue;
|
|
249
|
+
const operandRhss = bindings.get(operand);
|
|
250
|
+
if (operandRhss?.some((r) => r.startsWith("<<"))) {
|
|
251
|
+
escaped = true;
|
|
252
|
+
evidence = `unresolvable heredoc-bound operand '${operand}' — the <<RHS holds a multi-line value invisible to the line grammar (fail closed)`;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
const resolved = gemResolveBinding(bindings, operand, new Set());
|
|
256
|
+
if (resolved === null) {
|
|
257
|
+
if (rootedLiteralPresent) {
|
|
258
|
+
escaped = true;
|
|
259
|
+
evidence = `unresolvable bareword operand '${operand}' while a rooted-path literal exists in the same file (fail closed)`;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
} else if (gemSpanEscapes(resolved)) {
|
|
263
|
+
escaped = true;
|
|
264
|
+
evidence = `${operand} resolves to "${resolved.trim().slice(0, 120)}"`;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!escaped) continue;
|
|
270
|
+
hits.push({
|
|
271
|
+
rule: RESIDUE_T3_RULE,
|
|
272
|
+
severity: "HIGH",
|
|
273
|
+
message: `gem install-time code in '${rel}' writes outside the extension build tree: ${verb.trim()} ${evidence} — rubygems executes extconf.rb with the unpacked extension dir as CWD; only in-tree/$extout staging is legitimate (T3)`,
|
|
274
|
+
fileRel: rel,
|
|
275
|
+
patternId: "gem-confinement",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return hits;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const normalizeRel = (rel: string): string => rel.replace(/^\.\//u, "");
|
|
282
|
+
|
|
283
|
+
/** metadata.gz `extensions:` YAML (flow `[]`/`["a"]` or block seq, gem 3.6.7 measured shapes).
|
|
284
|
+
* Missing key or `[]` ⇒ [] (pure-ruby gem is T0 silent). Block-seq truth (BLK-3): blank AND comment
|
|
285
|
+
* lines are skipped without terminating collection; the region ends only at the next top-level
|
|
286
|
+
* mapping key or EOF (blanks/comments/EOF ⇒ YAML null ⇒ legitimately zero declarations). Any other
|
|
287
|
+
* unrecognized line inside the region THROWS — the caller turns it into an EngineRunError, never a
|
|
288
|
+
* silent clean (plan R3b AC: declared-but-missing must surface). BLK-7 (VERIFIER-REPORT-R3B-FIX):
|
|
289
|
+
* the scan counts EVERY top-level `extensions:` occurrence first — psych/rubygems resolve duplicate
|
|
290
|
+
* keys LAST-wins (box-measured 2026-09-09) while any first/last-wins single read certifies one side
|
|
291
|
+
* of a crafted `extensions: []` + block-seq pair, so >1 occurrence THROWS (both dup orders, never a
|
|
292
|
+
* silent clean). `files:` needs no twin guard: this leg never reads it — data-side facts come from
|
|
293
|
+
* the actual data.tar.gz member listing, not from metadata. */
|
|
294
|
+
export function gemDeclaredExtensions(metadata: string): string[] {
|
|
295
|
+
const occurrences = [...metadata.matchAll(GEM_EXTENSIONS_KEYS)];
|
|
296
|
+
if (occurrences.length > 1) {
|
|
297
|
+
throw new Error(`duplicate extensions: key (${occurrences.length} top-level declarations) — cannot certify which one rubygems loads (psych is last-wins); failing closed`);
|
|
298
|
+
}
|
|
299
|
+
const m = occurrences[0];
|
|
300
|
+
if (m === undefined) return [];
|
|
301
|
+
const rest = (m[1] ?? "").trim();
|
|
302
|
+
if (rest === "") {
|
|
303
|
+
const items: string[] = [];
|
|
304
|
+
for (const line of metadata.slice((m.index ?? 0) + m[0].length + 1).split("\n")) {
|
|
305
|
+
if (line.trim() === "" || GEM_YAML_COMMENT.test(line)) continue;
|
|
306
|
+
const item = GEM_YAML_SEQ_ITEM.exec(line);
|
|
307
|
+
if (item !== null) {
|
|
308
|
+
items.push((item[1] as string).trim());
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (GEM_YAML_KEY_TERM.test(line)) break;
|
|
312
|
+
throw new Error(`unrecognized line inside the extensions: block (${JSON.stringify(line.slice(0, 60))}) — cannot certify the build list clean`);
|
|
313
|
+
}
|
|
314
|
+
return items;
|
|
315
|
+
}
|
|
316
|
+
if (rest === "[]") return [];
|
|
317
|
+
if (rest.startsWith("[") && rest.endsWith("]")) {
|
|
318
|
+
return rest
|
|
319
|
+
.slice(1, -1)
|
|
320
|
+
.split(",")
|
|
321
|
+
.map((s) => s.trim().replace(/^["']|["']$/gu, ""))
|
|
322
|
+
.filter((s) => s !== "");
|
|
323
|
+
}
|
|
324
|
+
throw new Error(`unrecognized extensions: declaration in metadata.gz (${JSON.stringify(rest.slice(0, 60))}) — cannot certify the build list clean`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Plan R3b AC: every declared extension must have a matching extconf.rb in data.tar.gz; each
|
|
328
|
+
* unmatchable build gets EXACTLY ONE MEDIUM note (dupes in the declaration collapse to one row),
|
|
329
|
+
* never a silent clean and never a second note for the same miss. */
|
|
330
|
+
function gemUnmatchedExtensionHits(metadata: string, dataRels: ReadonlySet<string>): ResidueHit[] {
|
|
331
|
+
const hits: ResidueHit[] = [];
|
|
332
|
+
const noted = new Set<string>();
|
|
333
|
+
for (const entry of gemDeclaredExtensions(metadata)) {
|
|
334
|
+
const expected = normalizeRel(entry.endsWith(`/${GEM_EXTCONF_BASENAME}`) ? entry : `${entry}/${GEM_EXTCONF_BASENAME}`);
|
|
335
|
+
if (dataRels.has(expected) || noted.has(expected)) continue;
|
|
336
|
+
noted.add(expected);
|
|
337
|
+
hits.push({
|
|
338
|
+
rule: RESIDUE_GEM_UNMATCHED_EXT_RULE,
|
|
339
|
+
severity: "MEDIUM",
|
|
340
|
+
message: `metadata.gz declares extension '${entry}' but data.tar.gz contains no '${expected}' — the native build is unmatchable: rubygems cannot run an extconf that is not shipped (plan R3b: never a silent clean)`,
|
|
341
|
+
fileRel: expected,
|
|
342
|
+
patternId: "gem-unmatched-extension",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return hits;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Full rubygems leg over the UNPACKED data tree (pkgDir = inner extraction root, rels = its files)
|
|
349
|
+
* plus the gunzipped metadata.gz text. Scan set (plan R3b): every file gets the artifact-wide T4
|
|
350
|
+
* sweep + the auto-load/hook lane; install-time-reachable files (extconf.rb anywhere, plugin files,
|
|
351
|
+
* hook-registering files) additionally get the family scan (T2/T3/xmgr) — bin/lib/Rakefile at rest
|
|
352
|
+
* do NOT (reachability decision, §8). extconf.rb alone gets the write-confinement lane. */
|
|
353
|
+
export function residueGemHits(pkgDir: string, rels: readonly string[], metadata: string): ResidueHit[] {
|
|
354
|
+
const hits: ResidueHit[] = [];
|
|
355
|
+
const dataRels = new Set(rels.map(normalizeRel));
|
|
356
|
+
for (const raw of rels) {
|
|
357
|
+
const rel = normalizeRel(raw);
|
|
358
|
+
const text = readText(pkgDir, rel);
|
|
359
|
+
if (text === null) continue;
|
|
360
|
+
const base = basename(rel);
|
|
361
|
+
const isExtconf = base === GEM_EXTCONF_BASENAME;
|
|
362
|
+
const persistence = gemPersistenceHit(rel, text);
|
|
363
|
+
if (persistence !== null) hits.push(persistence);
|
|
364
|
+
if (isExtconf || persistence !== null) hits.push(...familyHits(rel, text, GEM_WORDING));
|
|
365
|
+
if (isExtconf) hits.push(...gemConfinementHits(rel, text));
|
|
366
|
+
const t4 = t4HitFor(rel, text);
|
|
367
|
+
if (t4 !== null) hits.push(t4);
|
|
368
|
+
}
|
|
369
|
+
hits.push(...gemUnmatchedExtensionHits(metadata, dataRels));
|
|
370
|
+
return hits;
|
|
371
|
+
}
|