@git.zone/tsrust 1.13.0 → 1.14.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.
Files changed (61) hide show
  1. package/.smartconfig.json +4 -0
  2. package/assets/notices/gcc-gpl-3.0.txt +674 -0
  3. package/assets/notices/gcc-runtime-library-exception-3.1.txt +73 -0
  4. package/assets/notices/glibc-lgpl-2.1.txt +502 -0
  5. package/assets/notices/musl-copyright.txt +193 -0
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/index.d.ts +1 -0
  8. package/dist_ts/index.js +2 -1
  9. package/dist_ts/mod_cargo/classes.cargorunner.d.ts +1 -0
  10. package/dist_ts/mod_cargo/classes.cargorunner.js +47 -39
  11. package/dist_ts/mod_cargo/classes.targetcache.d.ts +10 -0
  12. package/dist_ts/mod_cargo/classes.targetcache.js +94 -75
  13. package/dist_ts/mod_cargo/classes.targetguard.d.ts +4 -0
  14. package/dist_ts/mod_cargo/classes.targetguard.js +83 -0
  15. package/dist_ts/mod_cli/classes.tsrustcli.d.ts +10 -0
  16. package/dist_ts/mod_cli/classes.tsrustcli.js +191 -106
  17. package/dist_ts/mod_cli/helpers.targets.d.ts +2 -0
  18. package/dist_ts/mod_cli/helpers.targets.js +1 -1
  19. package/dist_ts/mod_matrix/classes.nativematrixbuilder.d.ts +6 -0
  20. package/dist_ts/mod_matrix/classes.nativematrixbuilder.js +44 -1
  21. package/dist_ts/mod_notices/classes.nativenoticesgenerator.d.ts +96 -0
  22. package/dist_ts/mod_notices/classes.nativenoticesgenerator.js +912 -0
  23. package/dist_ts/mod_notices/classes.noticetoolchain.d.ts +30 -0
  24. package/dist_ts/mod_notices/classes.noticetoolchain.js +69 -0
  25. package/dist_ts/mod_notices/helpers.licensetext.d.ts +12 -0
  26. package/dist_ts/mod_notices/helpers.licensetext.js +81 -0
  27. package/dist_ts/mod_notices/helpers.muslversion.d.ts +6 -0
  28. package/dist_ts/mod_notices/helpers.muslversion.js +47 -0
  29. package/dist_ts/mod_notices/helpers.noticesconfig.d.ts +51 -0
  30. package/dist_ts/mod_notices/helpers.noticesconfig.js +113 -0
  31. package/dist_ts/mod_notices/helpers.runtimetexts.d.ts +9 -0
  32. package/dist_ts/mod_notices/helpers.runtimetexts.js +45 -0
  33. package/dist_ts/mod_notices/helpers.spdx.d.ts +21 -0
  34. package/dist_ts/mod_notices/helpers.spdx.js +88 -0
  35. package/dist_ts/mod_notices/helpers.staticglibc.d.ts +26 -0
  36. package/dist_ts/mod_notices/helpers.staticglibc.js +45 -0
  37. package/dist_ts/mod_notices/index.d.ts +8 -0
  38. package/dist_ts/mod_notices/index.js +9 -0
  39. package/dist_ts/plugins.d.ts +6 -1
  40. package/dist_ts/plugins.js +7 -2
  41. package/package.json +5 -5
  42. package/readme.md +115 -13
  43. package/ts/00_commitinfo_data.ts +1 -1
  44. package/ts/index.ts +1 -0
  45. package/ts/mod_cargo/classes.cargorunner.ts +52 -43
  46. package/ts/mod_cargo/classes.targetcache.ts +78 -79
  47. package/ts/mod_cargo/classes.targetguard.ts +63 -0
  48. package/ts/mod_cli/classes.tsrustcli.ts +227 -128
  49. package/ts/mod_cli/helpers.targets.ts +2 -0
  50. package/ts/mod_matrix/classes.nativematrixbuilder.ts +44 -0
  51. package/ts/mod_notices/classes.nativenoticesgenerator.ts +1151 -0
  52. package/ts/mod_notices/classes.noticetoolchain.ts +92 -0
  53. package/ts/mod_notices/helpers.licensetext.ts +111 -0
  54. package/ts/mod_notices/helpers.muslversion.ts +46 -0
  55. package/ts/mod_notices/helpers.noticesconfig.ts +190 -0
  56. package/ts/mod_notices/helpers.runtimetexts.ts +63 -0
  57. package/ts/mod_notices/helpers.spdx.ts +107 -0
  58. package/ts/mod_notices/helpers.staticglibc.ts +79 -0
  59. package/ts/mod_notices/index.ts +8 -0
  60. package/ts/plugins.ts +6 -0
  61. package/readme.hints.md +0 -27
