@git.zone/tsrust 1.5.0 → 1.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.
@@ -0,0 +1,304 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+
4
+ export const toolCacheMarkerFile = '.gitzone-tool-cache.json';
5
+ export const tsrustCacheOwner = '@git.zone/tsrust';
6
+ export const targetCacheKind = 'rust-target-cache';
7
+
8
+ export interface IToolCacheMarker {
9
+ owner: string;
10
+ kind: string;
11
+ safeToPrune: boolean;
12
+ createdAt: string;
13
+ schemaVersion: 1;
14
+ }
15
+
16
+ export interface ITsrustPruneCandidate {
17
+ path: string;
18
+ workspace: string;
19
+ bytes: number;
20
+ marked: boolean;
21
+ shouldPrune: boolean;
22
+ reason: string;
23
+ truncated?: boolean;
24
+ }
25
+
26
+ export interface ITsrustPruneOptions {
27
+ workspace: string;
28
+ targetDir?: string;
29
+ days?: number;
30
+ maxSizeBytes?: number;
31
+ }
32
+
33
+ export function createTargetCacheMarker(): IToolCacheMarker {
34
+ return {
35
+ owner: tsrustCacheOwner,
36
+ kind: targetCacheKind,
37
+ safeToPrune: true,
38
+ createdAt: new Date().toISOString(),
39
+ schemaVersion: 1,
40
+ };
41
+ }
42
+
43
+ export function isValidTargetCacheMarker(markerArg: unknown): markerArg is IToolCacheMarker {
44
+ const marker = markerArg as Partial<IToolCacheMarker>;
45
+ return marker.owner === tsrustCacheOwner
46
+ && marker.kind === targetCacheKind
47
+ && marker.safeToPrune === true
48
+ && marker.schemaVersion === 1;
49
+ }
50
+
51
+ export function getDefaultManagedTargetDir(workspaceArg: string): string {
52
+ return path.resolve(workspaceArg, '.nogit', 'tsrust-target');
53
+ }
54
+
55
+ export function isSafeManagedTargetDir(workspaceArg: string, targetDirArg: string): boolean {
56
+ const workspace = path.resolve(workspaceArg);
57
+ const targetDir = path.resolve(targetDirArg);
58
+ const relative = path.relative(workspace, targetDir);
59
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
60
+ return false;
61
+ }
62
+ return relative === path.join('.nogit', 'tsrust-target')
63
+ || relative.startsWith(`${path.join('.nogit', 'tsrust-')}`)
64
+ || relative.startsWith(`${path.join('.nogit', 'tsrust')}${path.sep}`);
65
+ }
66
+
67
+ export function resolveManagedTargetDir(workspaceArg: string, configTargetDirArg?: string): string {
68
+ const configured = process.env.TSRUST_TARGET_DIR || configTargetDirArg;
69
+ const targetDir = configured
70
+ ? path.isAbsolute(configured) ? configured : path.resolve(workspaceArg, configured)
71
+ : getDefaultManagedTargetDir(workspaceArg);
72
+ if (!isSafeManagedTargetDir(workspaceArg, targetDir)) {
73
+ throw new Error(`Refusing unsafe tsrust target dir outside workspace .nogit/: ${targetDir}`);
74
+ }
75
+ return targetDir;
76
+ }
77
+
78
+ export function getCargoArtifactDir(targetDirArg: string, profileArg: string, targetTripleArg?: string): string {
79
+ return targetTripleArg
80
+ ? path.join(targetDirArg, targetTripleArg, profileArg)
81
+ : path.join(targetDirArg, profileArg);
82
+ }
83
+
84
+ export function parseSizeToBytes(valueArg: string | undefined): number | undefined {
85
+ if (!valueArg) {
86
+ return undefined;
87
+ }
88
+ const match = valueArg.trim().match(/^(\d+(?:\.\d+)?)(b|kb|kib|mb|mib|gb|gib)?$/i);
89
+ if (!match) {
90
+ return undefined;
91
+ }
92
+ const value = Number(match[1]);
93
+ const unit = (match[2] || 'b').toLowerCase();
94
+ const multiplier = unit === 'gb' || unit === 'gib'
95
+ ? 1024 ** 3
96
+ : unit === 'mb' || unit === 'mib'
97
+ ? 1024 ** 2
98
+ : unit === 'kb' || unit === 'kib'
99
+ ? 1024
100
+ : 1;
101
+ return Math.round(value * multiplier);
102
+ }
103
+
104
+ export async function writeTargetCacheMarker(targetDirArg: string): Promise<void> {
105
+ await fs.mkdir(targetDirArg, { recursive: true });
106
+ const existingMarker = await readTargetCacheMarker(targetDirArg);
107
+ if (existingMarker) {
108
+ return;
109
+ }
110
+ const entries = await fs.readdir(targetDirArg);
111
+ if (entries.length > 0) {
112
+ throw new Error(`Refusing to mark pre-existing non-empty tsrust target cache: ${targetDirArg}`);
113
+ }
114
+ await fs.writeFile(
115
+ path.join(targetDirArg, toolCacheMarkerFile),
116
+ `${JSON.stringify(createTargetCacheMarker(), null, 2)}\n`,
117
+ );
118
+ }
119
+
120
+ export async function readTargetCacheMarker(targetDirArg: string): Promise<IToolCacheMarker | undefined> {
121
+ try {
122
+ const markerRaw = await fs.readFile(path.join(targetDirArg, toolCacheMarkerFile), 'utf8');
123
+ const marker = JSON.parse(markerRaw);
124
+ return isValidTargetCacheMarker(marker) ? marker : undefined;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ async function pathExists(pathArg: string): Promise<boolean> {
131
+ try {
132
+ await fs.stat(pathArg);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ export async function getDirectorySize(pathArg: string): Promise<number> {
140
+ let total = 0;
141
+ let entries;
142
+ try {
143
+ entries = await fs.readdir(pathArg, { withFileTypes: true });
144
+ } catch {
145
+ return 0;
146
+ }
147
+
148
+ for (const entry of entries) {
149
+ const entryPath = path.join(pathArg, entry.name);
150
+ if (entry.isDirectory()) {
151
+ total += await getDirectorySize(entryPath);
152
+ } else if (entry.isFile()) {
153
+ try {
154
+ total += (await fs.stat(entryPath)).size;
155
+ } catch {
156
+ // Ignore files that disappear while planning a prune.
157
+ }
158
+ }
159
+ }
160
+
161
+ return total;
162
+ }
163
+
164
+ async function getDirectoryUsage(pathArg: string, maxEntriesArg = 100_000): Promise<{ bytes: number; newestMtimeMs: number; truncated: boolean }> {
165
+ let bytes = 0;
166
+ let newestMtimeMs = 0;
167
+ let visitedEntries = 0;
168
+ 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;
193
+ try {
194
+ entries = await fs.readdir(entryPathArg, { withFileTypes: true });
195
+ } catch {
196
+ return;
197
+ }
198
+ for (const entry of entries) {
199
+ if (visitedEntries >= maxEntriesArg) {
200
+ truncated = true;
201
+ return;
202
+ }
203
+ await visit(path.join(entryPathArg, entry.name));
204
+ }
205
+ };
206
+
207
+ await visit(pathArg);
208
+ return { bytes, newestMtimeMs, truncated };
209
+ }
210
+
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
+ }
237
+ }
238
+ }
239
+
240
+ return newest;
241
+ }
242
+
243
+ export async function createTsrustPrunePlan(optionsArg: ITsrustPruneOptions): Promise<ITsrustPruneCandidate[]> {
244
+ const workspace = path.resolve(optionsArg.workspace);
245
+ const managedTargetDir = optionsArg.targetDir
246
+ ? resolveManagedTargetDir(workspace, optionsArg.targetDir)
247
+ : resolveManagedTargetDir(workspace);
248
+ const candidates = [
249
+ managedTargetDir,
250
+ path.join(workspace, 'rust', 'target'),
251
+ path.join(workspace, 'ts_rust', 'target'),
252
+ ];
253
+ const uniqueCandidates = [...new Set(candidates.map((candidate) => path.resolve(candidate)))];
254
+ const pruneCandidates: ITsrustPruneCandidate[] = [];
255
+ const maxAgeMs = typeof optionsArg.days === 'number' ? optionsArg.days * 24 * 60 * 60 * 1000 : undefined;
256
+
257
+ for (const candidate of uniqueCandidates) {
258
+ if (!(await pathExists(candidate))) {
259
+ continue;
260
+ }
261
+ const marker = await readTargetCacheMarker(candidate);
262
+ const usage = await getDirectoryUsage(candidate);
263
+ const isManagedTarget = isSafeManagedTargetDir(workspace, candidate);
264
+ const ageMatches = typeof maxAgeMs === 'number' ? Date.now() - usage.newestMtimeMs >= maxAgeMs : false;
265
+ const bytes = usage.bytes;
266
+ const sizeMatches = typeof optionsArg.maxSizeBytes === 'number' ? bytes >= optionsArg.maxSizeBytes : false;
267
+ const shouldPrune = !!marker && isManagedTarget && (ageMatches || sizeMatches);
268
+ const reason = marker
269
+ ? !isManagedTarget
270
+ ? 'marked Rust target outside managed tsrust target allowlist; report-only'
271
+ : shouldPrune
272
+ ? [ageMatches ? `older than ${optionsArg.days} day(s)` : '', sizeMatches ? `at least ${optionsArg.maxSizeBytes} bytes` : ''].filter(Boolean).join(', ')
273
+ : 'marked tsrust target cache; below prune thresholds'
274
+ : 'unmarked conventional Rust target; report-only';
275
+
276
+ pruneCandidates.push({
277
+ path: candidate,
278
+ workspace,
279
+ bytes,
280
+ marked: !!marker,
281
+ shouldPrune,
282
+ reason,
283
+ truncated: usage.truncated,
284
+ });
285
+ }
286
+
287
+ return pruneCandidates;
288
+ }
289
+
290
+ export async function applyTsrustPrunePlan(planArg: ITsrustPruneCandidate[]): Promise<void> {
291
+ 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 });
303
+ }
304
+ }
@@ -1,2 +1,3 @@
1
1
  export { CargoConfig } from './classes.cargoconfig.js';
