@git.zone/tsrust 1.12.0 → 1.13.2

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.
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { withTargetCacheLock } from '../mod_cargo/classes.targetguard.js';
1
2
  import * as fs from 'fs';
2
3
  import * as path from 'path';
3
4
  import * as os from 'os';
@@ -175,56 +176,144 @@ export class TsRustCli {
175
176
  const distDir = path.join(this.cwd, 'dist_rust');
176
177
  const profile = isDebug ? 'debug' : 'release';
177
178
  const managedTargetDir = resolveManagedTargetDir(this.cwd, this.config.targetDir);
178
- await writeTargetCacheMarker(managedTargetDir);
179
+ await withTargetCacheLock(managedTargetDir, async () => {
180
+ await writeTargetCacheMarker(managedTargetDir);
181
+
182
+ // CLI targets override host-specific and legacy configured targets.
183
+ const cliTargets = (argvArg as any).target;
184
+ const cliTargetList: string[] | undefined = cliTargets
185
+ ? (Array.isArray(cliTargets) ? cliTargets : [cliTargets])
186
+ : undefined;
187
+ const hostTriple = new ToolchainManager().getHostTriple();
188
+ const resolvedTargets = resolveBuildTargets(this.config, hostTriple, cliTargetList);
189
+
190
+ const useStatic = !!(argvArg as any).static || !!this.config.static;
191
+ if (useStatic) {
192
+ console.log('Static linking enabled (crt-static for linux-gnu targets)');
193
+ }
179
194
 
180
- // CLI targets override host-specific and legacy configured targets.
181
- const cliTargets = (argvArg as any).target;
182
- const cliTargetList: string[] | undefined = cliTargets
183
- ? (Array.isArray(cliTargets) ? cliTargets : [cliTargets])
184
- : undefined;
185
- const hostTriple = new ToolchainManager().getHostTriple();
186
- const resolvedTargets = resolveBuildTargets(this.config, hostTriple, cliTargetList);
195
+ const rustflags = this.buildRustflags(rustDir);
196
+ if (rustflags.length > 0) {
197
+ console.log(`Using additional Rust flags: ${rustflags.join(' ')}`);
198
+ }
187
199
 
188
- const useStatic = !!(argvArg as any).static || !!this.config.static;
189
- if (useStatic) {
190
- console.log('Static linking enabled (crt-static for linux-gnu targets)');
191
- }
200
+ if (resolvedTargets.length > 0) {
201
+ // Cross-compilation mode
202
+ console.log(
203
+ `Cross-compiling for: ${resolvedTargets.map((target) => `${target.friendly} (${target.triple})`).join(', ')}`,
204
+ );
192
205
 
193
- const rustflags = this.buildRustflags(rustDir);
194
- if (rustflags.length > 0) {
195
- console.log(`Using additional Rust flags: ${rustflags.join(' ')}`);
196
- }
206
+ await FsHelpers.ensureEmptyDir(distDir);
197
207
 
198
- if (resolvedTargets.length > 0) {
199
- // Cross-compilation mode
200
- console.log(
201
- `Cross-compiling for: ${resolvedTargets.map((target) => `${target.friendly} (${target.triple})`).join(', ')}`,
202
- );
208
+ for (const { triple, friendly } of resolvedTargets) {
209
+ console.log(`\n--- Building for ${friendly} (${triple}) ---`);
210
+ if (useStatic && !isLinuxTriple(triple)) {
211
+ console.log(
212
+ `Note: static linking is not applicable for ${triple}; building with default linkage.`,
213
+ );
214
+ }
215
+ const cargoRunner = new CargoRunner(rustDir, envPrefix);
216
+ const gitBefore = await captureGitSnapshot(this.cwd);
217
+ const provenanceBefore = this.collectBuildProvenance(gitBefore);
218
+ const buildResult = await cargoRunner.build({
219
+ debug: isDebug,
220
+ clean: shouldClean,
221
+ target: triple,
222
+ locked: this.config.locked === true,
223
+ crtStatic: useStatic && wantsCrtStatic(triple),
224
+ rustflags,
225
+ targetDir: managedTargetDir,
226
+ });
203
227
 
204
- await FsHelpers.ensureEmptyDir(distDir);
228
+ if (!buildResult.success) {
229
+ console.error(`Build failed for target ${triple} with exit code ${buildResult.exitCode}`);
230
+ process.exit(1);
231
+ }
232
+ const gitAfter = await captureGitSnapshot(this.cwd);
233
+ assertGitSnapshotUnchanged(gitBefore, gitAfter);
234
+ const provenanceAfter = this.collectBuildProvenance(gitAfter);
235
+ this.assertProjectIdentityUnchanged(provenanceBefore, provenanceAfter);
205
236
 
206
- for (const { triple, friendly } of resolvedTargets) {
207
- console.log(`\n--- Building for ${friendly} (${triple}) ---`);
208
- if (useStatic && !isLinuxTriple(triple)) {
209
- console.log(
210
- `Note: static linking is not applicable for ${triple}; building with default linkage.`,
211
- );
237
+ const targetDir = getCargoArtifactDir(managedTargetDir, profile, triple);
238
+
239
+ for (const binName of workspaceInfo.binTargets) {
240
+ const srcBinary = path.join(targetDir, binName);
241
+ const destName = `${binName}_${friendly}`;
242
+ const destBinary = path.join(distDir, destName);
243
+
244
+ if (!(await FsHelpers.fileExists(srcBinary))) {
245
+ console.warn(`Warning: Expected binary not found: ${srcBinary}`);
246
+ continue;
247
+ }
248
+
249
+ if (isDarwinTriple(triple)) {
250
+ const signatureResult = await ensureDarwinCodeSignature(srcBinary);
251
+ console.log(
252
+ `${signatureResult === 'applied' ? 'Applied' : 'Verified'} Darwin code signature: ${srcBinary}`,
253
+ );
254
+ }
255
+ await FsHelpers.copyFile(srcBinary, destBinary);
256
+ await FsHelpers.makeExecutable(destBinary);
257
+
258
+ const size = await FsHelpers.getFileSize(destBinary);
259
+ console.log(
260
+ `Copied ${binName} (${FsHelpers.formatFileSize(size)}) -> dist_rust/${destName}`,
261
+ );
262
+
263
+ if (useStatic && isLinuxTriple(triple)) {
264
+ if (!(await ElfInspector.isStaticElf(destBinary))) {
265
+ console.error(
266
+ `Error: ${destName} is not statically linked (PT_INTERP present) although static linking was requested.`,
267
+ );
268
+ process.exit(1);
269
+ }
270
+ console.log(`Verified statically linked: dist_rust/${destName}`);
271
+ }
272
+
273
+ await ProvenanceStore.write(destBinary, {
274
+ ...provenanceBefore,
275
+ binary: binName,
276
+ target: friendly,
277
+ });
278
+ console.log(
279
+ `Wrote provenance: ${provenanceBefore.projectName}@${provenanceBefore.projectVersion} ${provenanceBefore.gitCommit.slice(0, 12)} (${friendly})`,
280
+ );
281
+ }
282
+
283
+ // Only clean on first iteration
284
+ if (shouldClean) {
285
+ shouldClean = false;
286
+ }
287
+ }
288
+ } else {
289
+ // Native build. When static linking is requested, build with an
290
+ // explicit host --target so RUSTFLAGS never reach host artifacts
291
+ // (proc-macros cannot build with +crt-static on linux-gnu).
292
+ let nativeTriple: string | undefined;
293
+ if (useStatic) {
294
+ nativeTriple = hostTriple;
295
+ if (!isLinuxTriple(nativeTriple)) {
296
+ console.log(
297
+ `Note: static linking is not applicable for ${nativeTriple}; building with default linkage.`,
298
+ );
299
+ }
212
300
  }
301
+
213
302
  const cargoRunner = new CargoRunner(rustDir, envPrefix);
214
303
  const gitBefore = await captureGitSnapshot(this.cwd);
215
304
  const provenanceBefore = this.collectBuildProvenance(gitBefore);
216
305
  const buildResult = await cargoRunner.build({
217
306
  debug: isDebug,
218
307
  clean: shouldClean,
219
- target: triple,
308
+ target: nativeTriple,
220
309
  locked: this.config.locked === true,
221
- crtStatic: useStatic && wantsCrtStatic(triple),
310
+ crtStatic: !!nativeTriple && wantsCrtStatic(nativeTriple),
222
311
  rustflags,
223
312
  targetDir: managedTargetDir,
224
313
  });
225
314
 
226
315
  if (!buildResult.success) {
227
- console.error(`Build failed for target ${triple} with exit code ${buildResult.exitCode}`);
316
+ console.error(`Build failed with exit code ${buildResult.exitCode}`);
228
317
  process.exit(1);
229
318
  }
230
319
  const gitAfter = await captureGitSnapshot(this.cwd);
@@ -232,19 +321,20 @@ export class TsRustCli {
232
321
  const provenanceAfter = this.collectBuildProvenance(gitAfter);
233
322
  this.assertProjectIdentityUnchanged(provenanceBefore, provenanceAfter);
234
323
 
235
- const targetDir = getCargoArtifactDir(managedTargetDir, profile, triple);
324
+ const targetDir = getCargoArtifactDir(managedTargetDir, profile, nativeTriple);
325
+
326
+ await FsHelpers.ensureEmptyDir(distDir);
236
327
 
237
328
  for (const binName of workspaceInfo.binTargets) {
238
329
  const srcBinary = path.join(targetDir, binName);
239
- const destName = `${binName}_${friendly}`;
240
- const destBinary = path.join(distDir, destName);
330
+ const destBinary = path.join(distDir, binName);
241
331
 
242
332
  if (!(await FsHelpers.fileExists(srcBinary))) {
243
333
  console.warn(`Warning: Expected binary not found: ${srcBinary}`);
244
334
  continue;
245
335
  }
246
336
 
247
- if (isDarwinTriple(triple)) {
337
+ if (isDarwinTriple(hostTriple)) {
248
338
  const signatureResult = await ensureDarwinCodeSignature(srcBinary);
249
339
  console.log(
250
340
  `${signatureResult === 'applied' ? 'Applied' : 'Verified'} Darwin code signature: ${srcBinary}`,
@@ -255,127 +345,41 @@ export class TsRustCli {
255
345
 
256
346
  const size = await FsHelpers.getFileSize(destBinary);
257
347
  console.log(
258
- `Copied ${binName} (${FsHelpers.formatFileSize(size)}) -> dist_rust/${destName}`,
348
+ `Copied ${binName} (${FsHelpers.formatFileSize(size)}) -> dist_rust/${binName}`,
259
349
  );
260
350
 
261
- if (useStatic && isLinuxTriple(triple)) {
351
+ if (useStatic && nativeTriple && isLinuxTriple(nativeTriple)) {
262
352
  if (!(await ElfInspector.isStaticElf(destBinary))) {
263
353
  console.error(
264
- `Error: ${destName} is not statically linked (PT_INTERP present) although static linking was requested.`,
354
+ `Error: ${binName} is not statically linked (PT_INTERP present) although static linking was requested.`,
265
355
  );
266
356
  process.exit(1);
267
357
  }
268
- console.log(`Verified statically linked: dist_rust/${destName}`);
358
+ console.log(`Verified statically linked: dist_rust/${binName}`);
269
359
  }
270
360
 
271
361
  await ProvenanceStore.write(destBinary, {
272
362
  ...provenanceBefore,
273
363
  binary: binName,
274
- target: friendly,
364
+ target: nativeTriple || 'native',
275
365
  });
276
366
  console.log(
277
- `Wrote provenance: ${provenanceBefore.projectName}@${provenanceBefore.projectVersion} ${provenanceBefore.gitCommit.slice(0, 12)} (${friendly})`,
278
- );
279
- }
280
-
281
- // Only clean on first iteration
282
- if (shouldClean) {
283
- shouldClean = false;
284
- }
285
- }
286
- } else {
287
- // Native build. When static linking is requested, build with an
288
- // explicit host --target so RUSTFLAGS never reach host artifacts
289
- // (proc-macros cannot build with +crt-static on linux-gnu).
290
- let nativeTriple: string | undefined;
291
- if (useStatic) {
292
- nativeTriple = hostTriple;
293
- if (!isLinuxTriple(nativeTriple)) {
294
- console.log(
295
- `Note: static linking is not applicable for ${nativeTriple}; building with default linkage.`,
367
+ `Wrote provenance: ${provenanceBefore.projectName}@${provenanceBefore.projectVersion} ${provenanceBefore.gitCommit.slice(0, 12)} (${nativeTriple || 'native'})`,
296
368
  );
297
369
  }
298
370
  }
299
371
 
300
- const cargoRunner = new CargoRunner(rustDir, envPrefix);
301
- const gitBefore = await captureGitSnapshot(this.cwd);
302
- const provenanceBefore = this.collectBuildProvenance(gitBefore);
303
- const buildResult = await cargoRunner.build({
304
- debug: isDebug,
305
- clean: shouldClean,
306
- target: nativeTriple,
307
- locked: this.config.locked === true,
308
- crtStatic: !!nativeTriple && wantsCrtStatic(nativeTriple),
309
- rustflags,
310
- targetDir: managedTargetDir,
311
- });
312
-
313
- if (!buildResult.success) {
314
- console.error(`Build failed with exit code ${buildResult.exitCode}`);
315
- process.exit(1);
316
- }
317
- const gitAfter = await captureGitSnapshot(this.cwd);
318
- assertGitSnapshotUnchanged(gitBefore, gitAfter);
319
- const provenanceAfter = this.collectBuildProvenance(gitAfter);
320
- this.assertProjectIdentityUnchanged(provenanceBefore, provenanceAfter);
321
-
322
- const targetDir = getCargoArtifactDir(managedTargetDir, profile, nativeTriple);
323
-
324
- await FsHelpers.ensureEmptyDir(distDir);
325
-
326
- for (const binName of workspaceInfo.binTargets) {
327
- const srcBinary = path.join(targetDir, binName);
328
- const destBinary = path.join(distDir, binName);
329
-
330
- if (!(await FsHelpers.fileExists(srcBinary))) {
331
- console.warn(`Warning: Expected binary not found: ${srcBinary}`);
332
- continue;
333
- }
334
-
335
- if (isDarwinTriple(hostTriple)) {
336
- const signatureResult = await ensureDarwinCodeSignature(srcBinary);
337
- console.log(
338
- `${signatureResult === 'applied' ? 'Applied' : 'Verified'} Darwin code signature: ${srcBinary}`,
339
- );
340
- }
341
- await FsHelpers.copyFile(srcBinary, destBinary);
342
- await FsHelpers.makeExecutable(destBinary);
343
-
344
- const size = await FsHelpers.getFileSize(destBinary);
345
- console.log(
346
- `Copied ${binName} (${FsHelpers.formatFileSize(size)}) -> dist_rust/${binName}`,
347
- );
348
-
349
- if (useStatic && nativeTriple && isLinuxTriple(nativeTriple)) {
350
- if (!(await ElfInspector.isStaticElf(destBinary))) {
351
- console.error(
352
- `Error: ${binName} is not statically linked (PT_INTERP present) although static linking was requested.`,
353
- );
354
- process.exit(1);
355
- }
356
- console.log(`Verified statically linked: dist_rust/${binName}`);
357
- }
358
-
359
- await ProvenanceStore.write(destBinary, {
360
- ...provenanceBefore,
361
- binary: binName,
362
- target: nativeTriple || 'native',
372
+ if (this.shouldPruneAfterBuild()) {
373
+ const plan = await createTsrustPrunePlan({
374
+ workspace: this.cwd,
375
+ targetDir: managedTargetDir,
376
+ days: 0,
363
377
  });
364
- console.log(
365
- `Wrote provenance: ${provenanceBefore.projectName}@${provenanceBefore.projectVersion} ${provenanceBefore.gitCommit.slice(0, 12)} (${nativeTriple || 'native'})`,
366
- );
378
+ await applyTsrustPrunePlan(plan);
379
+ console.log('Pruned marked tsrust target cache after successful build.');
367
380
  }
368
- }
369
381
 
370
- if (this.shouldPruneAfterBuild()) {
371
- const plan = await createTsrustPrunePlan({
372
- workspace: this.cwd,
373
- targetDir: managedTargetDir,
374
- days: 0,
375
- });
376
- await applyTsrustPrunePlan(plan);
377
- console.log('Pruned marked tsrust target cache after successful build.');
378
- }
382
+ });
379
383
 
380
384
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
381
385
  console.log(`\nDone in ${elapsed}s`);
@@ -551,11 +555,12 @@ export class TsRustCli {
551
555
  const daysArg = (argvArg as any).days;
552
556
  const days = daysArg === undefined ? 14 : Number(daysArg);
553
557
  const maxSizeBytes = parseSizeToBytes((argvArg as any).maxSize as string | undefined);
558
+ if ((argvArg as any).maxSize !== undefined && maxSizeBytes === undefined) throw new Error('Invalid --max-size');
554
559
  const apply = !!(argvArg as any).apply;
555
560
  const plan = await createTsrustPrunePlan({
556
561
  workspace,
557
562
  targetDir: resolveManagedTargetDir(workspace, this.config.targetDir),
558
- days: Number.isFinite(days) ? days : 14,
563
+ days,
559
564
  maxSizeBytes,
560
565
  });
561
566