disk-space-saviour 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - dddd5f6: Batch remote filesystem operations, add scan budgets and partial reports on
8
+ cancellation, discover containerd upperdirs, batch safe container deletions with
9
+ allocated-byte accounting, and retry process inspection by owner while reporting
10
+ blocked sizes and environment hints.
11
+
12
+ Find version-manager download caches and idle semver baseline builds, honor
13
+ configured cache roots, and watch host task health within its mount namespace.
14
+ Preserve scan and interrupted cleanup pre-scan reports when audit storage is
15
+ unwritable, and avoid root-owned default state under another user's home.
16
+
17
+ ## 0.15.2
18
+
19
+ ### Patch Changes
20
+
21
+ - 841a999: Fix the remaining CI/CD false positives, false negatives and warnings: tests that cannot run on a runner are reported as skipped with the reason instead of passing vacuously or vanishing from the summary, the Deno leg runs with `-A --parallel`, the pedantic zizmor pass runs online and triggers on every file it reads, jscpd 5 receives the comment-skipping setting it understands, the example app reviews its locked install scripts, and release logs no longer warn about `deno.lock` or print the changeset publish output twice.
22
+
3
23
  ## 0.15.1
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -118,6 +118,7 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
118
118
  macOS) are skipped. So are projects with a running `cargo`, `gradle`,
119
119
  `npm`, `node` or similar tool, and anything modified within
120
120
  `--older-than` (1h).
121
+ Container paths are checked individually before each bounded deletion batch.
121
122
  - **Rust builds are pruned in two parts.** `cargo-superseded` removes
122
123
  older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
123
124
  `build/*`, `.fingerprint`) that a newer build replaced, but never while
@@ -135,6 +136,9 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
135
136
  environment stops at once, and the audit log and summary say which
136
137
  process was lost. A build that simply finishes during the cleanup also
137
138
  stops it: an exit cannot be told apart from a crash.
139
+ Host cleanup watches processes in its own mount namespace; each container
140
+ watches its own tasks. Processes with unknown namespace membership remain
141
+ watched.
138
142
  - **Git awareness.** Whole projects and containers are protected while they
139
143
  have unsaved changes, unpushed commits or stashes. Ignored dependency and
140
144
  build outputs can be cleaned despite unrelated source changes; tracked or
@@ -152,6 +156,9 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
152
156
  and left per environment, and disk usage before and after. Scan audits
153
157
  include every candidate and its blockers; blocked candidates do not
154
158
  contribute to reclaimable tier totals.
159
+ If a read-only scan cannot save its audit, it warns and still prints the
160
+ report. Root scans with another user's `HOME` use a private temporary audit
161
+ directory. Set `--audit-dir` to choose a persistent writable location.
155
162
 
156
163
  Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
157
164
  header caches to be cleaned. Active npx hashes remain protected through
@@ -165,6 +172,21 @@ and produces one environment hint. Old isolation logs and sanitized upload stagi
165
172
  are moderate-tier candidates. Opam download cleanup removes its cache
166
173
  directory directly and does not invoke `opam clean` or migrate the root.
167
174
 