2
2
  export { CargoRunner } from './classes.cargorunner.js';
3
+ export * from './classes.targetcache.js';
@@ -1,11 +1,21 @@
1
+ import * as fs from 'fs';
1
2
  import * as path from 'path';
2
3
  import * as os from 'os';
3
4
  import * as plugins from '../plugins.js';
4
5
  import { CargoConfig } from '../mod_cargo/index.js';
5
6
  import { CargoRunner } from '../mod_cargo/index.js';
6
- import { ElfInspector } from '../mod_elf/index.js';
7
+ import {
8
+ applyTsrustPrunePlan,
9
+ createTsrustPrunePlan,
10
+ getCargoArtifactDir,
11
+ parseSizeToBytes,
12
+ resolveManagedTargetDir,
13
+ writeTargetCacheMarker,
14
+ } from '../mod_cargo/index.js';
15
+ import { ElfInspector, ProvenanceStamper, type ITsrustBuildInfo } from '../mod_elf/index.js';
7
16
  import { FsHelpers } from '../mod_fs/index.js';
8
17
  import { ToolchainManager } from '../mod_toolchain/index.js';
18
+ import { commitinfo } from '../00_commitinfo_data.js';
9
19
 
10
20
  /** Maps friendly target names to Rust target triples */
11
21
  const targetAliasMap: Record<string, string> = {
@@ -48,6 +58,8 @@ interface ITsrustConfig {
48
58
  static?: boolean;
49
59
  rustflags?: string[];
50
60
  remapLocalPaths?: boolean;
61
+ targetDir?: string;
62
+ pruneAfterBuild?: boolean;
51
63
  }
52
64
 
53
65
  /** crt-static injection only applies to glibc targets; musl is static by default. */
@@ -75,6 +87,44 @@ export class TsRustCli {
75
87
  private registerCommands(): void {
76
88
  this.registerStandardCommand();
77
89
  this.registerCleanCommand();
90
+ this.registerPruneCommand();
91
+ this.registerInspectCommand();
92
+ }
93
+
94
+ /**
95
+ * Collects build provenance once per run: consuming project identity,
96
+ * git commit, and the tsrust version doing the build. Tolerant of
97
+ * non-git checkouts and missing package metadata.
98
+ */
99
+ private async collectBuildProvenance(): Promise<Omit<ITsrustBuildInfo, 'binary' | 'target'>> {
100
+ let projectName = 'unknown';
101
+ let projectVersion = 'unknown';
102
+ try {
103
+ const packageJson = JSON.parse(
104
+ fs.readFileSync(path.join(this.cwd, 'package.json'), 'utf8'),
105
+ );
106
+ projectName = packageJson.name || projectName;
107
+ projectVersion = packageJson.version || projectVersion;
108
+ } catch {
109
+ // package.json optional for pure Rust workspaces
110
+ }
111
+ let gitCommit = 'unknown';
112
+ try {
113
+ const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
114
+ const result = await shell.execSilent(`git -C ${JSON.stringify(this.cwd)} rev-parse HEAD`);
115
+ if (result.exitCode === 0) {
116
+ gitCommit = result.stdout.trim();
117
+ }
118
+ } catch {
119
+ // not a git checkout
120
+ }
121
+ return {
122
+ projectName,
123
+ projectVersion,
124
+ gitCommit,
125
+ builtAt: new Date().toISOString(),
126
+ tsrustVersion: commitinfo.version,
127
+ };
78
128
  }
79
129
 
80
130
  /**
@@ -135,6 +185,8 @@ export class TsRustCli {
135
185
  const shouldClean = !!(argvArg as any).clean;
136
186
  const distDir = path.join(this.cwd, 'dist_rust');
137
187
  const profile = isDebug ? 'debug' : 'release';
188
+ const managedTargetDir = resolveManagedTargetDir(this.cwd, this.config.targetDir);
189
+ await writeTargetCacheMarker(managedTargetDir);
138
190
 
139
191
  // Parse --target flag (can appear multiple times), fall back to smartconfig.json config
140
192
  const cliTargets = (argvArg as any).target;
@@ -152,6 +204,8 @@ export class TsRustCli {
152
204
  console.log(`Using additional Rust flags: ${rustflags.join(' ')}`);
153
205
  }
154
206
 
207
+ const buildProvenance = await this.collectBuildProvenance();
208
+
155
209
  if (targets.length > 0) {
156
210
  // Cross-compilation mode
157
211
  const resolvedTargets = targets.map((t: string) => ({
@@ -175,6 +229,7 @@ export class TsRustCli {
175
229
  target: triple,
176
230
  crtStatic: useStatic && wantsCrtStatic(triple),
177
231
  rustflags,
232
+ targetDir: managedTargetDir,
178
233
  });
179
234
 
180
235
  if (!buildResult.success) {
@@ -182,8 +237,7 @@ export class TsRustCli {
182
237
  process.exit(1);
183
238
  }
184
239
 
185
- // Cross-compiled binaries go to target/<triple>/<profile>/
186
- const targetDir = path.join(rustDir, 'target', triple, profile);
240
+ const targetDir = getCargoArtifactDir(managedTargetDir, profile, triple);
187
241
 
188
242
  for (const binName of workspaceInfo.binTargets) {
189
243
  const srcBinary = path.join(targetDir, binName);
@@ -208,6 +262,13 @@ export class TsRustCli {
208
262
  }
209
263
  console.log(`Verified statically linked: dist_rust/${destName}`);
210
264
  }
265
+
266
+ await ProvenanceStamper.stamp(destBinary, {
267
+ ...buildProvenance,
268
+ binary: binName,
269
+ target: friendly,
270
+ });
271
+ console.log(`Stamped provenance: ${buildProvenance.projectName}@${buildProvenance.projectVersion} ${buildProvenance.gitCommit.slice(0, 12)} (${friendly})`);
211
272
  }
212
273
 
213
274
  // Only clean on first iteration
@@ -234,6 +295,7 @@ export class TsRustCli {
234
295
  target: nativeTriple,
235
296
  crtStatic: !!nativeTriple && wantsCrtStatic(nativeTriple),
236
297
  rustflags,
298
+ targetDir: managedTargetDir,
237
299
  });
238
300
 
239
301
  if (!buildResult.success) {
@@ -241,9 +303,7 @@ export class TsRustCli {
241
303
  process.exit(1);
242
304
  }
243
305
 
244
- const targetDir = nativeTriple
245
- ? path.join(rustDir, 'target', nativeTriple, profile)
246
- : path.join(rustDir, 'target', profile);
306
+ const targetDir = getCargoArtifactDir(managedTargetDir, profile, nativeTriple);
247
307
 
248
308
  await FsHelpers.ensureEmptyDir(distDir);
249
309
 
@@ -269,14 +329,36 @@ export class TsRustCli {
269
329
  }
270
330
  console.log(`Verified statically linked: dist_rust/${binName}`);
271
331
  }
332
+
333
+ await ProvenanceStamper.stamp(destBinary, {
334
+ ...buildProvenance,
335
+ binary: binName,
336
+ target: nativeTriple || 'native',
337
+ });
338
+ console.log(`Stamped provenance: ${buildProvenance.projectName}@${buildProvenance.projectVersion} ${buildProvenance.gitCommit.slice(0, 12)} (${nativeTriple || 'native'})`);
272
339
  }
273
340
  }
274
341
 
342
+ if (this.shouldPruneAfterBuild()) {
343
+ const plan = await createTsrustPrunePlan({
344
+ workspace: this.cwd,
345
+ targetDir: managedTargetDir,
346
+ days: 0,
347
+ });
348
+ await applyTsrustPrunePlan(plan);
349
+ console.log('Pruned marked tsrust target cache after successful build.');
350
+ }
351
+
275
352
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
276
353
  console.log(`\nDone in ${elapsed}s`);
277
354
  });
278
355
  }
279
356
 
357
+ private shouldPruneAfterBuild(): boolean {
358
+ const normalized = process.env.TSRUST_PRUNE_AFTER_BUILD?.toLowerCase();
359
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'y' || this.config.pruneAfterBuild === true;
360
+ }
361
+
280
362
  private buildRustflags(rustDir: string): string[] {
281
363
  const rustflags = [...(this.config.rustflags || [])];
282
364
  if (this.config.remapLocalPaths) {
@@ -295,6 +377,29 @@ export class TsRustCli {
295
377
  return rustflags;
296
378
  }
297
379
 
380
+ /**
381
+ * `tsrust inspect <binary>` — prints the embedded build provenance so any
382
+ * environment can answer "what is this binary built from" definitively.
383
+ */
384
+ private registerInspectCommand(): void {
385
+ this.cli.addCommand('inspect').subscribe(async (argvArg) => {
386
+ const binaryPath = (argvArg as any)._?.[1];
387
+ if (!binaryPath) {
388
+ console.error('Usage: tsrust inspect <path-to-binary>');
389
+ process.exit(1);
390
+ }
391
+ const resolvedPath = path.isAbsolute(binaryPath)
392
+ ? binaryPath
393
+ : path.join(this.cwd, binaryPath);
394
+ const info = await ProvenanceStamper.read(resolvedPath);
395
+ if (!info) {
396
+ console.error(`No tsrust build provenance found in ${resolvedPath} (built before tsrust 1.7.0?).`);
397
+ process.exit(1);
398
+ }
399
+ console.log(JSON.stringify(info, null, 2));
400
+ });
401
+ }
402
+
298
403
  private registerCleanCommand(): void {
299
404
  this.cli.addCommand('clean').subscribe(async (_argvArg) => {
300
405
  // Clean cargo build
@@ -303,7 +408,7 @@ export class TsRustCli {
303
408
  const envPrefix = await this.resolveToolchain();
304
409
  console.log('Running cargo clean...');
305
410
  const runner = new CargoRunner(rustDir, envPrefix);
306
- await runner.clean();
411
+ await runner.clean({ targetDir: resolveManagedTargetDir(this.cwd, this.config.targetDir) });
307
412
  console.log('Cargo clean complete.');
308
413
  }
309
414
 
@@ -318,6 +423,36 @@ export class TsRustCli {
318
423
  });
319
424
  }
320
425
 
426
+ private registerPruneCommand(): void {
427
+ this.cli.addCommand('prune').subscribe(async (argvArg) => {
428
+ const workspace = ((argvArg as any).workspace as string | undefined) || this.cwd;
429
+ const daysArg = (argvArg as any).days;
430
+ const days = daysArg === undefined ? 14 : Number(daysArg);
431
+ const maxSizeBytes = parseSizeToBytes((argvArg as any).maxSize as string | undefined);
432
+ const apply = !!(argvArg as any).apply;
433
+ const plan = await createTsrustPrunePlan({
434
+ workspace,
435
+ targetDir: resolveManagedTargetDir(workspace, this.config.targetDir),
436
+ days: Number.isFinite(days) ? days : 14,
437
+ maxSizeBytes,
438
+ });
439
+
440
+ console.log(apply ? 'tsrust prune apply plan:' : 'tsrust prune dry-run plan:');
441
+ if (plan.length === 0) {
442
+ console.log('No Rust target caches found.');
443
+ }
444
+ for (const candidate of plan) {
445
+ const action = candidate.shouldPrune ? (apply ? 'remove' : 'would remove') : 'report';
446
+ console.log(`${action}: ${candidate.path} (${FsHelpers.formatFileSize(candidate.bytes)}) - ${candidate.reason}`);
447
+ }
448
+ if (apply) {
449
+ await applyTsrustPrunePlan(plan);
450
+ } else {
451
+ console.log('Dry-run only. Re-run with --apply to remove marked tsrust target caches.');
452
+ }
453
+ });
454
+ }
455
+
321
456
  private async detectRustDir(): Promise<string | null> {
322
457
  // Check rust/ first
323
458
  const rustDir = path.join(this.cwd, 'rust');
Binary file
@@ -1 +1,2 @@
1
1
  export { ElfInspector } from './classes.elfinspector.js';
2
+ export { ProvenanceStamper, type ITsrustBuildInfo } from './classes.provenance.js';