carrick 0.3.112 → 0.3.113

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.
@@ -27,17 +27,73 @@ import { buildPoisonIndexes } from './check-poison.js';
27
27
  import { openProbeProgram, probeDeepFindings } from './check-deep.js';
28
28
  import { pairFieldReports } from './check-fields.js';
29
29
  import { pairDispatchUnions } from './check-union.js';
30
- function runProcess(command, args, cwd) {
30
+ function runProcess(command, args, cwd, env) {
31
31
  return new Promise((resolve, reject) => {
32
- const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
32
+ const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
33
33
  let stdout = '';
34
34
  let stderr = '';
35
35
  child.stdout.on('data', (d) => (stdout += d.toString()));
36
36
  child.stderr.on('data', (d) => (stderr += d.toString()));
37
37
  child.on('error', reject);
38
- child.on('close', (code) => resolve({ code, stdout, stderr }));
38
+ child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr }));
39
39
  });
40
40
  }
41
+ /** The tail V8 prints on every fatal heap-exhaustion line. */
42
+ const HEAP_OUT_OF_MEMORY = 'JavaScript heap out of memory';
43
+ /**
44
+ * V8's hard size limits print the same tail, and a larger heap does not lift
45
+ * them, so they are not memory deaths.
46
+ */
47
+ const HARD_SIZE_LIMITS = ['invalid table size', 'invalid array length', 'Invalid array buffer length'];
48
+ /**
49
+ * Whether a process's stderr carries V8's fatal heap-exhaustion line
50
+ * (`Reached heap limit`, `Ineffective mark-compacts near heap limit`, and the
51
+ * other forms all end in `JavaScript heap out of memory`), and not one of the
52
+ * hard size limits that end the same way (carrick#2209).
53
+ *
54
+ * The scanner applies the same rule to the sidecar's own stderr
55
+ * (carrick#2208); the two must stay in step.
56
+ */
57
+ export function carriesHeapLimitLine(stderr) {
58
+ return stderr
59
+ .split('\n')
60
+ .some((line) => line.includes(HEAP_OUT_OF_MEMORY) &&
61
+ !HARD_SIZE_LIMITS.some((limit) => line.includes(limit)));
62
+ }
63
+ /** V8's default old-space ceiling on a 64-bit host, in MB. */
64
+ const V8_DEFAULT_HEAP_CEILING_MB = 4096;
65
+ /**
66
+ * The heap a Node child of the sidecar (the check's compiler) is given
67
+ * (carrick#2209).
68
+ *
69
+ * The scanner sizes the sidecar's cap to the machine and passes it as a
70
+ * command-line flag, which a child does not inherit, so the child ran at V8's
71
+ * default. Handing it the same cap would overcommit the box: the sidecar's
72
+ * cap is already 3/4 of memory, and two such caps turn a heap error into an
73
+ * OS kill, which the sizing exists to avoid. So the child gets what is left of
74
+ * the sidecar's cap after what the sidecar holds now (`rssMb`), and only when
75
+ * that is more than V8's default; otherwise `NODE_OPTIONS` is left as it is
76
+ * and the child runs at the default, as before.
77
+ */
78
+ export function childHeap(execArgv, existing, rssMb) {
79
+ let cap;
80
+ for (let i = 0; i < execArgv.length; i++) {
81
+ const match = /^--max[-_]old[-_]space[-_]size(?:=(.*))?$/.exec(execArgv[i]);
82
+ if (!match)
83
+ continue;
84
+ const value = match[1] ?? execArgv[i + 1];
85
+ if (value !== undefined && /^\d+$/.test(value))
86
+ cap = Number(value);
87
+ }
88
+ const budget = cap === undefined ? 0 : Math.floor(cap - rssMb);
89
+ if (budget <= V8_DEFAULT_HEAP_CEILING_MB)
90
+ return { nodeOptions: existing, heapCapMb: null };
91
+ const flag = `--max-old-space-size=${budget}`;
92
+ return {
93
+ nodeOptions: existing && existing.trim() ? `${existing} ${flag}` : flag,
94
+ heapCapMb: budget,
95
+ };
96
+ }
41
97
  /** Resolve the sidecar root (where the vendored pnpm/tsc live) from this bundle. */