175
+ Scans have a two-minute budget per filesystem environment, configurable with
176
+ `--scan-budget 5m` (library: `scanBudget`). Progress goes to stderr, leaving
177
+ JSON stdout clean. SIGINT and SIGTERM stop new work, cancel subprocesses and
178
+ print completed findings marked `aborted`; incomplete findings remain blocked.
179
+ The CLI exits 130 or 143 respectively. A second signal exits immediately.
180
+ Library callers can pass an `AbortSignal` and an `onScanProgress` callback.
181
+
182
+ Cache discovery honors tool configuration before default paths, including
183
+ `GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
184
+ `GRADLE_USER_HOME`, `DENO_DIR`, `BUN_INSTALL_CACHE_DIR` and `XDG_CACHE_HOME`.
185
+ Go, npm, pip, Yarn and pnpm cache queries are bounded and cached per environment.
186
+ Safe rules also cover perlbrew build/download trees, SDKMAN tmp/archive contents,
187
+ nvm downloads, idle `target/semver-checks`, and downloaded Ruby `.gem` files.
188
+ SDKMAN parent directories and installed gems are preserved.
189
+
168
190
  ## Docker
169
191
 
170
192
  With `--docker`, or automatically when a daemon is reachable, `dss` handles
@@ -188,6 +210,11 @@ three kinds of Docker data:
188
210
  this is denied or incomplete, cleanup stays blocked and names the
189
211
  unreadable processes. This also applies when dss runs inside a container
190
212
  with access to its Docker daemon.
213
+ Root probes also retry unreadable processes as their owning UID/GID with
214
+ `setpriv` when available. Readable evidence is retained after failed retries;
215
+ unresolved inspection produces one environment hint. Blocked totals show
216
+ measured bytes and separately identify visible bytes whose freeable size is
217
+ unknown.
191
218
  - **Stopped containers** are listed with their size, image, command, owner
192
219
  session (`HIVE_MIND_PARENT_SESSION_ID` and other session labels or
193
220
  environment variables, else a session id in the container name), task
@@ -223,6 +250,13 @@ memory headroom, and skips excessive candidate counts. Fresh Git checks before
223
250
  stopped-container removal still inspect new work. Verbose mode reports storage
224
251
  measurement progress. Host scans prune runtime, nested overlay, read-only and
225
252
  unselected mount points; explicitly selected ordinary mount roots are allowed.
253
+ For containerd-backed running containers, the daemon-side init process's
254
+ `/proc/<pid>/mountinfo` supplies the overlay upperdir when `GraphDriver` is absent.
255
+ JSON environments and the text summary show the storage source. Paths are
256
+ measured on the daemon host and removed inside the container so overlay
257
+ whiteouts are respected; cleanup reports the measured change in allocated
258
+ upperdir blocks when available. Remote filesystem operations use bounded
259
+ POSIX-shell batches and do not require Node inside the target container.
226
260
 
227
261
  Docker discovery uses a size-free container listing and measures each
228
262
  container independently. Failed size queries are retried once; objects
@@ -343,7 +377,7 @@ Configured in `.prettierrc`:
343
377
  npm install
344
378
  npm test # Node.js, 30s per test
345
379
  bun test --timeout 30000 # Bun
346
- deno test --allow-read # Deno (read-only tests)
380
+ deno test -A --parallel # Deno (fixtures need -A)
347
381
  npm run check # lint + format + duplication
348
382
  npm run test:dind # Docker-in-Docker integration (needs --privileged)
349
383
  node bin/dss.js scan --verbose
package/bin/dss.js CHANGED
@@ -3,14 +3,31 @@
3
3
  import { runCli } from '../src/cli.js';
4
4
 
5
5
  const controller = new AbortController();
6
- const abort = () => controller.abort();
7
- process.once('SIGINT', abort);
8
- process.once('SIGTERM', abort);
6
+ let received = null,
7
+ fallback;
8
+ const abort = (signal) => {
9
+ const code = signal === 'SIGTERM' ? 143 : 130;
10
+ if (received) {
11
+ process.exit(code);
12
+ }
13
+ received = code;
14
+ controller.abort(new Error(`interrupted by ${signal}`));
15
+ fallback = setTimeout(() => process.exit(code), 5000);
16
+ fallback.unref();
17
+ };
18
+ const interrupt = () => abort('SIGINT'),
19
+ terminate = () => abort('SIGTERM');
20
+ process.on('SIGINT', interrupt);
21
+ process.on('SIGTERM', terminate);
9
22
  try {
10
23
  process.exitCode = await runCli(process.argv.slice(2), {
11
24
  signal: controller.signal,
12
25
  });
26
+ if (received) {
27
+ process.exitCode = received;
28
+ }
13
29
  } finally {
14
- process.removeListener('SIGINT', abort);
15
- process.removeListener('SIGTERM', abort);
30
+ clearTimeout(fallback);
31
+ process.removeListener('SIGINT', interrupt);
32
+ process.removeListener('SIGTERM', terminate);
16
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/audit.js CHANGED
@@ -12,6 +12,7 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
 
14
14
  export const AUDIT_SCHEMA = 1;
15
+ let privateAuditDirectory;
15
16
 
16
17
  export function throwIfAborted(options) {
17
18
  if (options.signal?.aborted) {
@@ -74,8 +75,24 @@ export function startAudit(command, fields = {}) {
74
75
  * @returns {Promise<string>}
75
76
  */
76
77
  export async function writeAudit(audit, options = {}) {
77
- const dir = auditDirectory(options);
78
- await fsp.mkdir(dir, { recursive: true });
78
+ let dir = auditDirectory(options);
79
+ // sudo/docker exec may retain another user's HOME. Do not leave root-owned
80
+ // default state there; explicit audit locations remain the caller's choice.
81
+ if (
82
+ !options.auditDir &&
83
+ !process.env.DSS_AUDIT_DIR &&
84
+ process.getuid?.() === 0
85
+ ) {
86
+ const home = os.homedir();
87
+ const owner = await fsp.stat(home).catch(() => null);
88
+ if (owner && owner.uid !== 0 && dir.startsWith(`${home}${path.sep}`)) {
89
+ privateAuditDirectory ??= fsp.mkdtemp(
90
+ path.join(os.tmpdir(), 'dss-audit-0-')
91
+ );
92
+ dir = await privateAuditDirectory;
93
+ }
94
+ }
95
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
79
96
  if (!options.inProgress) {
80
97
  audit.finishedAt ??= new Date().toISOString();
81
98
  }
package/src/clean.js CHANGED
@@ -31,6 +31,9 @@ import {
31
31
  import { imageNamed } from './docker/images.js';
32
32
  import { resolveEnvironment } from './env/resolve.js';
33
33
  import { configureScanPolicy } from './env/policy.js';
34
+ import { configureCacheRoots } from './env/cache-roots.js';
35
+ import { environmentScope, signalExecutor } from './env/scope.js';
36
+ import { batches } from './env/batches.js';
34
37
  import { GitInspector } from './git.js';
35
38
  import { HealthWatch } from './health.js';
36
39
  import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
@@ -219,6 +222,18 @@ export class Cleaner {
219
222
  return;
220
223
  }
221
224
  const ctx = this.context(item.env);
225
+ const executor = ctx.executor;
226
+ ctx.executor = signalExecutor(executor, this.options.signal);
227
+ try {
228
+ return await environmentScope(ctx.env, this.options.signal, () =>
229
+ this.#processScoped(ctx, item, entry)
230
+ );
231
+ } finally {
232
+ ctx.executor = executor;
233
+ }
234
+ }
235
+
236
+ async #processScoped(ctx, item, entry) {
222
237
  const fresh = await this.#recheck(ctx, item);
223
238
  if (fresh.reason) {
224
239
  entry.reason = fresh.reason;
@@ -357,6 +372,10 @@ export class Cleaner {
357
372
 
358
373
  async #staticBlocker(ctx, item) {
359
374
  if (item.recheck?.type === 'version') {
375
+ if (!ctx.cacheRootsConfigured) {
376
+ await configureCacheRoots(ctx.env, await ctx.env.homeDirs());
377
+ ctx.cacheRootsConfigured = true;
378
+ }
360
379
  const rule = VERSION_RULES.find(
361
380
  (candidate) => candidate.id === item.rule
362
381
  );
@@ -469,6 +488,9 @@ export class Cleaner {
469
488
  * item stops; an item checked path by path skips just the busy path.
470
489
  */
471
490
  async #removePaths(ctx, item, entry) {
491
+ if (ctx.env.kind === 'container' && item.checks?.perPath) {
492
+ return this.#removeBatches(ctx, item, entry);
493
+ }
472
494
  for (const target of item.action.paths) {
473
495
  if (this.options.signal?.aborted) {
474
496
  throw new Error('cleanup aborted');
@@ -490,18 +512,114 @@ export class Cleaner {
490
512
  entry.reason = boundary;
491
513
  return;
492
514
  }
493
- await ctx.env.remove([target]);
494
- ctx.env.writableLayer?.forget?.(target, ctx.env.path);
495
- entry.status = 'removed';
496
- entry.deletedPaths.push(target);
497
- entry.freedBytes += usage?.bytes ?? 0;
498
- await this.options.onProgress?.(entry);
515
+ await this.#deletePaths(ctx, entry, [target], usage?.bytes ?? 0);
499
516
  }
500
517
  if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
501
518
  entry.reason = 'every path was in use or recently written';
502
519
  }
503
520
  }
504
521
 
522
+ async #removeBatches(ctx, item, entry) {
523
+ for (const batch of batches(item.action.paths)) {
524
+ if (!(await this.#removeBatch(ctx, item, entry, batch))) {
525
+ return;
526
+ }
527
+ }
528
+ }
529
+
530
+ /** One short remote deletion per batch, after fresh health/liveness/boundary checks. */
531
+ async #removeBatch(ctx, item, entry, batch) {
532
+ await configureScanPolicy(ctx.env, this.options);
533
+ await ctx.liveness.refresh(true);
534
+ const stopped = this.health.verify(item, ctx.liveness.processList());
535
+ if (stopped) {
536
+ entry.reason = stopped;
537
+ return false;
538
+ }
539
+ await ctx.liveness.resolve(batch);
540
+ const usages = await ctx.env.usageMany(batch);
541
+ const { selected, reason } = this.#batchSelection(
542
+ ctx,
543
+ item,
544
+ entry,
545
+ batch,
546
+ usages
547
+ );
548
+ if (reason) {
549
+ entry.reason = reason;
550
+ return false;
551
+ }
552
+ if (!selected.length) {
553
+ return true;
554
+ }
555
+ await this.#deletePaths(ctx, entry, selected, sumBytes(usages, selected));
556
+ const lost = await this.health.check(ctx.env, item);
557
+ if (lost) {
558
+ entry.reason = lost;
559
+ }
560
+ return !lost;
561
+ }
562
+
563
+ #batchSelection(ctx, item, entry, batch, usages) {
564
+ const selected = [];
565
+ for (const target of batch) {
566
+ const blocker =
567
+ ctx.env.scanPolicy.removalReason(target) ??
568
+ ctx.liveness.busyReason({ ...item, paths: [target] });
569
+ if (blocker) {
570
+ return { selected: [], reason: blocker };
571
+ }
572
+ const usage = usages.get(target);
573
+ const busy = this.#pathActivity(ctx, item, target, usage);
574
+ const unavailable = this.#unavailableUsage(usage);
575
+ if (busy || unavailable) {
576
+ entry.skippedPaths.push({ path: target, reason: busy ?? unavailable });
577
+ } else {
578
+ selected.push(target);
579
+ }
580
+ }
581
+ return { selected };
582
+ }
583
+
584
+ #unavailableUsage(usage) {
585
+ if (!usage) {
586
+ return 'path disappeared';
587
+ }
588
+ if (usage.sizeUnknown) {
589
+ return 'writable-layer size unknown';
590
+ }
591
+ return usage.bytes === 0 ? 'no writable bytes remain' : null;
592
+ }
593
+
594
+ async #deletePaths(ctx, entry, selected, estimate) {
595
+ const before = await ctx.env.writableLayer?.allocatedBytes?.(
596
+ selected,
597
+ ctx.env.path
598
+ );
599
+ await ctx.env.remove(selected);
600
+ const after = await ctx.env.writableLayer?.allocatedBytes?.(
601
+ selected,
602
+ ctx.env.path
603
+ );
604
+ for (const target of selected) {
605
+ ctx.env.writableLayer?.forget?.(target, ctx.env.path);
606
+ }
607
+ entry.status = 'removed';
608
+ entry.deletedPaths.push(...selected);
609
+ entry.freedBytes += this.#freedBytes(ctx, entry, before, after, estimate);
610
+ await this.options.onProgress?.(entry);
611
+ }
612
+
613
+ #freedBytes(ctx, entry, before, after, fallback) {
614
+ if (typeof before === 'number' && typeof after === 'number') {
615
+ return Math.max(0, before - after);
616
+ }
617
+ if (ctx.env.writableLayer) {
618
+ entry.freedEstimated = true;
619
+ }
620
+ return fallback;
621
+ }
622
+
505
623
  #pathActivity(ctx, item, target, usage) {
506
624
  return (
507
625
  item.checks?.perPath &&
package/src/cli.js CHANGED
@@ -37,6 +37,7 @@ Scope:
37
37
  --recursive docker: recurse into nested daemons
38
38
  --container ID|NAME limit Docker work to these containers (repeat)
39
39
  --max-depth N directory depth for project search (default 6)
40
+ --scan-budget AGE time budget per filesystem environment (2m)
40
41
  --only NAME only these ecosystems, rules or kinds (repeat)
41
42
  --exclude PATH|GLOB never touch matching paths (repeat)
42
43
  --scanner NAME projects, global, versions, agents, system
@@ -80,6 +81,7 @@ const OPTIONS = {
80
81
  recursive: { type: 'boolean' },
81
82
  container: { type: 'string', multiple: true },
82
83
  'max-depth': { type: 'string' },
84
+ 'scan-budget': { type: 'string' },
83
85
  only: { type: 'string', multiple: true },
84
86
  exclude: { type: 'string', multiple: true },
85
87
  scanner: { type: 'string', multiple: true },
@@ -158,6 +160,7 @@ export function toOptions(values, paths = []) {
158
160
  dockerDepth: integer(values.depth, '--depth'),
159
161
  containers: values.container,
160
162
  maxDepth: integer(values['max-depth'], '--max-depth'),
163
+ scanBudget: values['scan-budget'],
161
164
  only: values.only,
162
165
  exclude: values.exclude,
163
166
  scanners: values.scanner,
@@ -294,9 +297,18 @@ class Cli {
294
297
 
295
298
  async scan(options, values) {
296
299
  const report = await this.api.scan(options);
300
+ return this.printScan(report, options, values);
301
+ }
302
+
303
+ async printScan(report, options, values) {
297
304
  const audit = scanAudit(report);
298
305
  if (options.audit !== false) {
299
- await writeAudit(audit, options);
306
+ try {
307
+ await writeAudit(audit, options);
308
+ } catch (error) {
309
+ report.audit = { file: null, error: error.message, code: error.code };
310
+ this.io.stderr(`Audit log: not written (${error.message})`);
311
+ }
300
312
  }
301
313
  this.print(values, report, () =>
302
314
  [
@@ -305,7 +317,7 @@ class Cli {
305
317
  'Run `dss clean --tier safe` to see the plan, add --yes to delete.',
306
318
  ].join('\n')
307
319
  );
308
- return report.errors.length > 0 ? 1 : 0;
320
+ return report.aborted ? 130 : report.errors.length > 0 ? 1 : 0;
309
321
  }
310
322
 
311
323
  async loadReport(options, values) {
@@ -360,6 +372,9 @@ class Cli {
360
372
 
361
373
  async clean(options, values) {
362
374
  const report = await this.loadReport(options, values);
375
+ if (report.aborted) {
376
+ return this.printScan(report, options, values);
377
+ }
363
378
  const audit = await this.consent(values, (extra) =>
364
379
  this.api.clean(report, { ...options, ...extra })
365
380
  );
@@ -379,6 +394,9 @@ class Cli {
379
394
  throw new UsageError(error.message);
380
395
  }
381
396
  const report = await this.loadReport(options, values);
397
+ if (report.aborted) {
398
+ return this.printScan(report, options, values);
399
+ }
382
400
  const audit = await this.consent(values, (extra) =>
383
401
  this.api.emergency({ ...options, report, ...extra })
384
402
  );
@@ -441,6 +459,10 @@ export async function runCli(argv, deps = {}) {
441
459
  if (deps.signal) {
442
460
  options.signal = deps.signal;
443
461
  }
462
+ options.onScanProgress = (progress) =>
463
+ io.stderr(
464
+ `[scan] ${progress.env}: ${progress.scanner}, ${Math.round(progress.elapsedMs / 1000)}s, ${progress.items} items (${progress.status})`
465
+ );
444
466
  return await dispatch(
445
467
  new Cli(io, deps.api ?? API),
446
468
  command,
package/src/docker/cli.js CHANGED
@@ -306,12 +306,16 @@ export class DockerCli {
306
306
  const stream = this.executor.spawn(
307
307
  this.argv(['cp', `${id}:${source}`, '-'])
308
308
  );
309
- return pipeInto(stream, [
310
- 'tar',
311
- '-x',
312
- '-C',
313
- destination,
314
- ...excludes.map((pattern) => `--exclude=${pattern}`),
315
- ]);
309
+ return pipeInto(
310
+ stream,
311
+ [
312
+ 'tar',
313
+ '-x',
314
+ '-C',
315
+ destination,
316
+ ...excludes.map((pattern) => `--exclude=${pattern}`),
317
+ ],
318
+ { signal: this.executor.signal }
319
+ );
316
320
  }
317
321
  }
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { backupDirectory } from '../audit.js';
24
24
  import { ShellEnv } from '../env/shell.js';
25
+ import { checkSignal, signalExecutor } from '../env/scope.js';
25
26
  import { containerExecutor, trace } from '../exec.js';
26
27
  import { block, makeItem } from '../items.js';
27
28
  import { DockerCli, parseDockerSize } from './cli.js';
@@ -380,6 +381,7 @@ class DockerScan {
380
381
  }
381
382
 
382
383
  async daemon(record, depth, inherited) {
384
+ checkSignal(this.options.signal);
383
385
  const docker = new DockerCli(record.executor);
384
386
  const info = await docker.info();
385
387
  if (!info) {
@@ -443,6 +445,7 @@ class DockerScan {
443
445
  })),
444
446
  };
445
447
  for (const ps of all) {
448
+ checkSignal(this.options.signal);
446
449
  ps.sizes = await this.bestEffort(
447
450
  record.env.id,
448
451
  () => docker.containerSize(ps.ID),
@@ -544,6 +547,7 @@ class DockerScan {
544
547
  }))
545
548
  );
546
549
  for (const ps of all) {
550
+ checkSignal(this.options.signal);
547
551
  const selected = selection.ids.has(ps.ID);
548
552
  if (!selected) {
549
553
  continue;
@@ -597,7 +601,10 @@ class DockerScan {
597
601
 
598
602
  async running(record, depth, ps, entry, inspected) {
599
603
  const label = `${record.env.label}/${entry.name}`;
600
- const executor = containerExecutor(record.executor, ps.ID, { label });
604
+ const executor = signalExecutor(
605
+ containerExecutor(record.executor, ps.ID, { label }),
606
+ this.options.signal
607
+ );
601
608
  const env = new ShellEnv(executor, {
602
609
  id: `${record.env.id}/${entry.name}`,
603
610
  label,
@@ -614,14 +621,20 @@ class DockerScan {
614
621
  entry.env = env.id;
615
622
  entry.scanned = true;
616
623
  const chain = [...record.chain, { containerId: ps.ID, name: entry.name }];
617
- this.result.environments.push({
624
+ const descriptor = {
618
625
  id: env.id,
619
626
  label,
620
627
  kind: 'container',
621
628
  depth,
622
629
  chain,
623
- });
630
+ };
631
+ this.result.environments.push(descriptor);
624
632
  this.result.items.push(...(await this.scanEnvironment(env)));
633
+ Object.assign(descriptor, {
634
+ scanStatus: env.scanStatus,
635
+ hint: env.probeHint ?? null,
636
+ layerSource: env.writableLayer?.source ?? null,
637
+ });
625
638
  if (await env.which('docker')) {
626
639
  try {
627
640
  await this.daemon({ env, executor, chain }, depth, true);
@@ -650,6 +663,14 @@ export async function scanDocker(hostRecord, options, scanEnvironment) {
650
663
  if (!(await hostRecord.env.which('docker'))) {
651
664
  return scan.result;
652
665
  }
653
- await scan.daemon(hostRecord, 0, false);
666
+ try {
667
+ await scan.daemon(hostRecord, 0, false);
668
+ } catch (error) {
669
+ scan.result.errors.push({
670
+ env: hostRecord.env.id,
671
+ scanner: 'docker',
672
+ message: error.message,
673
+ });
674
+ }
654
675
  return scan.result;
655
676
  }
@@ -0,0 +1,44 @@
1
+ import { batches } from '../env/batches.js';
2
+
3
+ /** Root overlay from the running container's mount namespace, read by its daemon. */
4
+ export function rootUpperdir(mountinfo) {
5
+ for (const line of mountinfo.split('\n')) {
6
+ const [mount, filesystem] = line.split(' - ');
7
+ if (mount.split(' ')[4] !== '/' || !filesystem?.startsWith('overlay ')) {
8
+ continue;
9
+ }
10
+ const options = filesystem.split(' ').slice(2).join(' ');
11
+ const upper = /(?:^|,)upperdir=([^,]+)/.exec(options)?.[1];
12
+ if (upper) {
13
+ return upper.replace(/\\([0-7]{3})/g, (_, code) =>
14
+ String.fromCharCode(Number.parseInt(code, 8))
15
+ );
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
21
+ /** One transport call per bounded batch; null means inaccessible, never guessed. */
22
+ export async function upperdirBytes(executor, paths) {
23
+ const measured = new Map();
24
+ const script = `for t do
25
+ printf '\\001%s\\n' "$t"
26
+ value=$(LC_ALL=C du -skx -- "$t" 2>&1)
27
+ code=$?
28
+ if [ "$code" = 0 ]; then printf '%s\\n' "$value" | cut -f1
29
+ else case "$value" in *'No such file or directory'*) echo 0 ;; *) echo unknown ;; esac; fi
30
+ done`;
31
+ for (const batch of batches(paths)) {
32
+ const result = await executor.run(['sh', '-c', script, 'dss', ...batch]);
33
+ for (const record of result.stdout.split('\u0001').slice(1)) {
34
+ const newline = record.indexOf('\n'),
35
+ target = record.slice(0, newline),
36
+ value = record.slice(newline + 1).trim();
37
+ measured.set(
38
+ target,
39
+ result.code === 0 && /^\d+$/.test(value) ? Number(value) * 1024 : null
40
+ );
41
+ }
42
+ }
43
+ return measured;
44
+ }