@@ -1,5 +1,7 @@
1
- import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
1
+ import * as plugins from '../plugins.js';
2
+ import { withTargetCacheLock, assertTargetCacheIdle } from './classes.targetguard.js';
3
+ const fs = plugins.fsPromises;
4
+ const path = plugins.path;
3
5
 
4
6
  export const toolCacheMarkerFile = '.gitzone-tool-cache.json';
5
7
  export const tsrustCacheOwner = '@git.zone/tsrust';
@@ -21,6 +23,9 @@ export interface ITsrustPruneCandidate {
21
23
  shouldPrune: boolean;
22
24
  reason: string;
23
25
  truncated?: boolean;
26
+ /** Captured eligibility and identity, revalidated by apply. */
27
+ criteria?: { days?: number; maxSizeBytes?: number };
28
+ identity?: { dev: number; ino: number; markerCreatedAt: string };
24
29
  }
25
30
 
26
31
  export interface ITsrustPruneOptions {
@@ -42,7 +47,9 @@ export function createTargetCacheMarker(): IToolCacheMarker {
42
47
 
43
48
  export function isValidTargetCacheMarker(markerArg: unknown): markerArg is IToolCacheMarker {
44
49
  const marker = markerArg as Partial<IToolCacheMarker>;
45
- return marker.owner === tsrustCacheOwner
50
+ return !!marker && typeof marker === 'object'
51
+ && typeof marker.createdAt === 'string' && Number.isFinite(Date.parse(marker.createdAt))
52
+ && marker.owner === tsrustCacheOwner
46
53
  && marker.kind === targetCacheKind
47
54
  && marker.safeToPrune === true
48
55
  && marker.schemaVersion === 1;
@@ -119,7 +126,10 @@ export async function writeTargetCacheMarker(targetDirArg: string): Promise<void
119
126
 
120
127
  export async function readTargetCacheMarker(targetDirArg: string): Promise<IToolCacheMarker | undefined> {
121
128
  try {
122
- const markerRaw = await fs.readFile(path.join(targetDirArg, toolCacheMarkerFile), 'utf8');
129
+ const markerPath = path.join(targetDirArg, toolCacheMarkerFile);
130
+ const stat = await fs.lstat(markerPath);
131
+ if (!stat.isFile() || stat.size > 65536) return undefined;
132
+ const markerRaw = await fs.readFile(markerPath, 'utf8');
123
133
  const marker = JSON.parse(markerRaw);
124
134
  return isValidTargetCacheMarker(marker) ? marker : undefined;
125
135
  } catch {
@@ -161,86 +171,56 @@ export async function getDirectorySize(pathArg: string): Promise<number> {
161
171
  return total;
162
172
  }
163
173
 
164
- async function getDirectoryUsage(pathArg: string, maxEntriesArg = 100_000): Promise<{ bytes: number; newestMtimeMs: number; truncated: boolean }> {
174
+ async function getDirectoryUsage(pathArg: string): Promise<{ bytes: number; newestMtimeMs: number; truncated: boolean }> {
165
175
  let bytes = 0;
166
176
  let newestMtimeMs = 0;
167
177
  let visitedEntries = 0;
168
178
  let truncated = false;
169
-
170
- const visit = async (entryPathArg: string): Promise<void> => {
171
- if (visitedEntries >= maxEntriesArg) {
172
- truncated = true;
173
- return;
174
- }
175
- visitedEntries++;
176
-
177
- let stat;
178
- try {
179
- stat = await fs.stat(entryPathArg);
180
- } catch {
181
- return;
182
- }
183
- newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs);
184
- if (stat.isFile()) {
185
- bytes += stat.size;
186
- return;
187
- }
188
- if (!stat.isDirectory()) {
189
- return;
190
- }
191
-
192
- let entries;
179
+ const deadline = Date.now() + 30_000;
180
+ const root = await fs.lstat(pathArg);
181
+ const visit = async (entryPath: string): Promise<void> => {
182
+ if (++visitedEntries > 100_000 || Date.now() > deadline) { truncated = true; return; }
193
183
  try {
194
- entries = await fs.readdir(entryPathArg, { withFileTypes: true });
195
- } catch {
196
- return;
197
- }
198
- for (const entry of entries) {
199
- if (visitedEntries >= maxEntriesArg) {
184
+ const stat = await fs.lstat(entryPath);
185
+ if (stat.isSymbolicLink() || stat.dev !== root.dev || stat.uid !== root.uid) {
200
186
  truncated = true;
201
187
  return;
202
188
  }
203
- await visit(path.join(entryPathArg, entry.name));
204
- }
189
+ newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs);
190
+ if (stat.isFile()) bytes += stat.size;
191
+ else if (stat.isDirectory()) {
192
+ for (const entry of await fs.readdir(entryPath)) {
193
+ await visit(path.join(entryPath, entry));
194
+ if (truncated) break;
195
+ }
196
+ } else truncated = true;
197
+ } catch { truncated = true; }
205
198
  };
206
-
207
199
  await visit(pathArg);
208
200
  return { bytes, newestMtimeMs, truncated };
209
201
  }
210
202
 
211
- async function getNewestMtimeMs(pathArg: string): Promise<number> {
212
- let newest = 0;
213
- try {
214
- const stat = await fs.stat(pathArg);
215
- newest = stat.mtimeMs;
216
- } catch {
217
- return 0;
218
- }
219
-
220
- let entries;
221
- try {
222
- entries = await fs.readdir(pathArg, { withFileTypes: true });
223
- } catch {
224
- return newest;
225
- }
226
-
227
- for (const entry of entries) {
228
- const entryPath = path.join(pathArg, entry.name);
229
- if (entry.isDirectory()) {
230
- newest = Math.max(newest, await getNewestMtimeMs(entryPath));
231
- } else if (entry.isFile()) {
232
- try {
233
- newest = Math.max(newest, (await fs.stat(entryPath)).mtimeMs);
234
- } catch {
235
- // Ignore files that disappear while planning a prune.
236
- }
203
+ async function assertSafeTarget(workspace: string, target: string): Promise<void> {
204
+ if (!isSafeManagedTargetDir(workspace, target)) throw new Error(`Unsafe target: ${target}`);
205
+ for (const location of [workspace, target]) {
206
+ const stat = await fs.lstat(location);
207
+ if (!stat.isDirectory() || await fs.realpath(location) !== location || stat.uid !== process.getuid?.()) {
208
+ throw new Error(`Target must be an owned directory without symlink ancestors: ${location}`);
237
209
  }
238
210
  }
211
+ const workspaceStat = await fs.lstat(workspace);
212
+ const targetStat = await fs.lstat(target);
213
+ if (workspaceStat.dev !== targetStat.dev) throw new Error('Target crosses a filesystem boundary');
214
+ }
239
215
 
240
- return newest;
216
+ function validateThresholds(options: { days?: number; maxSizeBytes?: number }): void {
217
+ for (const value of [options.days, options.maxSizeBytes]) {
218
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) throw new Error('Prune thresholds must be finite non-negative numbers');
219
+ }
241
220
  }
242
221
 
243
222
  export async function createTsrustPrunePlan(optionsArg: ITsrustPruneOptions): Promise<ITsrustPruneCandidate[]> {
223
+ validateThresholds(optionsArg);
244
224
  const workspace = path.resolve(optionsArg.workspace);
245
225
  const managedTargetDir = optionsArg.targetDir
246
226
  ? resolveManagedTargetDir(workspace, optionsArg.targetDir)
@@ -264,14 +244,19 @@ export async function createTsrustPrunePlan(optionsArg: ITsrustPruneOptions): Pr
264
244
  const ageMatches = typeof maxAgeMs === 'number' ? Date.now() - usage.newestMtimeMs >= maxAgeMs : false;
265
245
  const bytes = usage.bytes;
266
246
  const sizeMatches = typeof optionsArg.maxSizeBytes === 'number' ? bytes >= optionsArg.maxSizeBytes : false;
267
- const shouldPrune = !!marker && isManagedTarget && (ageMatches || sizeMatches);
268
- const reason = marker
247
+ let safetyError: string | undefined;
248
+ if (isManagedTarget) {
249
+ try { await assertSafeTarget(workspace, candidate); }
250
+ catch (error) { safetyError = (error as Error).message; }
251
+ }
252
+ const shouldPrune = !!marker && isManagedTarget && !usage.truncated && !safetyError && (ageMatches || sizeMatches);
253
+ const reason = safetyError || (usage.truncated ? 'incomplete or unsafe cache scan; report-only' : undefined) || (marker
269
254
  ? !isManagedTarget
270
255
  ? 'marked Rust target outside managed tsrust target allowlist; report-only'
271
256
  : shouldPrune
272
257
  ? [ageMatches ? `older than ${optionsArg.days} day(s)` : '', sizeMatches ? `at least ${optionsArg.maxSizeBytes} bytes` : ''].filter(Boolean).join(', ')
273
258
  : 'marked tsrust target cache; below prune thresholds'
274
- : 'unmarked conventional Rust target; report-only';
259
+ : 'unmarked conventional Rust target; report-only');
275
260
 
276
261
  pruneCandidates.push({
277
262
  path: candidate,
@@ -281,6 +266,8 @@ export async function createTsrustPrunePlan(optionsArg: ITsrustPruneOptions): Pr
281
266
  shouldPrune,
282
267
  reason,
283
268
  truncated: usage.truncated,
269
+ criteria: { days: optionsArg.days, maxSizeBytes: optionsArg.maxSizeBytes },
270
+ identity: marker ? { dev: (await fs.lstat(candidate)).dev, ino: (await fs.lstat(candidate)).ino, markerCreatedAt: marker.createdAt } : undefined,
284
271
  });
285
272
  }
286
273
 
@@ -289,16 +276,28 @@ export async function createTsrustPrunePlan(optionsArg: ITsrustPruneOptions): Pr
289
276
 
290
277
  export async function applyTsrustPrunePlan(planArg: ITsrustPruneCandidate[]): Promise<void> {
291
278
  for (const candidate of planArg) {
292
- if (!candidate.shouldPrune) {
293
- continue;
294
- }
295
- if (!isSafeManagedTargetDir(candidate.workspace, candidate.path)) {
296
- throw new Error(`Refusing unsafe Rust target cache path: ${candidate.path}`);
297
- }
298
- const marker = await readTargetCacheMarker(candidate.path);
299
- if (!marker) {
300
- throw new Error(`Refusing to remove unmarked Rust target cache: ${candidate.path}`);
301
- }
302
- await fs.rm(candidate.path, { recursive: true, force: true });
279
+ if (!candidate.shouldPrune) continue;
280
+ await withTargetCacheLock(candidate.path, async () => {
281
+ await assertSafeTarget(candidate.workspace, candidate.path);
282
+ if (!candidate.criteria || !candidate.identity) throw new Error('Prune plan lacks eligibility and identity; create a new plan');
283
+ validateThresholds(candidate.criteria);
284
+ const fresh = (await createTsrustPrunePlan({ workspace: candidate.workspace, targetDir: candidate.path, ...candidate.criteria }))
285
+ .find((entry) => entry.path === candidate.path);
286
+ if (!fresh?.shouldPrune || JSON.stringify(fresh.identity) !== JSON.stringify(candidate.identity)) {
287
+ throw new Error(`Rust target cache changed or is no longer eligible: ${candidate.path}`);
288
+ }
289
+ await assertTargetCacheIdle(candidate.path);
290
+ await assertSafeTarget(candidate.workspace, candidate.path);
291
+ const stat = await fs.lstat(candidate.path);
292
+ if (stat.dev !== candidate.identity.dev || stat.ino !== candidate.identity.ino) throw new Error('Target identity changed');
293
+ // Recheck after process inspection as well; it can take time on busy hosts.
294
+ const usage = await getDirectoryUsage(candidate.path);
295
+ const { days, maxSizeBytes } = candidate.criteria;
296
+ if (usage.truncated || !((days !== undefined && Date.now() - usage.newestMtimeMs >= days * 86400000)
297
+ || (maxSizeBytes !== undefined && usage.bytes >= maxSizeBytes))) throw new Error('Target eligibility changed');
298
+ const marker = await readTargetCacheMarker(candidate.path);
299
+ if (!marker || marker.createdAt !== candidate.identity.markerCreatedAt) throw new Error('Target marker changed');
300
+ await fs.rm(candidate.path, { recursive: true, force: false });
301
+ });
303
302
  }
304
303
  }
@@ -0,0 +1,63 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ const heldTargets = new plugins.AsyncLocalStorage<ReadonlySet<string>>();
4
+
5
+ /** A kernel-owned Linux socket serializes builds and pruning without stale lock files. */
6
+ export async function withTargetCacheLock<T>(targetArg: string, action: () => Promise<T>): Promise<T> {
7
+ const target = plugins.path.resolve(targetArg);
8
+ if (process.platform !== 'linux' || heldTargets.getStore()?.has(target)) return action();
9
+ const address = '\0tsrust-target-' + plugins.crypto.createHash('sha256').update(target).digest('hex');
10
+ const deadline = Date.now() + 30_000;
11
+ let server: plugins.net.Server;
12
+ while (true) {
13
+ server = plugins.net.createServer((connection) => connection.destroy());
14
+ const acquired = await new Promise<boolean>((resolve, reject) => {
15
+ const failed = (error: NodeJS.ErrnoException) => {
16
+ if (error.code === 'EADDRINUSE') resolve(false);
17
+ else reject(error);
18
+ };
19
+ server.once('error', failed);
20
+ server.listen(address, () => { server.removeListener('error', failed); resolve(true); });
21
+ });
22
+ if (acquired) break;
23
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for the Rust target cache guard: ${target}`);
24
+ await new Promise<void>((resolve) => setTimeout(resolve, 100));
25
+ }
26
+ try {
27
+ return await heldTargets.run(new Set([...(heldTargets.getStore() || []), target]), action);
28
+ } finally {
29
+ await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
30
+ }
31
+ }
32
+
33
+ /** Fail closed if this host cannot prove that the cache is idle. */
34
+ export async function assertTargetCacheIdle(target: string): Promise<void> {
35
+ if (process.platform !== 'linux') throw new Error('Safe target pruning requires Linux /proc process inspection');
36
+ const fs = plugins.fsPromises;
37
+ const contains = (value: string) => value === target || value.startsWith(target + plugins.path.sep);
38
+ const disappeared = (error: unknown) => ['ENOENT', 'ESRCH'].includes((error as NodeJS.ErrnoException).code || '');
39
+ const deadline = Date.now() + 30_000;
40
+ for (const pid of await fs.readdir('/proc')) {
41
+ if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
42
+ if (Date.now() > deadline) throw new Error('Process inspection timed out; refusing to prune');
43
+ const base = `/proc/${pid}`;
44
+ try {
45
+ const status = await fs.readFile(`${base}/status`, 'utf8');
46
+ const uid = Number(status.match(/^Uid:\s+(\d+)/m)?.[1]);
47
+ if (!Number.isFinite(uid)) throw new Error(`Cannot identify process ${pid}`);
48
+ if (uid !== process.getuid?.()) continue;
49
+ const command = await fs.readFile(`${base}/comm`, 'utf8');
50
+ if (/^(cargo|rustc|rust-analyzer)\n?$/.test(command)) throw new Error(`Rust tool process ${pid} is active; refusing to prune`);
51
+ for (const name of ['cwd', 'exe', ...(await fs.readdir(`${base}/fd`)).map((fd) => `fd/${fd}`)]) {
52
+ try {
53
+ if (contains(await fs.readlink(`${base}/${name}`))) throw new Error(`Process ${pid} is using target cache ${target}`);
54
+ } catch (error) { if (!disappeared(error)) throw error; }
55
+ }
56
+ const maps = await fs.readFile(`${base}/maps`, 'utf8');
57
+ if (maps.split('\n').some((line) => {
58
+ const mappedPath = line.match(/^\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+(.*)$/)?.[1];
59
+ return mappedPath !== undefined && contains(mappedPath);
60
+ })) throw new Error(`Process ${pid} maps target cache ${target}`);
61
+ } catch (error) { if (!disappeared(error)) throw error; }
62
+ }
63
+ }