euthyna 0.1.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/LICENSE +202 -0
- package/NOTICE.md +119 -0
- package/README.md +246 -0
- package/assets/euthyna.png +0 -0
- package/bin/euthyna.js +25 -0
- package/package.json +40 -0
- package/src/cli.js +238 -0
- package/src/contract.js +249 -0
- package/src/facts/coverage.js +338 -0
- package/src/facts/history.js +399 -0
- package/src/git.js +107 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History facts: what did this change delete, and where did the deleted code come from?
|
|
3
|
+
*
|
|
4
|
+
* The upstream methodology states the rule this mechanises: code deleted by a
|
|
5
|
+
* commit whose message says it was a security fix is a regression risk, and code
|
|
6
|
+
* that was removed and is now being added back is a reintroduction. Both are
|
|
7
|
+
* answerable from git alone, deterministically, and both are things a language
|
|
8
|
+
* model guesses at rather than measures.
|
|
9
|
+
*
|
|
10
|
+
* This module produces facts. It does not decide whether a regression happened.
|
|
11
|
+
*/
|
|
12
|
+
import { git, commitSummaries } from '../git.js';
|
|
13
|
+
import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strong signal: the commit is about security.
|
|
17
|
+
*
|
|
18
|
+
* Tuned asymmetrically on purpose. The two ways this classifier can be wrong do
|
|
19
|
+
* not cost the same: over-classifying yields a noisy report a reader dismisses,
|
|
20
|
+
* while under-classifying hides exactly the deleted-code-origin case this tool
|
|
21
|
+
* exists to surface. So it errs broad across vocabulary that is unambiguously
|
|
22
|
+
* security, and stops there - it does not guess from general words such as
|
|
23
|
+
* "validate" or "check", which appear in ordinary refactors.
|
|
24
|
+
*/
|
|
25
|
+
const SECURITY_PATTERN =
|
|
26
|
+
/\bcve-\d{4}-\d+\b|\bsecurity\b|\bvuln(?:erabilit(?:y|ies))?\b|\bexploit\b|\bxss\b|\bcsrf\b|\bssrf\b|\binjection\b|\bsanitiz|\bpermission\b|\bauthoriz|\bauthenticat|\bauthn?\b|\boauth\b|\bcredential|\bprivileg|\baccess control\b|\bhardening\b|\bmalicious\b|\bmalware\b|\bunsafe\b|漏洞|安全/i;
|
|
27
|
+
|
|
28
|
+
/** Weak signal: an ordinary fix. Worth reporting, weaker than the above. */
|
|
29
|
+
const FIX_PATTERN = /^\s*(?:fix|bugfix|hotfix|patch)\b|\bfixes\b|\bfixed\b|\bfixing\b|\bpatches\b|\brevert/i;
|
|
30
|
+
|
|
31
|
+
/** Longest-first so a longer added line is preferred over a substring of it. */
|
|
32
|
+
const MIN_PICKAXE_LINE_LENGTH = 12;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse `git diff --unified=0` hunk headers into deleted line ranges.
|
|
36
|
+
* With --unified=0 every hunk is exactly the changed lines, so a hunk's old
|
|
37
|
+
* range is precisely the set of lines this change removed.
|
|
38
|
+
*
|
|
39
|
+
* @returns {Array<{start: number, count: number}>}
|
|
40
|
+
*/
|
|
41
|
+
export function parseDeletedRanges(diffText) {
|
|
42
|
+
const ranges = [];
|
|
43
|
+
const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
44
|
+
for (const line of diffText.split('\n')) {
|
|
45
|
+
const match = hunk.exec(line);
|
|
46
|
+
if (!match) continue;
|
|
47
|
+
const start = Number(match[1]);
|
|
48
|
+
// A missing count means 1; a count of 0 means pure insertion, so nothing was deleted.
|
|
49
|
+
const count = match[2] === undefined ? 1 : Number(match[2]);
|
|
50
|
+
if (count > 0) ranges.push({ start, count });
|
|
51
|
+
}
|
|
52
|
+
return ranges;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Attribute every line in `ranges` to the commit that last touched it, as of `rev`.
|
|
57
|
+
*
|
|
58
|
+
* Returns a Map of full commit hash -> array of line numbers in `rev`. Line
|
|
59
|
+
* numbers rather than a bare count, because the fact has to carry a command a
|
|
60
|
+
* reader can actually paste: `git blame -L 104,104` reproduces the claim, while
|
|
61
|
+
* `git blame -L <deleted-range>` is a placeholder, not evidence.
|
|
62
|
+
*/
|
|
63
|
+
export async function blameRanges({ cwd, rev, file, ranges }) {
|
|
64
|
+
const byCommit = new Map();
|
|
65
|
+
for (const range of ranges) {
|
|
66
|
+
const end = range.start + range.count - 1;
|
|
67
|
+
const out = await git(
|
|
68
|
+
['blame', '--porcelain', '-L', `${range.start},${end}`, rev, '--', file],
|
|
69
|
+
{ cwd, allowFailure: true }
|
|
70
|
+
);
|
|
71
|
+
if (!out) continue;
|
|
72
|
+
|
|
73
|
+
let current = null;
|
|
74
|
+
// Porcelain emits `<sha> <origLine> <finalLine> [<groupSize>]`. `origLine`
|
|
75
|
+
// is the line number in the commit that introduced the line, which is NOT
|
|
76
|
+
// where the line sits in the revision being blamed; `finalLine` is. Using
|
|
77
|
+
// the wrong one produces a command that looks right and blames other lines.
|
|
78
|
+
let lineNumber = null;
|
|
79
|
+
for (const raw of out.split('\n')) {
|
|
80
|
+
const header = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(raw);
|
|
81
|
+
if (header) {
|
|
82
|
+
current = header[1];
|
|
83
|
+
lineNumber = Number(header[3]);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (raw.startsWith('\t') && current && lineNumber !== null) {
|
|
87
|
+
const list = byCommit.get(current) ?? [];
|
|
88
|
+
list.push(lineNumber);
|
|
89
|
+
byCommit.set(current, list);
|
|
90
|
+
lineNumber++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return byCommit;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Compress sorted line numbers into inclusive ranges, so a command can name
|
|
99
|
+
* them as `git blame -L 104,104 -L 114,114` instead of enumerating every line.
|
|
100
|
+
*/
|
|
101
|
+
export function compressRanges(lineNumbers) {
|
|
102
|
+
const sorted = [...new Set(lineNumbers)].sort((a, b) => a - b);
|
|
103
|
+
const ranges = [];
|
|
104
|
+
for (const line of sorted) {
|
|
105
|
+
const last = ranges[ranges.length - 1];
|
|
106
|
+
if (last && line === last.end + 1) last.end = line;
|
|
107
|
+
else ranges.push({ start: line, end: line });
|
|
108
|
+
}
|
|
109
|
+
return ranges;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Classify a commit subject. Returns 'security', 'fix', or 'none'. */
|
|
113
|
+
export function classifySubject(subject, { securityPattern = SECURITY_PATTERN, fixPattern = FIX_PATTERN } = {}) {
|
|
114
|
+
if (!subject) return 'none';
|
|
115
|
+
if (securityPattern.test(subject)) return 'security';
|
|
116
|
+
if (fixPattern.test(subject)) return 'fix';
|
|
117
|
+
return 'none';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Collect history facts for a revision range.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} options
|
|
124
|
+
* @param {string} options.cwd
|
|
125
|
+
* @param {string} options.base
|
|
126
|
+
* @param {string} options.head
|
|
127
|
+
* @param {boolean} [options.pickaxe] also look for reintroduced lines
|
|
128
|
+
* @param {number} [options.maxPickaxe] cap on pickaxe probes (reported when hit)
|
|
129
|
+
*/
|
|
130
|
+
export async function collectHistoryFacts({
|
|
131
|
+
cwd,
|
|
132
|
+
base,
|
|
133
|
+
head = 'HEAD',
|
|
134
|
+
pickaxe = false,
|
|
135
|
+
maxPickaxe = 40,
|
|
136
|
+
securityPattern = SECURITY_PATTERN,
|
|
137
|
+
fixPattern = FIX_PATTERN
|
|
138
|
+
} = {}) {
|
|
139
|
+
const facts = [];
|
|
140
|
+
const evaluated = [];
|
|
141
|
+
const notEvaluated = [];
|
|
142
|
+
let counter = 0;
|
|
143
|
+
|
|
144
|
+
// NUL-separated so filenames containing newlines survive.
|
|
145
|
+
const fileList = await git(['diff', '--name-only', '-z', `${base}..${head}`], { cwd });
|
|
146
|
+
const files = fileList.split('\0').filter(Boolean);
|
|
147
|
+
|
|
148
|
+
if (files.length === 0) {
|
|
149
|
+
notEvaluated.push(
|
|
150
|
+
notEvaluatedEntry('history', `范围 ${base}..${head} 内没有文件变更,没有可归属的删除行`)
|
|
151
|
+
);
|
|
152
|
+
return { facts, evaluated, notEvaluated, files, measured: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Deleted code whose origin commit was itself removed by the range is not
|
|
156
|
+
// attributable to an earlier commit, so it is reported rather than dropped.
|
|
157
|
+
const blameByCommit = new Map();
|
|
158
|
+
const linesByFile = new Map();
|
|
159
|
+
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
const diff = await git(['diff', '--unified=0', `${base}..${head}`, '--', file], { cwd });
|
|
162
|
+
const ranges = parseDeletedRanges(diff);
|
|
163
|
+
if (ranges.length === 0) continue;
|
|
164
|
+
|
|
165
|
+
const deletedLines = ranges.reduce((sum, r) => sum + r.count, 0);
|
|
166
|
+
linesByFile.set(file, { ranges, deletedLines });
|
|
167
|
+
|
|
168
|
+
const byCommit = await blameRanges({ cwd, rev: base, file, ranges });
|
|
169
|
+
for (const [hash, lineNumbers] of byCommit) {
|
|
170
|
+
const entry = blameByCommit.get(hash) ?? { lines: 0, byFile: new Map() };
|
|
171
|
+
const existing = entry.byFile.get(file) ?? [];
|
|
172
|
+
entry.byFile.set(file, [...existing, ...lineNumbers]);
|
|
173
|
+
entry.lines += lineNumbers.length;
|
|
174
|
+
blameByCommit.set(hash, entry);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
evaluated.push({
|
|
179
|
+
kind: KIND.HISTORY,
|
|
180
|
+
producer: 'euthyna-history',
|
|
181
|
+
count: [...linesByFile.values()].reduce((sum, v) => sum + v.deletedLines, 0)
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (linesByFile.size === 0) {
|
|
185
|
+
// Files changed but nothing was deleted. Saying nothing here would render as
|
|
186
|
+
// "no facts", which a reader could mistake for "measured and clean".
|
|
187
|
+
notEvaluated.push(
|
|
188
|
+
notEvaluatedEntry(
|
|
189
|
+
'history',
|
|
190
|
+
`范围 ${base}..${head} 内有 ${files.length} 个文件变更,但没有任何一行被删除,` +
|
|
191
|
+
'因此没有可归属的历史来源'
|
|
192
|
+
)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const summaries = await commitSummaries(cwd, [...blameByCommit.keys()]);
|
|
197
|
+
|
|
198
|
+
// Report the security-relevant origins first: they are what an adjudicator
|
|
199
|
+
// must look at, and the ordering is stable so two runs produce the same report.
|
|
200
|
+
const ranked = [...blameByCommit.entries()]
|
|
201
|
+
.map(([hash, entry]) => {
|
|
202
|
+
const summary = summaries.get(hash) ?? { subject: '(提交信息不可读)', author: null, date: null };
|
|
203
|
+
return { hash, entry, summary, classification: classifySubject(summary.subject, { securityPattern, fixPattern }) };
|
|
204
|
+
})
|
|
205
|
+
.sort((a, b) => rank(a.classification) - rank(b.classification) || b.entry.lines - a.entry.lines);
|
|
206
|
+
|
|
207
|
+
for (const { hash, entry, summary, classification } of ranked) {
|
|
208
|
+
const byFile = [...entry.byFile.entries()]
|
|
209
|
+
.map(([file, lineNumbers]) => ({
|
|
210
|
+
file,
|
|
211
|
+
lines: lineNumbers.length,
|
|
212
|
+
ranges: compressRanges(lineNumbers)
|
|
213
|
+
}))
|
|
214
|
+
.sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file));
|
|
215
|
+
|
|
216
|
+
const fileCount = byFile.length;
|
|
217
|
+
// Say how many files the lines are spread over. Reporting a total against a
|
|
218
|
+
// single file path reads as "all of these are here", which sends a reader to
|
|
219
|
+
// the wrong place and makes an accurate attribution look wrong.
|
|
220
|
+
const statement =
|
|
221
|
+
`本次变更删除了 ${entry.lines} 行来自提交 ${hash.slice(0, 10)} 的代码` +
|
|
222
|
+
(fileCount > 1 ? `,分布在 ${fileCount} 个文件` : `(文件:${byFile[0].file})`) +
|
|
223
|
+
`。提交信息:${JSON.stringify(summary.subject)},分类:${classification}`;
|
|
224
|
+
|
|
225
|
+
// Multiple -L flags reproduce every attributed line in the primary file.
|
|
226
|
+
const primary = byFile[0];
|
|
227
|
+
const lineFlags = primary.ranges.map(r => `-L ${r.start},${r.end}`).join(' ');
|
|
228
|
+
|
|
229
|
+
facts.push(
|
|
230
|
+
makeFact({
|
|
231
|
+
id: `history-${++counter}`,
|
|
232
|
+
kind: KIND.HISTORY,
|
|
233
|
+
statement,
|
|
234
|
+
status: STATUS.ESTABLISHED,
|
|
235
|
+
evidence: { file: primary.file, commit: hash, files: byFile.map(f => f.file) },
|
|
236
|
+
method: 'command',
|
|
237
|
+
command: `git blame --porcelain ${lineFlags} ${base} -- ${shellQuote(primary.file)}`,
|
|
238
|
+
detail: {
|
|
239
|
+
classification,
|
|
240
|
+
commitSubject: summary.subject,
|
|
241
|
+
commitAuthor: summary.author,
|
|
242
|
+
commitDate: summary.date,
|
|
243
|
+
blamedLines: entry.lines,
|
|
244
|
+
byFile,
|
|
245
|
+
// Stated explicitly so a reader is not left thinking the command above
|
|
246
|
+
// covers lines in the other files too.
|
|
247
|
+
reproductionNote:
|
|
248
|
+
fileCount > 1
|
|
249
|
+
? `上面的命令只复现「${primary.file}」中的归属;其余 ${fileCount - 1} 个文件的行区间见 byFile`
|
|
250
|
+
: '上面的命令复现本事实涉及的全部行'
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (ranked.length === 0 && linesByFile.size > 0) {
|
|
257
|
+
notEvaluated.push(
|
|
258
|
+
notEvaluatedEntry('history', '有删除行但 blame 未能归属到任何提交(可能是二进制文件或路径异常)')
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (pickaxe) {
|
|
263
|
+
const reintro = await collectReintroductionFacts({
|
|
264
|
+
cwd,
|
|
265
|
+
base,
|
|
266
|
+
head,
|
|
267
|
+
files,
|
|
268
|
+
maxPickaxe,
|
|
269
|
+
securityPattern,
|
|
270
|
+
fixPattern,
|
|
271
|
+
startCounter: counter
|
|
272
|
+
});
|
|
273
|
+
facts.push(...reintro.facts);
|
|
274
|
+
counter = reintro.counter;
|
|
275
|
+
if (reintro.evaluated) evaluated.push(reintro.evaluated);
|
|
276
|
+
notEvaluated.push(...reintro.notEvaluated);
|
|
277
|
+
} else {
|
|
278
|
+
notEvaluated.push(
|
|
279
|
+
notEvaluatedEntry('reintroduction', '未启用 --pickaxe,未检查「被移除又加回」的代码行')
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { facts, evaluated, notEvaluated, files, measured: true };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function rank(classification) {
|
|
287
|
+
if (classification === 'security') return 0;
|
|
288
|
+
if (classification === 'fix') return 1;
|
|
289
|
+
return 2;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Reintroduction: an added line that exists nowhere at `base`, yet `git log -S`
|
|
294
|
+
* reports commits that changed its occurrence count before `base`.
|
|
295
|
+
*
|
|
296
|
+
* The inference is sound rather than heuristic: the line is absent at base and
|
|
297
|
+
* present at head, so any earlier commit that changed its count must have
|
|
298
|
+
* removed it. Adding it back is the reintroduction.
|
|
299
|
+
*/
|
|
300
|
+
async function collectReintroductionFacts({
|
|
301
|
+
cwd,
|
|
302
|
+
base,
|
|
303
|
+
head,
|
|
304
|
+
files,
|
|
305
|
+
maxPickaxe,
|
|
306
|
+
securityPattern,
|
|
307
|
+
fixPattern,
|
|
308
|
+
startCounter
|
|
309
|
+
}) {
|
|
310
|
+
const facts = [];
|
|
311
|
+
const notEvaluated = [];
|
|
312
|
+
let counter = startCounter;
|
|
313
|
+
let probed = 0;
|
|
314
|
+
let candidates = 0;
|
|
315
|
+
|
|
316
|
+
for (const file of files) {
|
|
317
|
+
const diff = await git(['diff', '--unified=0', `${base}..${head}`, '--', file], { cwd });
|
|
318
|
+
const added = diff
|
|
319
|
+
.split('\n')
|
|
320
|
+
.filter(line => line.startsWith('+') && !line.startsWith('+++'))
|
|
321
|
+
.map(line => line.slice(1));
|
|
322
|
+
|
|
323
|
+
// Longest first: a longer line is the more specific probe.
|
|
324
|
+
const unique = [...new Set(added.map(l => l.trim()))]
|
|
325
|
+
.filter(l => l.length >= MIN_PICKAXE_LINE_LENGTH)
|
|
326
|
+
.sort((a, b) => b.length - a.length);
|
|
327
|
+
candidates += unique.length;
|
|
328
|
+
|
|
329
|
+
for (const line of unique) {
|
|
330
|
+
if (probed >= maxPickaxe) break;
|
|
331
|
+
probed++;
|
|
332
|
+
|
|
333
|
+
const log = await git(
|
|
334
|
+
['log', '--format=%H', `-S${line}`, base, '--', file],
|
|
335
|
+
{ cwd, allowFailure: true }
|
|
336
|
+
);
|
|
337
|
+
const hashes = log.split('\n').map(s => s.trim()).filter(Boolean);
|
|
338
|
+
if (hashes.length === 0) continue;
|
|
339
|
+
|
|
340
|
+
// Present at head by construction; confirm it is genuinely absent at base.
|
|
341
|
+
const atBase = await git(['grep', '-F', '-q', '-e', line, base, '--', file], {
|
|
342
|
+
cwd,
|
|
343
|
+
allowFailure: true
|
|
344
|
+
});
|
|
345
|
+
if (atBase.trim()) continue;
|
|
346
|
+
|
|
347
|
+
const summaries = await commitSummaries(cwd, hashes.slice(0, 5));
|
|
348
|
+
const classified = [...summaries.entries()].map(([hash, summary]) => ({
|
|
349
|
+
hash,
|
|
350
|
+
summary,
|
|
351
|
+
classification: classifySubject(summary.subject, { securityPattern, fixPattern })
|
|
352
|
+
}));
|
|
353
|
+
const securityOrigin = classified.find(c => c.classification === 'security');
|
|
354
|
+
|
|
355
|
+
facts.push(
|
|
356
|
+
makeFact({
|
|
357
|
+
id: `reintroduction-${++counter}`,
|
|
358
|
+
kind: KIND.REINTRODUCTION,
|
|
359
|
+
statement:
|
|
360
|
+
`本次变更加回了一行在 ${base} 中不存在的代码,而该行的出现次数曾被 ${hashes.length} 个提交改变` +
|
|
361
|
+
(securityOrigin
|
|
362
|
+
? `,其中提交 ${securityOrigin.hash.slice(0, 10)} 的提交信息含安全关键词`
|
|
363
|
+
: ''),
|
|
364
|
+
status: STATUS.ESTABLISHED,
|
|
365
|
+
evidence: { file, snippet: line.slice(0, 160), commit: hashes[0] },
|
|
366
|
+
method: 'command',
|
|
367
|
+
command: `git log --format=%H -S${shellQuote(line.slice(0, 60))} ${base} -- ${shellQuote(file)}`,
|
|
368
|
+
detail: {
|
|
369
|
+
line: line.slice(0, 400),
|
|
370
|
+
candidateCommits: classified.map(c => ({
|
|
371
|
+
hash: c.hash,
|
|
372
|
+
subject: c.summary.subject,
|
|
373
|
+
classification: c.classification
|
|
374
|
+
})),
|
|
375
|
+
securityOrigin: securityOrigin ? securityOrigin.hash : null
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (probed >= maxPickaxe) break;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (probed >= maxPickaxe && candidates > probed) {
|
|
385
|
+
notEvaluated.push(
|
|
386
|
+
notEvaluatedEntry(
|
|
387
|
+
'reintroduction',
|
|
388
|
+
`pickaxe 探针上限 ${maxPickaxe} 已用尽,另有 ${candidates - probed} 行新增代码未检查`
|
|
389
|
+
)
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
facts,
|
|
395
|
+
counter,
|
|
396
|
+
evaluated: { kind: KIND.REINTRODUCTION, producer: 'euthyna-history', count: probed },
|
|
397
|
+
notEvaluated
|
|
398
|
+
};
|
|
399
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around the `git` binary.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately shells out to git rather than using a library: git is the
|
|
5
|
+
* authority on git semantics, and a library would be a dependency the audit
|
|
6
|
+
* itself would have to trust.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
|
|
11
|
+
import { safeText, shellQuote } from './contract.js';
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
// Large repositories produce large diffs; the default 1 MB buffer is not enough.
|
|
16
|
+
const MAX_BUFFER = 256 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The message for a failed git invocation.
|
|
20
|
+
*
|
|
21
|
+
* Two transforms, both required. The argv is shell-quoted so the message can be
|
|
22
|
+
* pasted back into a shell to reproduce the failure — the same shellQuote the
|
|
23
|
+
* emitted commands use; an unquoted argv with a hostile filename in it would
|
|
24
|
+
* execute as code when the reader does exactly that. The assembled message is
|
|
25
|
+
* then made terminal-safe as a whole: the argv itself carries repo-controlled
|
|
26
|
+
* filenames in the measurement flows (git diff -- <file>), and quoting alone
|
|
27
|
+
* does not neutralize a control byte — a BEL inside single quotes still beeps.
|
|
28
|
+
* git's stderr gets the same treatment because git embeds repository-controlled
|
|
29
|
+
* text (filenames, refs) in its messages.
|
|
30
|
+
*
|
|
31
|
+
* Exported so both transforms are testable against hostile input directly: a
|
|
32
|
+
* repository carrying a control byte in a filename cannot be built on every
|
|
33
|
+
* host this suite runs on.
|
|
34
|
+
*/
|
|
35
|
+
export function gitFailureMessage(args, detail) {
|
|
36
|
+
const argv = args.map(shellQuote).join(' ');
|
|
37
|
+
return safeText(`git ${argv} failed: ${detail}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a git command and return stdout. Throws on a non-zero exit.
|
|
42
|
+
*
|
|
43
|
+
* @param {string[]} args
|
|
44
|
+
* @param {{cwd?: string, allowFailure?: boolean}} [options]
|
|
45
|
+
* @returns {Promise<string>} stdout, with the trailing newline preserved
|
|
46
|
+
*/
|
|
47
|
+
export async function git(args, options = {}) {
|
|
48
|
+
const { cwd, allowFailure = false } = options;
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
51
|
+
cwd,
|
|
52
|
+
maxBuffer: MAX_BUFFER,
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
// Keep output byte-exact: a security fact must not be altered by locale.
|
|
55
|
+
env: { ...process.env, LC_ALL: 'C' }
|
|
56
|
+
});
|
|
57
|
+
return stdout;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (allowFailure) return '';
|
|
60
|
+
// git writes its own diagnosis to stderr; when it wrote none, the exit
|
|
61
|
+
// status is all there is. error.message is deliberately not used: it
|
|
62
|
+
// embeds the argv unquoted, which is the shape this path exists to remove.
|
|
63
|
+
const stderr = (error.stderr || '').trim();
|
|
64
|
+
const detail = stderr || `exit status ${error.code ?? error.signal ?? 'unknown'}`;
|
|
65
|
+
throw new Error(gitFailureMessage(args, detail));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolve the repository top level, or null when cwd is not inside a repo. */
|
|
70
|
+
export async function repoToplevel(cwd) {
|
|
71
|
+
const out = await git(['rev-parse', '--show-toplevel'], { cwd, allowFailure: true });
|
|
72
|
+
return out.trim() || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve a revision to a full commit hash, or null when it does not exist. */
|
|
76
|
+
export async function revParse(cwd, rev) {
|
|
77
|
+
const out = await git(['rev-parse', '--verify', `${rev}^{commit}`], {
|
|
78
|
+
cwd,
|
|
79
|
+
allowFailure: true
|
|
80
|
+
});
|
|
81
|
+
return out.trim() || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One-line summaries for a set of commits, as a Map of hash -> {subject, author, date}.
|
|
86
|
+
* Uses a single `git log` invocation so the cost does not scale per commit.
|
|
87
|
+
*/
|
|
88
|
+
export async function commitSummaries(cwd, hashes) {
|
|
89
|
+
const result = new Map();
|
|
90
|
+
if (hashes.length === 0) return result;
|
|
91
|
+
|
|
92
|
+
// A NUL-separated record format survives commit subjects containing any
|
|
93
|
+
// character except NUL, so subjects with newlines or pipes stay intact.
|
|
94
|
+
const out = await git(
|
|
95
|
+
['log', '--no-walk', '--format=%H%x00%s%x00%an%x00%cI%x00%x00', ...hashes],
|
|
96
|
+
{ cwd, allowFailure: true }
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
for (const record of out.split('\0\0')) {
|
|
100
|
+
const trimmed = record.replace(/^\n+/, '');
|
|
101
|
+
if (!trimmed) continue;
|
|
102
|
+
const [hash, subject, author, date] = trimmed.split('\0');
|
|
103
|
+
if (!hash) continue;
|
|
104
|
+
result.set(hash, { subject, author, date });
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|