@crewhaus/tool-buildperf 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +715 -0
- package/dist/lib/bench.d.ts +77 -0
- package/dist/lib/bench.js +267 -0
- package/dist/lib/flaky.d.ts +112 -0
- package/dist/lib/flaky.js +346 -0
- package/dist/lib/size.d.ts +114 -0
- package/dist/lib/size.js +339 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +46 -0
package/dist/lib/size.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact size, and the single condition that makes two sizes comparable.
|
|
3
|
+
*
|
|
4
|
+
* Compressing bytes is the easy half. The half that costs a team a morning is
|
|
5
|
+
* comparability: a gzip size is not a property of a file, it is a property of
|
|
6
|
+
* a file AND the compressor settings. Level 6 against level 9 on the same
|
|
7
|
+
* bundle differs by several percent — the same order as the regression anyone
|
|
8
|
+
* is looking for — so a baseline produced at one level and a head measured at
|
|
9
|
+
* another produce a confident, entirely fabricated "+4.1%". Every number this
|
|
10
|
+
* module produces therefore travels with the parameters that produced it, and
|
|
11
|
+
* `parametersMismatch` exists so the caller can refuse rather than subtract.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here is statistical. Compression at fixed parameters is
|
|
14
|
+
* deterministic, so a one-byte difference is a real one-byte difference and
|
|
15
|
+
* there is no noise floor to declare. That is the whole reason size gating is
|
|
16
|
+
* a different shape of problem from benchmark gating, which is next door in
|
|
17
|
+
* `./bench`.
|
|
18
|
+
*/
|
|
19
|
+
import { constants as zlib, brotliCompressSync, gzipSync } from "node:zlib";
|
|
20
|
+
export const COMPRESSION_ALGORITHMS = ["none", "gzip", "brotli"];
|
|
21
|
+
export class SizeError extends Error {
|
|
22
|
+
name = "SizeError";
|
|
23
|
+
}
|
|
24
|
+
/** zlib's own default is 6; bundler size reporters quote level 9, so that is the default here. */
|
|
25
|
+
const GZIP_DEFAULT_LEVEL = 9;
|
|
26
|
+
/** Brotli's maximum quality, which is what every published "brotli size" means. */
|
|
27
|
+
const BROTLI_DEFAULT_LEVEL = 11;
|
|
28
|
+
/**
|
|
29
|
+
* Fill in and validate the parameters for a run.
|
|
30
|
+
*
|
|
31
|
+
* A level outside the algorithm's range is a refusal, not a clamp: gzip
|
|
32
|
+
* silently accepting 11 (a brotli quality) and compressing at 9 would produce
|
|
33
|
+
* a report claiming a level it did not use, which is the exact failure this
|
|
34
|
+
* module exists to prevent.
|
|
35
|
+
*/
|
|
36
|
+
export function compressionParameters(algorithm, level) {
|
|
37
|
+
if (algorithm === "none") {
|
|
38
|
+
if (level !== undefined) {
|
|
39
|
+
throw new SizeError(`algorithm "none" does not compress, so it has no level (got ${level})`);
|
|
40
|
+
}
|
|
41
|
+
return { algorithm, level: null, tuning: {} };
|
|
42
|
+
}
|
|
43
|
+
const max = algorithm === "gzip" ? 9 : 11;
|
|
44
|
+
const chosen = level ?? (algorithm === "gzip" ? GZIP_DEFAULT_LEVEL : BROTLI_DEFAULT_LEVEL);
|
|
45
|
+
if (!Number.isInteger(chosen) || chosen < 0 || chosen > max) {
|
|
46
|
+
throw new SizeError(`${algorithm} levels run 0-${max}, got ${chosen}${algorithm === "gzip" && chosen > 9
|
|
47
|
+
? " — 10 and 11 are brotli qualities, not gzip levels"
|
|
48
|
+
: ""}`);
|
|
49
|
+
}
|
|
50
|
+
return algorithm === "gzip"
|
|
51
|
+
? {
|
|
52
|
+
algorithm,
|
|
53
|
+
level: chosen,
|
|
54
|
+
// memLevel and strategy move the output too; pinned so the recorded
|
|
55
|
+
// pair (algorithm, level) really does determine the byte count.
|
|
56
|
+
tuning: { memLevel: 8, strategy: zlib.Z_DEFAULT_STRATEGY, windowBits: 15 },
|
|
57
|
+
}
|
|
58
|
+
: {
|
|
59
|
+
algorithm,
|
|
60
|
+
level: chosen,
|
|
61
|
+
tuning: { lgwin: 22, mode: zlib.BROTLI_MODE_GENERIC },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The string two parameter sets must agree on for a comparison to mean
|
|
66
|
+
* anything. Sorted, so key order in a stored baseline cannot change it.
|
|
67
|
+
*/
|
|
68
|
+
export function comparabilityKey(p) {
|
|
69
|
+
const tuning = Object.keys(p.tuning)
|
|
70
|
+
.sort()
|
|
71
|
+
.map((k) => `${k}=${p.tuning[k]}`)
|
|
72
|
+
.join(",");
|
|
73
|
+
return `${p.algorithm}/level=${p.level ?? "n/a"}${tuning === "" ? "" : `/${tuning}`}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Why these two sets of parameters cannot be compared, or `null` when they
|
|
77
|
+
* can. The message names the fields that differ, because "incomparable" on
|
|
78
|
+
* its own sends the reader to the source.
|
|
79
|
+
*/
|
|
80
|
+
export function parametersMismatch(baseline, head) {
|
|
81
|
+
if (baseline.algorithm !== head.algorithm) {
|
|
82
|
+
return `the baseline was measured with ${baseline.algorithm} and this run used ${head.algorithm}; compressed sizes from two algorithms are different measurements, not a change`;
|
|
83
|
+
}
|
|
84
|
+
if (baseline.level !== head.level) {
|
|
85
|
+
return `the baseline was measured at ${baseline.algorithm} level ${baseline.level} and this run at level ${head.level}; the difference between two levels is several percent on typical bundles, which would read as a regression that did not happen`;
|
|
86
|
+
}
|
|
87
|
+
const differing = [...new Set([...Object.keys(baseline.tuning), ...Object.keys(head.tuning)])]
|
|
88
|
+
.filter((k) => baseline.tuning[k] !== head.tuning[k])
|
|
89
|
+
.sort();
|
|
90
|
+
if (differing.length > 0) {
|
|
91
|
+
return `the baseline and this run disagree on ${differing
|
|
92
|
+
.map((k) => `${k} (${baseline.tuning[k] ?? "unset"} vs ${head.tuning[k] ?? "unset"})`)
|
|
93
|
+
.join(", ")}; those settings change the compressed size, so the two are not comparable`;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Why this baseline's ENTRIES cannot be differenced against a run measured
|
|
99
|
+
* under `algorithm`, or `null` when they can.
|
|
100
|
+
*
|
|
101
|
+
* `parametersMismatch` guards the compressor settings. This guards the
|
|
102
|
+
* column. Every entry carries two numbers — a raw size and a compressed one —
|
|
103
|
+
* and which of them a delta is taken on is decided by the algorithm, so a
|
|
104
|
+
* baseline that declares gzip but stores no `compressedBytes` makes the
|
|
105
|
+
* obvious `compressed ?? raw` fallback subtract a gzip size from a raw one.
|
|
106
|
+
* That lands near -70% on real bundles: not a number a reader distrusts, a
|
|
107
|
+
* number a reader celebrates, and it hides whatever the change actually did.
|
|
108
|
+
* The reverse — a compressed column under algorithm "none" — fabricates a
|
|
109
|
+
* regression the same way, so both directions are refused rather than
|
|
110
|
+
* silently filled in.
|
|
111
|
+
*/
|
|
112
|
+
export function baselineBasisMismatch(algorithm, entries) {
|
|
113
|
+
if (entries.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
const compresses = algorithm !== "none";
|
|
116
|
+
const offenders = entries
|
|
117
|
+
.filter((e) => ((e.compressedBytes ?? null) === null) === compresses)
|
|
118
|
+
.map((e) => e.path);
|
|
119
|
+
if (offenders.length === 0)
|
|
120
|
+
return null;
|
|
121
|
+
const named = offenders.slice(0, 5).join(", ");
|
|
122
|
+
const more = offenders.length > 5 ? `, and ${offenders.length - 5} more` : "";
|
|
123
|
+
return compresses
|
|
124
|
+
? `the baseline declares ${algorithm} but has no compressedBytes on ${offenders.length} of its ${entries.length} entries (${named}${more}); this run's sizes are compressed, so pairing them would subtract a compressed size from a raw one and report a saving of roughly the compression ratio. Re-record it from a report of this tool`
|
|
125
|
+
: `the baseline declares algorithm "none" but carries a compressedBytes on ${offenders.length} of its ${entries.length} entries (${named}${more}), and this run measured raw bytes; the two columns are different measurements, not a change`;
|
|
126
|
+
}
|
|
127
|
+
/** Compressed byte count under `parameters`; the raw length for `none`. */
|
|
128
|
+
export function compressedSize(bytes, parameters) {
|
|
129
|
+
if (parameters.algorithm === "none")
|
|
130
|
+
return bytes.byteLength;
|
|
131
|
+
if (parameters.algorithm === "gzip") {
|
|
132
|
+
return gzipSync(bytes, {
|
|
133
|
+
level: parameters.level ?? GZIP_DEFAULT_LEVEL,
|
|
134
|
+
memLevel: parameters.tuning["memLevel"] ?? 8,
|
|
135
|
+
strategy: parameters.tuning["strategy"] ?? zlib.Z_DEFAULT_STRATEGY,
|
|
136
|
+
windowBits: parameters.tuning["windowBits"] ?? 15,
|
|
137
|
+
}).byteLength;
|
|
138
|
+
}
|
|
139
|
+
return brotliCompressSync(bytes, {
|
|
140
|
+
params: {
|
|
141
|
+
[zlib.BROTLI_PARAM_QUALITY]: parameters.level ?? BROTLI_DEFAULT_LEVEL,
|
|
142
|
+
[zlib.BROTLI_PARAM_LGWIN]: parameters.tuning["lgwin"] ?? 22,
|
|
143
|
+
[zlib.BROTLI_PARAM_MODE]: parameters.tuning["mode"] ?? zlib.BROTLI_MODE_GENERIC,
|
|
144
|
+
// SIZE_HINT is deliberately not set. It improves the ratio and is what a
|
|
145
|
+
// bundler passes, but it makes the output depend on a value the caller
|
|
146
|
+
// never sees, so two tools measuring "the brotli size" would disagree.
|
|
147
|
+
},
|
|
148
|
+
}).byteLength;
|
|
149
|
+
}
|
|
150
|
+
// --- joining a hashed build against a baseline ------------------------------
|
|
151
|
+
export const JOIN_MODES = ["exact", "auto", "custom"];
|
|
152
|
+
/**
|
|
153
|
+
* Whether a filename segment looks like a content hash rather than a name.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately conservative in one direction only: `chunk`, `min`, `esm` and
|
|
156
|
+
* `vendor` must never be eaten, because normalizing a real name merges two
|
|
157
|
+
* different artifacts into one row. Pure hex needs 6 characters (webpack's
|
|
158
|
+
* shortest common `[contenthash:6]`); a mixed alphabet needs 8 AND both a
|
|
159
|
+
* digit and a letter (Vite's base64url ids, e.g. `BXaGz2Qm`). A lowercase
|
|
160
|
+
* word of 6+ letters that happens to be all hex — `decade`, `defaced` — is
|
|
161
|
+
* the known false positive, which is why every entry reports the key it was
|
|
162
|
+
* joined on rather than just the delta.
|
|
163
|
+
*/
|
|
164
|
+
export function looksLikeHash(segment) {
|
|
165
|
+
if (!/^[0-9a-zA-Z_]+$/.test(segment))
|
|
166
|
+
return false;
|
|
167
|
+
if (/^[0-9a-f]{6,}$/i.test(segment))
|
|
168
|
+
return true;
|
|
169
|
+
return segment.length >= 8 && /[0-9]/.test(segment) && /[a-zA-Z]/.test(segment);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The key an entry joins on.
|
|
173
|
+
*
|
|
174
|
+
* `exact` is the default and joins on the path as written. It is the right
|
|
175
|
+
* choice for a build with stable filenames and the wrong one for a hashed
|
|
176
|
+
* build, where it matches NOTHING on every run — which reads as "every file
|
|
177
|
+
* is new", not as "the join is broken". `auto` strips hash-shaped segments
|
|
178
|
+
* from the basename; `custom` applies the caller's own pattern, which is the
|
|
179
|
+
* escape hatch for a bundler whose scheme the heuristic does not recognise.
|
|
180
|
+
*/
|
|
181
|
+
export function joinKey(path, mode, custom) {
|
|
182
|
+
if (mode === "exact")
|
|
183
|
+
return { key: path, normalized: false };
|
|
184
|
+
if (mode === "custom") {
|
|
185
|
+
if (custom === undefined)
|
|
186
|
+
throw new SizeError('join mode "custom" needs a pattern');
|
|
187
|
+
const key = path.replace(custom, "[hash]");
|
|
188
|
+
return { key, normalized: key !== path };
|
|
189
|
+
}
|
|
190
|
+
const cut = path.lastIndexOf("/");
|
|
191
|
+
const dir = cut === -1 ? "" : path.slice(0, cut + 1);
|
|
192
|
+
const base = path.slice(cut + 1);
|
|
193
|
+
// Split on both separators at once: `app-4f2a1c.js` (Vite, Rollup) and
|
|
194
|
+
// `app.4f2a1c.js` (webpack) are the same idea with a different delimiter,
|
|
195
|
+
// and a build can contain both.
|
|
196
|
+
const parts = base.split(/([.-])/);
|
|
197
|
+
let normalized = false;
|
|
198
|
+
const rebuilt = parts.map((part, i) => {
|
|
199
|
+
// Odd indices are the captured separators; index 0 is the stem, which is
|
|
200
|
+
// never a hash on its own — `4f2a1c.js` as a whole filename carries no
|
|
201
|
+
// other identity, and blanking it would merge every such file.
|
|
202
|
+
if (i === 0 || i % 2 === 1)
|
|
203
|
+
return part;
|
|
204
|
+
if (!looksLikeHash(part))
|
|
205
|
+
return part;
|
|
206
|
+
normalized = true;
|
|
207
|
+
return "[hash]";
|
|
208
|
+
});
|
|
209
|
+
return { key: `${dir}${rebuilt.join("")}`, normalized };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Long enough for any real budget, short enough that the matcher's recursion
|
|
213
|
+
* depth (one frame per token) cannot reach the stack limit.
|
|
214
|
+
*/
|
|
215
|
+
export const MAX_GLOB_LENGTH = 512;
|
|
216
|
+
function tokenizeGlob(pattern) {
|
|
217
|
+
if (pattern.length > MAX_GLOB_LENGTH) {
|
|
218
|
+
throw new SizeError(`glob pattern is ${pattern.length} characters, over the ${MAX_GLOB_LENGTH}-character limit; a budget pattern that long is not a path shape`);
|
|
219
|
+
}
|
|
220
|
+
const tokens = [];
|
|
221
|
+
let literal = "";
|
|
222
|
+
const flush = () => {
|
|
223
|
+
if (literal !== "") {
|
|
224
|
+
tokens.push({ kind: "literal", text: literal });
|
|
225
|
+
literal = "";
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
229
|
+
const ch = pattern[i];
|
|
230
|
+
if (ch === "*") {
|
|
231
|
+
flush();
|
|
232
|
+
if (pattern[i + 1] === "*") {
|
|
233
|
+
if (pattern[i + 2] === "/") {
|
|
234
|
+
tokens.push({ kind: "anyDir" });
|
|
235
|
+
i += 2;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
tokens.push({ kind: "any" });
|
|
239
|
+
i += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
tokens.push({ kind: "segment" });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (ch === "?") {
|
|
246
|
+
flush();
|
|
247
|
+
tokens.push({ kind: "one" });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
literal += ch;
|
|
251
|
+
}
|
|
252
|
+
flush();
|
|
253
|
+
return tokens;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Compile a pattern once, match many paths with it.
|
|
257
|
+
*
|
|
258
|
+
* This deliberately does NOT compile to a regular expression. `*` and `**`
|
|
259
|
+
* become `[^/]*` and `.*`, and a backtracking engine handed several of those
|
|
260
|
+
* separated by literals explores every way of splitting the string between
|
|
261
|
+
* them: `*a*a*a…*b` — a 49-character pattern — against sixty `a`s does not
|
|
262
|
+
* finish, and a budget pattern is caller-supplied. The state here is only
|
|
263
|
+
* (token index, offset in the path), so memoising it bounds the search at
|
|
264
|
+
* tokens x length cells no matter how the wildcards are arranged.
|
|
265
|
+
*
|
|
266
|
+
* Callers with many paths should hold the matcher: recompiling per path
|
|
267
|
+
* multiplies the tokenizer by the size of the build.
|
|
268
|
+
*/
|
|
269
|
+
export function compileGlob(pattern) {
|
|
270
|
+
const tokens = tokenizeGlob(pattern);
|
|
271
|
+
const width = tokens.length;
|
|
272
|
+
return (path) => {
|
|
273
|
+
const n = path.length;
|
|
274
|
+
const stride = n + 1;
|
|
275
|
+
// 0 = not yet answered, 1 = no, 2 = yes. `match` only ever descends to a
|
|
276
|
+
// higher token index, so a cell can never be re-entered while it is being
|
|
277
|
+
// computed and there is no need for an "in progress" state.
|
|
278
|
+
const memo = new Uint8Array((width + 1) * stride);
|
|
279
|
+
const match = (j, i) => {
|
|
280
|
+
const cell = j * stride + i;
|
|
281
|
+
const cached = memo[cell];
|
|
282
|
+
if (cached !== 0)
|
|
283
|
+
return cached === 2;
|
|
284
|
+
let answer;
|
|
285
|
+
if (j === width) {
|
|
286
|
+
answer = i === n;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const token = tokens[j];
|
|
290
|
+
switch (token.kind) {
|
|
291
|
+
case "literal":
|
|
292
|
+
answer = path.startsWith(token.text, i) && match(j + 1, i + token.text.length);
|
|
293
|
+
break;
|
|
294
|
+
case "one":
|
|
295
|
+
answer = i < n && path[i] !== "/" && match(j + 1, i + 1);
|
|
296
|
+
break;
|
|
297
|
+
case "segment": {
|
|
298
|
+
answer = false;
|
|
299
|
+
for (let k = i;; k++) {
|
|
300
|
+
if (match(j + 1, k)) {
|
|
301
|
+
answer = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
// A segment wildcard stops at the separator it is not allowed to
|
|
305
|
+
// cross, which is what makes `*.js` miss `dist/app.js`.
|
|
306
|
+
if (k >= n || path[k] === "/")
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
case "any": {
|
|
312
|
+
answer = false;
|
|
313
|
+
for (let k = i; k <= n; k++) {
|
|
314
|
+
if (match(j + 1, k)) {
|
|
315
|
+
answer = true;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "anyDir": {
|
|
322
|
+
answer = match(j + 1, i);
|
|
323
|
+
for (let k = i; !answer && k < n; k++) {
|
|
324
|
+
if (path[k] === "/")
|
|
325
|
+
answer = match(j + 1, k + 1);
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
memo[cell] = answer ? 2 : 1;
|
|
332
|
+
return answer;
|
|
333
|
+
};
|
|
334
|
+
return match(0, 0);
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export function globMatch(pattern, path) {
|
|
338
|
+
return compileGlob(pattern)(path);
|
|
339
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
/** Raised when a caller-supplied path resolves outside the workspace root. */
|
|
3
|
+
export declare class ToolPermissionError extends CrewhausError {
|
|
4
|
+
readonly name = "ToolPermissionError";
|
|
5
|
+
readonly toolName: string;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
constructor(toolName: string, attemptedPath: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A validated path, in both forms the tools need.
|
|
11
|
+
*
|
|
12
|
+
* `real` has every in-workspace symlink already resolved and is what I/O
|
|
13
|
+
* should use — operating on it closes most of the check-to-use window.
|
|
14
|
+
* `abs` is the lexical resolution, which is what a tool must use when the
|
|
15
|
+
* question is about the link ITSELF (`Stat` reporting a symlink target,
|
|
16
|
+
* `RemovePath` deleting a link rather than its destination).
|
|
17
|
+
*/
|
|
18
|
+
export type SafePath = {
|
|
19
|
+
readonly abs: string;
|
|
20
|
+
readonly real: string;
|
|
21
|
+
/** The path as the caller wrote it, for echoing back in results. */
|
|
22
|
+
readonly given: string;
|
|
23
|
+
/** Slash-separated path relative to the workspace root. */
|
|
24
|
+
readonly rel: string;
|
|
25
|
+
};
|
|
26
|
+
/** The workspace root, resolved. Every path is contained within it. */
|
|
27
|
+
export declare function workspaceRoot(): string;
|
|
28
|
+
export declare function resolveSafe(toolName: string, rel: string, root?: string): SafePath;
|
|
29
|
+
/** A path relative to the workspace root, with `/` separators on every OS. */
|
|
30
|
+
export declare function toPosix(p: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* True when `candidate` is `parent` or lives underneath it. Both must already
|
|
33
|
+
* be absolute. Used to keep an archive entry, or a symlink inside an
|
|
34
|
+
* extracted tree, from reaching outside its destination.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isInside(parent: string, candidate: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Reject an archive member name that would escape its destination: an
|
|
39
|
+
* absolute path, a Windows drive prefix, or any `..` segment. Checked on the
|
|
40
|
+
* NAME, before extraction, because after extraction the damage is done.
|
|
41
|
+
*/
|
|
42
|
+
export declare function archiveEntryEscapes(name: string): boolean;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path containment — the gate every caller-supplied path in this package
|
|
3
|
+
* passes through before any syscall touches it.
|
|
4
|
+
*
|
|
5
|
+
* The approach is the one `@crewhaus/tool-fs` uses, and for the same reasons:
|
|
6
|
+
* a lexical check alone is fooled by a symlink that lives inside the
|
|
7
|
+
* workspace and points outside it (CWE-59), so the real path is checked too.
|
|
8
|
+
* The workspace root is `process.cwd()`, matching tool-fs, so a harness that
|
|
9
|
+
* trusts one tool's boundary gets the same boundary here.
|
|
10
|
+
*/
|
|
11
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
14
|
+
/** Raised when a caller-supplied path resolves outside the workspace root. */
|
|
15
|
+
export class ToolPermissionError extends CrewhausError {
|
|
16
|
+
name = "ToolPermissionError";
|
|
17
|
+
toolName;
|
|
18
|
+
path;
|
|
19
|
+
constructor(toolName, attemptedPath) {
|
|
20
|
+
super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
|
|
21
|
+
this.toolName = toolName;
|
|
22
|
+
this.path = attemptedPath;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The workspace root, resolved. Every path is contained within it. */
|
|
26
|
+
export function workspaceRoot() {
|
|
27
|
+
return path.resolve(process.cwd());
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve `rel` inside the workspace, or throw `ToolPermissionError`.
|
|
31
|
+
*
|
|
32
|
+
* The leaf is allowed not to exist — every tool here that creates something
|
|
33
|
+
* needs to validate a destination before it is made — so the deepest
|
|
34
|
+
* EXISTING ancestor is realpath'd and the missing tail re-appended. Any
|
|
35
|
+
* failure other than that walk fails closed.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* True when the NAME exists, whether or not it leads anywhere.
|
|
39
|
+
*
|
|
40
|
+
* `existsSync` FOLLOWS symlinks, so it answers false for a link whose target
|
|
41
|
+
* is missing — and that is exactly the case that matters: a dangling link is
|
|
42
|
+
* still a door, because `open(…, "w")` through `evil -> /outside/x` CREATES
|
|
43
|
+
* `/outside/x`. Probing with `lstat` keeps the link's name in the part of the
|
|
44
|
+
* path that gets resolved, rather than in the "does not exist yet" tail that
|
|
45
|
+
* is re-appended to the root verbatim and so passes the containment check.
|
|
46
|
+
*/
|
|
47
|
+
function nameExists(p) {
|
|
48
|
+
try {
|
|
49
|
+
lstatSync(p);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Where `target` would actually land, with every symlink on the way already
|
|
58
|
+
* followed — including one whose own target does not exist yet.
|
|
59
|
+
*
|
|
60
|
+
* `realpathSync` gives up with ENOENT on a dangling link, which would leave
|
|
61
|
+
* that link unresolved and let it stand in for a plain missing file. So the
|
|
62
|
+
* deepest ancestor that exists as a NAME is resolved, a dangling one is
|
|
63
|
+
* followed a hop by hand, and the components that do not exist are appended.
|
|
64
|
+
* The result is the path an `open` would create, which is the only path worth
|
|
65
|
+
* checking containment against.
|
|
66
|
+
*/
|
|
67
|
+
function resolveLocation(target, depth = 0) {
|
|
68
|
+
if (depth > 40)
|
|
69
|
+
throw new Error(`symlink chain at "${target}" is too long to resolve`);
|
|
70
|
+
let probe = target;
|
|
71
|
+
const tail = [];
|
|
72
|
+
while (!nameExists(probe)) {
|
|
73
|
+
tail.unshift(path.basename(probe));
|
|
74
|
+
const parent = path.dirname(probe);
|
|
75
|
+
if (parent === probe)
|
|
76
|
+
break; // reached the filesystem root
|
|
77
|
+
probe = parent;
|
|
78
|
+
}
|
|
79
|
+
let probeReal;
|
|
80
|
+
try {
|
|
81
|
+
probeReal = realpathSync(probe);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err.code !== "ENOENT")
|
|
85
|
+
throw err;
|
|
86
|
+
// The name is there but `realpath` cannot finish it: a symlink with a
|
|
87
|
+
// missing target. `readlinkSync` throws EINVAL on anything else, which
|
|
88
|
+
// fails closed.
|
|
89
|
+
const link = readlinkSync(probe);
|
|
90
|
+
// A RELATIVE target resolves against the directory that actually CONTAINS
|
|
91
|
+
// the link, which is not the link's lexical parent when that parent is
|
|
92
|
+
// itself reached through a symlink. `<root>/dirlink/x -> ../y` with
|
|
93
|
+
// `dirlink` pointing out of the root really lands at `<elsewhere>/y`, but
|
|
94
|
+
// measured from the lexical parent it reads as `<root>/y` — an in-root
|
|
95
|
+
// path the caller's path does not lead to. So the parent is made real
|
|
96
|
+
// first. An absolute target ignores the base.
|
|
97
|
+
const base = realpathSync(path.dirname(probe));
|
|
98
|
+
probeReal = resolveLocation(path.resolve(base, link), depth + 1);
|
|
99
|
+
}
|
|
100
|
+
return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
|
|
101
|
+
}
|
|
102
|
+
export function resolveSafe(toolName, rel, root = workspaceRoot()) {
|
|
103
|
+
const rootResolved = path.resolve(root);
|
|
104
|
+
const abs = path.resolve(rootResolved, rel);
|
|
105
|
+
// 1) Lexical containment — rejects `..` and absolute escapes. The trailing
|
|
106
|
+
// separator avoids the `/root` vs `/root-sibling` prefix pitfall.
|
|
107
|
+
if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
|
|
108
|
+
throw new ToolPermissionError(toolName, rel);
|
|
109
|
+
}
|
|
110
|
+
let real;
|
|
111
|
+
try {
|
|
112
|
+
const rootReal = realpathSync(rootResolved);
|
|
113
|
+
real = resolveLocation(abs);
|
|
114
|
+
// 2) Symlink-aware containment. The real path must land inside the real
|
|
115
|
+
// root, so an in-workspace symlink pointing at /etc is refused here.
|
|
116
|
+
if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
|
|
117
|
+
throw new ToolPermissionError(toolName, rel);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err instanceof ToolPermissionError)
|
|
122
|
+
throw err;
|
|
123
|
+
throw new ToolPermissionError(toolName, rel);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
abs,
|
|
127
|
+
real,
|
|
128
|
+
given: rel,
|
|
129
|
+
rel: toPosix(path.relative(rootResolved, abs)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** A path relative to the workspace root, with `/` separators on every OS. */
|
|
133
|
+
export function toPosix(p) {
|
|
134
|
+
return path.sep === "/" ? p : p.split(path.sep).join("/");
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* True when `candidate` is `parent` or lives underneath it. Both must already
|
|
138
|
+
* be absolute. Used to keep an archive entry, or a symlink inside an
|
|
139
|
+
* extracted tree, from reaching outside its destination.
|
|
140
|
+
*/
|
|
141
|
+
export function isInside(parent, candidate) {
|
|
142
|
+
const p = path.resolve(parent);
|
|
143
|
+
const c = path.resolve(candidate);
|
|
144
|
+
return c === p || c.startsWith(`${p}${path.sep}`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Reject an archive member name that would escape its destination: an
|
|
148
|
+
* absolute path, a Windows drive prefix, or any `..` segment. Checked on the
|
|
149
|
+
* NAME, before extraction, because after extraction the damage is done.
|
|
150
|
+
*/
|
|
151
|
+
export function archiveEntryEscapes(name) {
|
|
152
|
+
const normalized = name.replace(/\\/g, "/");
|
|
153
|
+
if (normalized.startsWith("/"))
|
|
154
|
+
return true;
|
|
155
|
+
if (/^[A-Za-z]:/.test(normalized))
|
|
156
|
+
return true;
|
|
157
|
+
return normalized.split("/").some((segment) => segment === "..");
|
|
158
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-buildperf",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic build-performance gates: bundle size, benchmark comparison and flaky-test detection against a baseline",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@crewhaus/errors": "0.7.0",
|
|
19
|
+
"@crewhaus/tool-builder": "0.7.0",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.7.0",
|
|
21
|
+
"@crewhaus/tool-math": "0.7.0",
|
|
22
|
+
"zod": "^3.23.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Max Meier",
|
|
30
|
+
"email": "max@crewhaus.ai",
|
|
31
|
+
"url": "https://crewhaus.ai"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
36
|
+
"directory": "packages/tool-buildperf"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-buildperf#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
46
|
+
}
|