42
98
  function sidecarRoot() {
43
99
  // dist/src/capture/check.js -> up three -> sidecar root.
@@ -198,7 +254,8 @@ export async function runCheck(opts, onProgress) {
198
254
  }
199
255
  // ---- Judge (async, off the event loop) ----------------------------------
200
256
  progress('checking', 'type-checking probes');
201
- const tsc = await runProcess(tscPath, ['--noEmit', '--pretty', 'false', '-p', path.join(ws.probesRel, 'tsconfig.json')], ws.workspaceDir);
257
+ const heap = childHeap(process.execArgv, process.env.NODE_OPTIONS, process.memoryUsage().rss / (1024 * 1024));
258
+ const tsc = await runProcess(tscPath, ['--noEmit', '--pretty', 'false', '-p', path.join(ws.probesRel, 'tsconfig.json')], ws.workspaceDir, { ...process.env, NODE_OPTIONS: heap.nodeOptions });
202
259
  const diagnostics = parseTscOutput(tsc.stdout + '\n' + tsc.stderr);
203
260
  // Abnormal tsc termination: OOM/SIGKILL (exit code null), an internal
204
261
  // crash, or a fileless config error (e.g. TS18003, printed without a
@@ -207,7 +264,9 @@ export async function runCheck(opts, onProgress) {
207
264
  // WITH parsed diagnostics is the legitimate incompatible path (tsc exits 1
208
265
  // on ordinary type errors) and stays untouched. Mirrors the install step's
209
266
  // `code === 0` guard.
210
- if (tsc.code !== 0 && diagnostics.length === 0) {
267
+ // A compiler a signal ended never finished, so a pair with no diagnostic
268
+ // was never cleared, whatever it printed first (carrick#2209).
269
+ if (tsc.signal !== null || (tsc.code !== 0 && diagnostics.length === 0)) {
211
270
  const excerpt = scrubPaths((tsc.stderr || tsc.stdout).trim().slice(0, 2000), scrubCtx);
212
271
  errors.push(`tsc terminated abnormally (exit code ${tsc.code ?? 'null'}) ` +
213
272
  `with no parseable diagnostics${excerpt ? `: ${excerpt}` : ''}`);
@@ -224,6 +283,15 @@ export async function runCheck(opts, onProgress) {
224
283
  ...preGated,
225
284
  ...unresolved,
226
285
  ]);
286
+ // How the compiler ended, as fields, so the scanner can tell a memory
287
+ // death from any other abnormal end without reading the text
288
+ // (carrick#2209).
289
+ const compilerEnd = {
290
+ exit_code: tsc.code,
291
+ signal: tsc.signal,
292
+ heap_limit: carriesHeapLimitLine(tsc.stderr),
293
+ heap_cap_mb: heap.heapCapMb,
294
+ };
227
295
  if (cleanup)
228
296
  safeRm(ws);
229
297
  return {
@@ -235,6 +303,7 @@ export async function runCheck(opts, onProgress) {
235
303
  verdicts,
236
304
  degraded_services: dedupeDegraded(degraded),
237
305
  errors,
306
+ compiler_end: compilerEnd,
238
307
  };
239
308
  }
240
309
  const { probeDiagsByPair, poison, globalErrors } = attributeDiagnostics(diagnostics, ws, probing);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The checkout a service sits in, and the bound an untrusted scan reads
3
+ * inside (carrick#2230).
4
+ *
5
+ * `CARRICK_UNTRUSTED_TREE` (any non-empty value) is the scan's switch, read
6
+ * the same way the scanner reads it. Unset, nothing here constructs a
7
+ * wrapper and every caller passes the compiler what it passed before. Set,
8
+ * the compiler reads no file whose REAL path (links followed) is outside the
9
+ * checkout, outside the sidecar's own install (its TypeScript library files),
10
+ * and outside the directories the caller names as its own (a capture's stub).
11
+ * A refused file reads as missing, so its types become `any`: the posture an
12
+ * unprepared tree already has.
13
+ */
14
+ import ts from 'typescript';
15
+ export declare const UNTRUSTED_TREE_ENV = "CARRICK_UNTRUSTED_TREE";
16
+ /** The checkout the service root sits in: the nearest ancestor holding a
17
+ * `.git` entry (a directory, or the file a worktree carries), else the service
18
+ * root itself. */
19
+ export declare function checkoutRootOf(repoRoot: string): string;
20
+ export declare function isInsideCheckout(file: string, repoRoot: string): boolean;
21
+ /** True when the scan runs under the untrusted-tree switch. */
22
+ export declare function untrustedTree(): boolean;
23
+ /** The real roots an untrusted scan may read beneath. */
24
+ export interface ReadBound {
25
+ readonly roots: readonly string[];
26
+ /** Whether the real path of `file` is beneath a root. Never throws. */
27
+ allows(file: string): boolean;
28
+ }
29
+ /**
30
+ * The bound for a scan of the service at `repoRoot`, or `undefined` when the
31
+ * switch is off. `own` names directories the caller itself owns and reads
32
+ * back (a capture's stub, its scratch).
33
+ */
34
+ export declare function untrustedReadBound(repoRoot: string, own?: readonly string[]): ReadBound | undefined;
35
+ /** `host` with every read the bound refuses answered as a missing file. */
36
+ export declare function boundedCompilerHost(host: ts.CompilerHost, bound: ReadBound | undefined): ts.CompilerHost;
37
+ /** The compiler's own file system, bounded; `ts.sys` itself when no bound. */
38
+ export declare function boundedSys(bound: ReadBound | undefined): ts.System;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The checkout a service sits in, and the bound an untrusted scan reads
3
+ * inside (carrick#2230).
4
+ *
5
+ * `CARRICK_UNTRUSTED_TREE` (any non-empty value) is the scan's switch, read
6
+ * the same way the scanner reads it. Unset, nothing here constructs a
7
+ * wrapper and every caller passes the compiler what it passed before. Set,
8
+ * the compiler reads no file whose REAL path (links followed) is outside the
9
+ * checkout, outside the sidecar's own install (its TypeScript library files),
10
+ * and outside the directories the caller names as its own (a capture's stub).
11
+ * A refused file reads as missing, so its types become `any`: the posture an
12
+ * unprepared tree already has.
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import ts from 'typescript';
18
+ export const UNTRUSTED_TREE_ENV = 'CARRICK_UNTRUSTED_TREE';
19
+ const checkoutRoots = new Map();
20
+ /** The checkout the service root sits in: the nearest ancestor holding a
21
+ * `.git` entry (a directory, or the file a worktree carries), else the service
22
+ * root itself. */
23
+ export function checkoutRootOf(repoRoot) {
24
+ const key = path.resolve(repoRoot);
25
+ const cached = checkoutRoots.get(key);
26
+ if (cached)
27
+ return cached;
28
+ let dir = key;
29
+ let root = key;
30
+ for (;;) {
31
+ if (fs.existsSync(path.join(dir, '.git'))) {
32
+ root = dir;
33
+ break;
34
+ }
35
+ const parent = path.dirname(dir);
36
+ if (parent === dir)
37
+ break;
38
+ dir = parent;
39
+ }
40
+ checkoutRoots.set(key, root);
41
+ return root;
42
+ }
43
+ export function isInsideCheckout(file, repoRoot) {
44
+ const root = checkoutRootOf(repoRoot).replace(/\\/g, '/');
45
+ return file === root || file.startsWith(root.endsWith('/') ? root : root + '/');
46
+ }
47
+ /** True when the scan runs under the untrusted-tree switch. */
48
+ export function untrustedTree() {
49
+ const value = process.env[UNTRUSTED_TREE_ENV];
50
+ return value !== undefined && value !== '';
51
+ }
52
+ /** `p` with every link resolved; a path that does not exist yet resolves
53
+ * through its deepest existing ancestor. */
54
+ function realOf(p) {
55
+ const abs = path.resolve(p);
56
+ const tail = [];
57
+ let dir = abs;
58
+ for (;;) {
59
+ try {
60
+ return path.join(fs.realpathSync(dir), ...tail.reverse());
61
+ }
62
+ catch {
63
+ const parent = path.dirname(dir);
64
+ if (parent === dir)
65
+ return abs;
66
+ tail.push(path.basename(dir));
67
+ dir = parent;
68
+ }
69
+ }
70
+ }
71
+ function within(real, root) {
72
+ return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
73
+ }
74
+ /** Where the sidecar's own files live: its install, and the TypeScript package
75
+ * whose `lib.*.d.ts` every program reads (beside the sidecar in the npm
76
+ * package, so not always beneath it). */
77
+ function sidecarRoots() {
78
+ const here = path.dirname(fileURLToPath(import.meta.url));
79
+ const roots = [
80
+ path.resolve(here, '..', '..', '..'),
81
+ path.dirname(path.dirname(ts.getDefaultLibFilePath({}))),
82
+ ];
83
+ const configured = process.env.CARRICK_SIDECAR_DIR;
84
+ if (configured && configured.trim() !== '')
85
+ roots.push(path.resolve(configured));
86
+ return roots;
87
+ }
88
+ /**
89
+ * The bound for a scan of the service at `repoRoot`, or `undefined` when the
90
+ * switch is off. `own` names directories the caller itself owns and reads
91
+ * back (a capture's stub, its scratch).
92
+ */
93
+ export function untrustedReadBound(repoRoot, own = []) {
94
+ if (!untrustedTree())
95
+ return undefined;
96
+ const roots = [...new Set([checkoutRootOf(repoRoot), ...sidecarRoots(), ...own].map(realOf))];
97
+ return {
98
+ roots,
99
+ allows: (file) => {
100
+ const real = realOf(file);
101
+ return roots.some((root) => within(real, root));
102
+ },
103
+ };
104
+ }
105
+ /** `host` with every read the bound refuses answered as a missing file. */
106
+ export function boundedCompilerHost(host, bound) {
107
+ if (!bound)
108
+ return host;
109
+ const getSourceFile = host.getSourceFile.bind(host);
110
+ const read = host.readFile.bind(host);
111
+ const exists = host.fileExists.bind(host);
112
+ const dirExists = host.directoryExists?.bind(host);
113
+ const dirs = host.getDirectories?.bind(host);
114
+ const readDirectory = host.readDirectory?.bind(host);
115
+ host.getSourceFile = (file, ...rest) => (bound.allows(file) ? getSourceFile(file, ...rest) : undefined);
116
+ host.readFile = (file) => (bound.allows(file) ? read(file) : undefined);
117
+ host.fileExists = (file) => bound.allows(file) && exists(file);
118
+ if (dirExists)
119
+ host.directoryExists = (dir) => bound.allows(dir) && dirExists(dir);
120
+ if (dirs)
121
+ host.getDirectories = (dir) => (bound.allows(dir) ? dirs(dir) : []);
122
+ if (readDirectory) {
123
+ host.readDirectory = (root, ...rest) => readDirectory(root, ...rest).filter((file) => bound.allows(file));
124
+ }
125
+ return host;
126
+ }
127
+ /** The compiler's own file system, bounded; `ts.sys` itself when no bound. */
128
+ export function boundedSys(bound) {
129
+ if (!bound)
130
+ return ts.sys;
131
+ return {
132
+ ...ts.sys,
133
+ readFile: (file, encoding) => (bound.allows(file) ? ts.sys.readFile(file, encoding) : undefined),
134
+ fileExists: (file) => bound.allows(file) && ts.sys.fileExists(file),
135
+ directoryExists: (dir) => bound.allows(dir) && ts.sys.directoryExists(dir),
136
+ getDirectories: (dir) => (bound.allows(dir) ? ts.sys.getDirectories(dir) : []),
137
+ readDirectory: (root, ...rest) => ts.sys.readDirectory(root, ...rest).filter((file) => bound.allows(file)),
138
+ };
139
+ }
@@ -31,6 +31,7 @@ import type { CaptureStubOptions, CaptureStubResult } from './api.js';
31
31
  export type { CaptureStubOptions, CaptureStubResult } from './api.js';
32
32
  export { DenoProject, findDenoConfig } from './deno-project.js';
33
33
  export { sidecarCompilerOptions } from './compiler-options.js';
34
+ export { isInsideCheckout, untrustedReadBound, UNTRUSTED_TREE_ENV, type ReadBound } from './checkout.js';
34
35
  export { serviceConfigPath } from './project-references.js';
35
36
  export { findServiceTsconfig } from './service-config.js';
36
37
  export { runCheck } from './check.js';
@@ -44,8 +44,10 @@ import { findServiceTsconfig } from './service-config.js';
44
44
  import { placeEmittedTree, surfaceModuleInTree } from './outside-root.js';
45
45
  import { WriteGuard } from './guarded-fs.js';
46
46
  import { sidecarCompilerOptions } from './compiler-options.js';
47
+ import { boundedCompilerHost, boundedSys, untrustedReadBound } from './checkout.js';
47
48
  export { DenoProject, findDenoConfig } from './deno-project.js';
48
49
  export { sidecarCompilerOptions } from './compiler-options.js';
50
+ export { isInsideCheckout, untrustedReadBound, UNTRUSTED_TREE_ENV } from './checkout.js';
49
51
  export { serviceConfigPath } from './project-references.js';
50
52
  export { findServiceTsconfig } from './service-config.js';
51
53
  // v2 check core ("tsc as the judge"). Same bundle, same seam: the sidecar
@@ -132,6 +134,10 @@ export function captureStub(opts) {
132
134
  const packageName = `@carrick/${sanitizeServiceName(opts.serviceName)}`;
133
135
  const stubDir = path.resolve(opts.outDir);
134
136
  const errors = [];
137
+ // Under CARRICK_UNTRUSTED_TREE every compiler read below is held to the
138
+ // checkout, the sidecar's own install and this stub; otherwise undefined
139
+ // and nothing here changes (carrick#2230).
140
+ const bound = untrustedReadBound(repoRoot, [stubDir]);
135
141
  // The named config, else the one the init'd project reads too
136
142
  // (carrick#1776). A service with neither is typed under defaults below.
137
143
  const configPath = opts.tsconfigPath
@@ -192,7 +198,7 @@ export function captureStub(opts) {
192
198
  checkJs: false,
193
199
  resolveJsonModule: true,
194
200
  },
195
- }, ts.sys, repoRoot);
201
+ }, boundedSys(bound), repoRoot);
196
202
  }
197
203
  else {
198
204
  // Each anchor is typed under the project that owns its file: the named
@@ -260,7 +266,7 @@ export function captureStub(opts) {
260
266
  // ---- Phase A: analysis program over placeholder entry + anchor sources ----
261
267
  let resolved;
262
268
  const progress = opts.onProgress ?? (() => { });
263
- const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress };
269
+ const analysisCtx = { repoRoot, entryDir: path.dirname(entryPath), entryPath, guard, progress, bound };
264
270
  try {
265
271
  resolved = ownerGroups && emitProject
266
272
  ? resolveAnchorsByOwner(opts, ownerGroups, emitProject, analysisCtx, errors)
@@ -302,7 +308,7 @@ export function captureStub(opts) {
302
308
  written = emitDeclarations({
303
309
  rootNames: [entryPath, ...augmentationSources],
304
310
  options: emitOptions,
305
- host: deno?.host(emitOptions),
311
+ host: programHost(emitOptions, deno, bound),
306
312
  staging,
307
313
  entryDir,
308
314
  });
@@ -490,8 +496,8 @@ export function captureStub(opts) {
490
496
  pinned,
491
497
  bareCheckout,
492
498
  repoRoot: dependencyRoot,
493
- compilerHost: deno || Object.keys(rewritten.installs).length > 0
494
- ? (options) => withInstalledPackages(options, rewritten.installs, deno ? deno.host(options) : undefined)
499
+ compilerHost: deno || bound || Object.keys(rewritten.installs).length > 0
500
+ ? (options) => boundedCompilerHost(withInstalledPackages(options, rewritten.installs, deno ? deno.host(options) : undefined), bound)
495
501
  : undefined,
496
502
  });
497
503
  const fidelity = computeFidelity(aliases);
@@ -691,6 +697,15 @@ function resolveAnchorsByOwner(opts, groups, emit, ctx, errors) {
691
697
  }
692
698
  return resolved;
693
699
  }
700
+ /**
701
+ * The compiler host a capture program is built on: the Deno graph's, else the
702
+ * default one (`undefined`), each held to the read bound when there is one.
703
+ */
704
+ function programHost(options, deno, bound) {
705
+ if (!bound)
706
+ return deno?.host(options);
707
+ return boundedCompilerHost(deno ? deno.host(options) : ts.createCompilerHost(options), bound);
708
+ }
694
709
  /** Phase A: build the placeholder entry, then resolve every anchor. */
695
710
  function resolveAnchors(opts, parsed, ctx, deno) {
696
711
  const placeholderLines = ['// Carrick capture v2 analysis placeholder.'];
@@ -708,7 +723,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
708
723
  ...parsed.options,
709
724
  noEmit: true,
710
725
  });
711
- const program = ts.createProgram([ctx.entryPath, ...anchorSources, ...(deno?.globals ?? [])], options, deno?.host(options));
726
+ const program = ts.createProgram([ctx.entryPath, ...anchorSources, ...(deno?.globals ?? [])], options, programHost(options, deno, ctx.bound));
712
727
  // The checker binds every file of the program when it is first asked for,
713
728
  // which the first anchor would otherwise do: asked for here, so that the
714
729
  // report below says the program is whole before any anchor is read.
@@ -731,7 +746,7 @@ function resolveAnchors(opts, parsed, ctx, deno) {
731
746
  if (!fromEntry.has(specifier)) {
732
747
  fromEntry.set(specifier, (deno
733
748
  ? deno.resolve(specifier, ctx.entryPath, options)
734
- : ts.resolveModuleName(specifier, ctx.entryPath, options, ts.sys, undefined, undefined, entryMode)
749
+ : ts.resolveModuleName(specifier, ctx.entryPath, options, boundedSys(ctx.bound), undefined, undefined, entryMode)
735
750
  .resolvedModule)?.resolvedFileName);
736
751
  }
737
752
  return fromEntry.get(specifier);
@@ -18,8 +18,7 @@
18
18
  * user payload that merely shares a member name can never trip it.
19
19
  */
20
20
  import ts from 'typescript';
21
- import * as fs from 'node:fs';
22
- import * as path from 'node:path';
21
+ import { isInsideCheckout } from './checkout.js';
23
22
  /**
24
23
  * Strongly-discriminating member names of HTTP transport machinery. Kept
25
24
  * identical to `MACHINERY_MEMBER_INDICATORS` in `type-inferrer.ts`. These are
@@ -171,34 +170,6 @@ export function isExternalOrigin(program, sourceFile, repoRoot) {
171
170
  }
172
171
  return program.isSourceFileFromExternalLibrary(sourceFile) && !isInsideCheckout(file, repoRoot);
173
172
  }
174
- const checkoutRoots = new Map();
175
- /** The checkout the service root sits in: the nearest ancestor holding a
176
- * `.git` entry (a directory, or the file a worktree carries), else the service
177
- * root itself. */
178
- function checkoutRootOf(repoRoot) {
179
- const key = path.resolve(repoRoot);
180
- const cached = checkoutRoots.get(key);
181
- if (cached)
182
- return cached;
183
- let dir = key;
184
- let root = key;
185
- for (;;) {
186
- if (fs.existsSync(path.join(dir, '.git'))) {
187
- root = dir;
188
- break;
189
- }
190
- const parent = path.dirname(dir);
191
- if (parent === dir)
192
- break;
193
- dir = parent;
194
- }
195
- checkoutRoots.set(key, root);
196
- return root;
197
- }
198
- function isInsideCheckout(file, repoRoot) {
199
- const root = checkoutRootOf(repoRoot).replace(/\\/g, '/');
200
- return file === root || file.startsWith(root.endsWith('/') ? root : root + '/');
201
- }
202
173
  /**
203
174
  * True when the symbol is declared in a runtime/library origin. Works on a bare
204
175
  * checkout: the DOM `Response`/`Request` resolve from the bundled
@@ -19,10 +19,11 @@
19
19
  */
20
20
  import * as path from 'node:path';
21
21
  import ts from 'typescript';
22
+ import { boundedSys, untrustedReadBound } from './checkout.js';
22
23
  /** Parse one config; throws on a file that cannot be read or parsed. */
23
24
  function parseConfig(configPath, extendedConfigCache) {
24
25
  const host = {
25
- ...ts.sys,
26
+ ...boundedSys(untrustedReadBound(path.dirname(configPath))),
26
27
  onUnRecoverableConfigFileDiagnostic: (d) => {
27
28
  throw new Error(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
28
29
  },
@@ -13,8 +13,8 @@
13
13
  * bundle, so the two are kept in lockstep by `machinery-indicator-mirror.test.ts`
14
14
  * rather than by sharing this module.
15
15
  */
16
- import * as fs from 'node:fs';
17
16
  import * as path from 'node:path';
17
+ import { isInsideCheckout } from './capture/index.js';
18
18
  /**
19
19
  * True when a declaration's source file is runtime/library origin rather than
20
20
  * user source. Four answers, in order:
@@ -129,31 +129,3 @@ export function registerExternalImports(project, imports) {
129
129
  export function externalImportsOf(project) {
130
130
  return externalImportsByProject.get(project);
131
131
  }
132
- const checkoutRoots = new Map();
133
- /** The checkout the service root sits in: the nearest ancestor holding a
134
- * `.git` entry (a directory, or the file a worktree carries), else the service
135
- * root itself. */
136
- function checkoutRootOf(repoRoot) {
137
- const key = path.resolve(repoRoot);
138
- const cached = checkoutRoots.get(key);
139
- if (cached)
140
- return cached;
141
- let dir = key;
142
- let root = key;
143
- for (;;) {
144
- if (fs.existsSync(path.join(dir, '.git'))) {
145
- root = dir;
146
- break;
147
- }
148
- const parent = path.dirname(dir);
149
- if (parent === dir)
150
- break;
151
- dir = parent;
152
- }
153
- checkoutRoots.set(key, root);
154
- return root;
155
- }
156
- function isInsideCheckout(file, repoRoot) {
157
- const root = checkoutRootOf(repoRoot).replace(/\\/g, '/');
158
- return file === root || file.startsWith(root.endsWith('/') ? root : root + '/');
159
- }
@@ -95,6 +95,12 @@ export declare class ProjectLoader {
95
95
  * Convert a TsconfigSnapshot to ts-morph CompilerOptions
96
96
  */
97
97
  private snapshotToCompilerOptions;
98
+ /**
99
+ * Under CARRICK_UNTRUSTED_TREE, the file system every project reads
100
+ * through: nothing whose real path is outside the checkout (carrick#2230).
101
+ * Nothing at all otherwise, so a project is built as it always was.
102
+ */
103
+ private fileSystemOption;
98
104
  /** A ts-morph project built from one tsconfig and the files it lists. */
99
105
  private projectFromConfig;
100
106
  /**
@@ -12,7 +12,7 @@
12
12
  import { Project, getCompilerOptionsFromTsConfig } from 'ts-morph';
13
13
  import * as path from 'node:path';
14
14
  import * as fs from 'node:fs';
15
- import { DenoProject, findDenoConfig, findServiceTsconfig, serviceConfigPath, sidecarCompilerOptions, } from './capture/index.js';
15
+ import { DenoProject, findDenoConfig, findServiceTsconfig, serviceConfigPath, sidecarCompilerOptions, untrustedReadBound, } from './capture/index.js';
16
16
  import { moduleFormatResolutionHost } from './module-format.js';
17
17
  import { ExternalImports, registerExternalImports } from './origin.js';
18
18
  /**
@@ -177,6 +177,7 @@ export class ProjectLoader {
177
177
  this.log('Project will load with tsconfig snapshot');
178
178
  this.buildProject = () => {
179
179
  const project = new Project({
180
+ ...this.fileSystemOption(),
180
181
  compilerOptions: sidecarCompilerOptions(this.snapshotToCompilerOptions(snapshot)),
181
182
  skipAddingFilesFromTsConfig: true,
182
183
  });
@@ -197,6 +198,7 @@ export class ProjectLoader {
197
198
  // rebuilds that drop them (carrick#1731).
198
199
  const imports = new ExternalImports();
199
200
  const project = new Project({
201
+ ...this.fileSystemOption(),
200
202
  compilerOptions: sidecarCompilerOptions(deno.parsed.options),
201
203
  skipAddingFilesFromTsConfig: true,
202
204
  resolutionHost: imports.recording((host, getOptions) => ({
@@ -221,6 +223,7 @@ export class ProjectLoader {
221
223
  this.log('No tsconfig.json found, using default compiler options');
222
224
  this.buildProject = () => {
223
225
  const project = new Project({
226
+ ...this.fileSystemOption(),
224
227
  compilerOptions: sidecarCompilerOptions(DEFAULT_COMPILER_OPTIONS),
225
228
  skipAddingFilesFromTsConfig: true,
226
229
  });
@@ -318,10 +321,20 @@ export class ProjectLoader {
318
321
  }
319
322
  return result;
320
323
  }
324
+ /**
325
+ * Under CARRICK_UNTRUSTED_TREE, the file system every project reads
326
+ * through: nothing whose real path is outside the checkout (carrick#2230).
327
+ * Nothing at all otherwise, so a project is built as it always was.
328
+ */
329
+ fileSystemOption() {
330
+ const bound = untrustedReadBound(this.repoRoot);
331
+ return bound ? { fileSystem: boundedFileSystem(bound) } : {};
332
+ }
321
333
  /** A ts-morph project built from one tsconfig and the files it lists. */
322
334
  projectFromConfig(configPath) {
323
335
  const imports = new ExternalImports();
324
336
  const project = new Project({
337
+ ...this.fileSystemOption(),
325
338
  tsConfigFilePath: configPath,
326
339
  // Over the tsconfig's own options: ts-morph lays these on top.
327
340
  compilerOptions: sidecarCompilerOptions(getCompilerOptionsFromTsConfig(configPath).options),
@@ -492,3 +505,53 @@ export class ProjectLoader {
492
505
  console.error(`[sidecar:project-loader:error] ${message}`);
493
506
  }
494
507
  }
508
+ /**
509
+ * A ts-morph file system that reads only inside the bound. ts-morph reads the
510
+ * tsconfig, lists the files it names and reads each through this host, and its
511
+ * module resolution asks it whether a file exists. Writes are not bounded:
512
+ * the write guard owns them (carrick#1748).
513
+ */
514
+ function boundedFileSystem(bound) {
515
+ const base = new Project({
516
+ compilerOptions: sidecarCompilerOptions({}),
517
+ skipAddingFilesFromTsConfig: true,
518
+ skipFileDependencyResolution: true,
519
+ }).getFileSystem();
520
+ const missing = (filePath) => Object.assign(new Error(`ENOENT: no such file or directory, open '${filePath}'`), { code: 'ENOENT' });
521
+ return new Proxy(base, {
522
+ get(target, prop, receiver) {
523
+ const value = Reflect.get(target, prop, receiver);
524
+ if (typeof value !== 'function')
525
+ return value;
526
+ const fn = value.bind(target);
527
+ switch (prop) {
528
+ case 'readFileSync':
529
+ return (p, e) => {
530
+ if (!bound.allows(p))
531
+ throw missing(p);
532
+ return fn(p, e);
533
+ };
534
+ case 'readFile':
535
+ return async (p, e) => {
536
+ if (!bound.allows(p))
537
+ throw missing(p);
538
+ return fn(p, e);
539
+ };
540
+ case 'fileExistsSync':
541
+ case 'directoryExistsSync':
542
+ return (p) => bound.allows(p) && fn(p);
543
+ case 'fileExists':
544
+ case 'directoryExists':
545
+ return async (p) => bound.allows(p) && (await fn(p));
546
+ case 'readDirSync':
547
+ return (p) => (bound.allows(p) ? fn(p) : []);
548
+ case 'globSync':
549
+ return (patterns) => fn(patterns).filter((f) => bound.allows(f));
550
+ case 'glob':
551
+ return async (patterns) => (await fn(patterns)).filter((f) => bound.allows(f));
552
+ default:
553
+ return fn;
554
+ }
555
+ },
556
+ });
557
+ }