disk-space-saviour 0.13.1 → 0.15.1

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/src/env/shell.js CHANGED
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import path from 'node:path';
11
+ import { sharedWritableLayer } from '../docker/writable.js';
11
12
 
12
- import { shellQuote } from '../exec.js';
13
+ import { containerExecutor, shellQuote, trace } from '../exec.js';
13
14
  import { DEFAULT_SKIP_NAMES, parseDf, processAliases } from './local.js';
14
15
 
15
16
  const STAT_FORMAT = '%F|%s|%b|%B|%Y|%n';
@@ -32,8 +33,8 @@ true`;
32
33
  const USAGE_MANY_SCRIPT = `for t in "$@"; do
33
34
  [ -e "$t" ] || [ -L "$t" ] || continue
34
35
  printf '${RECORD}%s\\n' "$t"
35
- du -sk -- "$t" 2>/dev/null | cut -f1
36
- find "$t" ! -type d -exec stat -c %Y {} + 2>/dev/null | awk 'BEGIN{m=0;n=0}{n++; if ($1>m) m=$1} END{print n, m}'
36
+ du -skx -- "$t" 2>/dev/null | cut -f1
37
+ find "$t" -xdev ! -type d -exec stat -c %Y {} + 2>/dev/null | awk 'BEGIN{m=0;n=0}{n++; if ($1>m) m=$1} END{print n, m}'
37
38
  stat -c %Y -- "$t"
38
39
  done
39
40
  true`;
@@ -44,18 +45,29 @@ for p in /proc/[0-9]*; do
44
45
  e=$(readlink "$p/exe" 2>/dev/null)
45
46
  a=$(tr '\\000' '\\n' < "$p/cmdline" 2>/dev/null | head -n 1)
46
47
  w=$(readlink "$p/cwd" 2>/dev/null)
48
+ q=$(tr '\\000' ' ' < "$p/cmdline" 2>/dev/null)
47
49
  t=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1,20)
48
- printf '%s|%s|%s|%s|%s|%s\\n' "\${p#/proc/}" "$t" "$c" "$e" "$a" "$w"
50
+ printf '%s|%s|%s|%s|%s|%s|%s\\n' "\${p#/proc/}" "$t" "$c" "$e" "$a" "$w" "$q"
49
51
  done`;
50
52
 
51
- const OPEN_PATHS_SCRIPT = `failed=0
53
+ export const OPEN_PATHS_SCRIPT = `failed=0
52
54
  for p in /proc/[0-9]*; do
53
55
  [ -d "$p" ] || continue
54
- ls "$p/fd" >/dev/null 2>&1 || { [ -d "$p" ] && failed=1; continue; }
56
+ state=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1)
57
+ [ "$state" = Z ] && continue
58
+ unreadable=0
59
+ ls "$p/fd" >/dev/null 2>&1 || unreadable=1
55
60
  for l in "$p/cwd" "$p/exe" "$p"/fd/*; do
56
61
  [ -L "$l" ] || continue
57
- readlink "$l" 2>/dev/null || failed=1
62
+ readlink "$l" 2>/dev/null || { [ -L "$l" ] && unreadable=1; }
58
63
  done
64
+ if [ "$unreadable" = 1 ] && [ -d "$p" ]; then
65
+ printf 'unreadable pid %s (%s)\\n' "\${p#/proc/}" "$(cat "$p/comm" 2>/dev/null)" >&2
66
+ uid=$(awk '/^Uid:/{print $2}' "$p/status" 2>/dev/null)
67
+ command=$(tr '\\000' ' ' < "$p/cmdline" 2>/dev/null)
68
+ printf 'DSS_UNREADABLE|%s|%s|%s|%s\\n' "\${p#/proc/}" "$uid" "$(cat "$p/comm" 2>/dev/null)" "$command" >&2
69
+ failed=1
70
+ fi
59
71
  done
60
72
  exit "$failed"`;
61
73
 
@@ -156,6 +168,10 @@ export class ShellEnv {
156
168
  */
157
169
  constructor(executor, options = {}) {
158
170
  this.executor = executor;
171
+ this.writableLayer =
172
+ executor.parent && executor.containerId
173
+ ? sharedWritableLayer(executor.parent, executor.containerId)
174
+ : null;
159
175
  this.id = options.id ?? executor.label;
160
176
  this.kind = options.kind ?? 'container';
161
177
  this.label = options.label ?? executor.label;
@@ -289,7 +305,74 @@ export class ShellEnv {
289
305
  * @returns {Promise<Map<string, object>>}
290
306
  */
291
307
  async usageMany(targets) {
308
+ const usages = await this.rawUsageMany(targets);
309
+ return this.writableLayer
310
+ ? this.writableLayer.measure(this, usages)
311
+ : usages;
312
+ }
313
+
314
+ async statMany(targets) {
315
+ const stats = new Map();
316
+ for (const batch of batches(targets)) {
317
+ const result = await this.executor.run([
318
+ 'stat',
319
+ '-c',
320
+ `${STAT_FORMAT}|%d:%i:%u`,
321
+ '--',
322
+ ...batch,
323
+ ]);
324
+ for (const line of result.stdout.split('\n').filter(Boolean)) {
325
+ const end = line.lastIndexOf('|');
326
+ const stat = parseStatLine(line.slice(0, end));
327
+ if (stat) {
328
+ const identity = line.slice(end + 1).split(':');
329
+ stats.set(stat.name, {
330
+ ...stat,
331
+ inode: identity.slice(0, 2).join(':'),
332
+ uid: identity.length > 2 ? Number(identity[2]) : undefined,
333
+ });
334
+ }
335
+ }
336
+ }
337
+ return stats;
338
+ }
339
+
340
+ async rawUsageMany(targets) {
292
341
  const usages = new Map();
342
+ const policy = this.scanPolicy;
343
+ if (policy) {
344
+ targets = targets.filter((target) => !policy.removalReason(target));
345
+ for (const target of targets) {
346
+ const expression = this.#boundaryPatterns().flatMap(
347
+ (pattern, index) => [
348
+ ...(index ? ['-o'] : []),
349
+ /[/\\]/.test(pattern) ? '-path' : '-name',
350
+ pattern.replaceAll('\\', '/'),
351
+ ]
352
+ );
353
+ const found = await this.executor.run([
354
+ 'find',
355
+ target,
356
+ '-xdev',
357
+ '-mindepth',
358
+ '1',
359
+ '(',
360
+ ...expression,
361
+ ')',
362
+ '-print',
363
+ '-quit',
364
+ ]);
365
+ for (const line of found.stdout.split('\n').filter(Boolean)) {
366
+ policy.excludedPaths.add(line);
367
+ }
368
+ if (found.code !== 0) {
369
+ policy.excludedPaths.add(target);
370
+ }
371
+ }
372
+ // du cannot prune same-device mounts or excluded children. Skip their
373
+ // ancestors before either recursive measurement command can enter them.
374
+ targets = targets.filter((target) => !policy.removalReason(target));
375
+ }
293
376
  for (const batch of batches(targets)) {
294
377
  const result = await this.sh(USAGE_MANY_SCRIPT, batch);
295
378
  for (const [target, body] of splitRecords(result.stdout)) {
@@ -344,6 +427,9 @@ export class ShellEnv {
344
427
  * descending into matched directories) and prunes `skipNames`.
345
428
  */
346
429
  async #find(roots, type, globs, maxDepth, skipNames) {
430
+ roots = roots.filter(
431
+ (root) => !this.scanPolicy || this.scanPolicy.allows(root)
432
+ );
347
433
  if (roots.length === 0 || globs.length === 0) {
348
434
  return [];
349
435
  }
@@ -355,10 +441,12 @@ export class ShellEnv {
355
441
  const argv = [
356
442
  'find',
357
443
  ...roots,
444
+ '-xdev',
358
445
  '-mindepth',
359
446
  '1',
360
447
  '-maxdepth',
361
448
  String(maxDepth),
449
+ ...this.#prunePaths(),
362
450
  ...(type === 'd'
363
451
  ? ['-type', 'd', '(', ...findNameExpression(globs), ')', '-print']
364
452
  : []),
@@ -369,7 +457,47 @@ export class ShellEnv {
369
457
  : ['-name', '']),
370
458
  ];
371
459
  const result = await this.executor.run(argv);
372
- return result.stdout.split('\n').filter(Boolean);
460
+ return result.stdout
461
+ .split('\n')
462
+ .filter(
463
+ (target) =>
464
+ target && (!this.scanPolicy || this.scanPolicy.allows(target))
465
+ );
466
+ }
467
+
468
+ #boundaryPatterns() {
469
+ const policy = this.scanPolicy;
470
+ if (!policy) {
471
+ return [];
472
+ }
473
+ return [
474
+ ...policy.exclude,
475
+ ...policy.pruned,
476
+ '/var/lib/docker',
477
+ '/var/lib/containerd',
478
+ '/run/containerd',
479
+ '/var/lib/containers',
480
+ '/tmp/containerd-mount*',
481
+ '/private/tmp/containerd-mount*',
482
+ ].map((pattern) =>
483
+ // find's * also matches slashes. Match optional globstar segments
484
+ // conservatively so its traversal cannot miss a zero-depth exclusion.
485
+ pattern
486
+ .replaceAll('\\', '/')
487
+ .replace(/\/\*\*$/, '')
488
+ .replaceAll('**/', '*')
489
+ );
490
+ }
491
+
492
+ #prunePaths() {
493
+ return this.#boundaryPatterns().flatMap((pattern) => [
494
+ '(',
495
+ /[/\\]/.test(pattern) ? '-path' : '-name',
496
+ pattern,
497
+ ')',
498
+ '-prune',
499
+ '-o',
500
+ ]);
373
501
  }
374
502
 
375
503
  async remove(targets) {
@@ -384,14 +512,18 @@ export class ShellEnv {
384
512
  }
385
513
 
386
514
  async processes() {
387
- const result = await this.sh(PROCESS_SCRIPT);
515
+ const result = await (this.probeExecutor ?? this.executor).run([
516
+ 'sh',
517
+ '-c',
518
+ PROCESS_SCRIPT,
519
+ ]);
388
520
  if (result.code !== 0) {
389
521
  return null;
390
522
  }
391
523
  const [selfPid, ...lines] = result.stdout.split('\n');
392
524
  return lines
393
525
  .map((line) =>
394
- /^(\d+)\|([^|]*)\|([^|]*)\|([^|]*)\|([^|]*)\|(.*)$/.exec(line)
526
+ /^(\d+)\|([^|]*)\|([^|]*)\|([^|]*)\|([^|]*)\|([^|]*)\|(.*)$/.exec(line)
395
527
  )
396
528
  .filter((m) => m && !m[2].startsWith('Z '))
397
529
  .map((m) => ({
@@ -400,14 +532,60 @@ export class ShellEnv {
400
532
  name: m[3],
401
533
  aliases: processAliases(m[4], m[5]),
402
534
  cwd: m[6] || null,
535
+ command: m[7],
403
536
  }))
404
537
  .filter((proc) => String(proc.pid) !== selfPid.trim());
405
538
  }
406
539
 
407
540
  async openPaths() {
408
- const result = await this.sh(OPEN_PATHS_SCRIPT);
541
+ let result = await (this.probeExecutor ?? this.executor).run([
542
+ 'sh',
543
+ '-c',
544
+ OPEN_PATHS_SCRIPT,
545
+ ]);
546
+ this.probeDiagnostic = null;
547
+ if (
548
+ result.code !== 0 &&
549
+ this.executor.parent &&
550
+ this.executor.containerId
551
+ ) {
552
+ const diagnostic = result.stderr.trim();
553
+ trace(
554
+ 'retrying process inspection as privileged root',
555
+ this.id,
556
+ diagnostic
557
+ );
558
+ const inspector = containerExecutor(
559
+ this.executor.parent,
560
+ this.executor.containerId,
561
+ { user: '0', privileged: true }
562
+ );
563
+ result = await inspector.run(['sh', '-c', OPEN_PATHS_SCRIPT]);
564
+ if (result.code !== 0) {
565
+ this.probeDiagnostic = `${diagnostic}; privileged inspection failed: ${result.stderr.trim()}`;
566
+ } else {
567
+ this.probeExecutor = inspector;
568
+ }
569
+ }
570
+ this.unreadableProcesses = result.stderr
571
+ .split('\n')
572
+ .filter((line) => line.startsWith('DSS_UNREADABLE|'))
573
+ .map((line) => {
574
+ const [, pid, uid, name, ...command] = line.split('|');
575
+ return {
576
+ pid: Number(pid),
577
+ uid: uid ? Number(uid) : null,
578
+ name,
579
+ command: command.join('|'),
580
+ };
581
+ });
409
582
  if (result.code !== 0) {
410
- return null;
583
+ this.probeDiagnostic ??= result.stderr.trim();
584
+ if (!this.unreadableProcesses.length) {
585
+ return null;
586
+ }
587
+ this.probeHint =
588
+ 'some processes could not be inspected; run as root for full verification';
411
589
  }
412
590
  return new Set(
413
591
  result.stdout
package/src/exec.js CHANGED
@@ -59,6 +59,7 @@ export function runProcess(argv, options = {}) {
59
59
  const stdout = [];
60
60
  const stderr = [];
61
61
  let captured = 0;
62
+ const captureLimit = options.maxCaptureBytes ?? MAX_CAPTURE_BYTES;
62
63
  let settled = false;
63
64
  const startedAt = Date.now();
64
65
  const finish = (result) => {
@@ -80,7 +81,7 @@ export function runProcess(argv, options = {}) {
80
81
  }, timeoutMs);
81
82
  const collect = (target) => (chunk) => {
82
83
  captured += chunk.length;
83
- if (captured <= MAX_CAPTURE_BYTES) {
84
+ if (captured <= captureLimit) {
84
85
  target.push(chunk);
85
86
  }
86
87
  };
@@ -94,6 +95,7 @@ export function runProcess(argv, options = {}) {
94
95
  code: code ?? 1,
95
96
  stdout: Buffer.concat(stdout).toString('utf8'),
96
97
  stderr: Buffer.concat(stderr).toString('utf8'),
98
+ truncated: captured > captureLimit,
97
99
  });
98
100
  });
99
101
  child.stdin.on('error', () => {});
@@ -161,6 +163,7 @@ export function containerExecutor(parent, containerId, options = {}) {
161
163
  docker,
162
164
  'exec',
163
165
  ...(interactive ? ['-i'] : []),
166
+ ...(options.privileged ? ['--privileged'] : []),
164
167
  ...(options.user ? ['--user', options.user] : []),
165
168
  containerId,
166
169
  ...argv,
@@ -0,0 +1,120 @@
1
+ /** Verify unstaged work against cached remote refs without running filters. */
2
+ import { createHash } from 'node:crypto';
3
+ import { createReadStream, promises as fsp } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ async function blob(root, file, algorithm) {
7
+ const target = path.join(root, file);
8
+ const stat = await fsp.lstat(target).catch((error) => {
9
+ if (['ENOENT', 'ENOTDIR'].includes(error.code)) {
10
+ return null;
11
+ }
12
+ throw error;
13
+ });
14
+ if (!stat) {
15
+ return null;
16
+ }
17
+ if (!stat.isFile() && !stat.isSymbolicLink()) {
18
+ throw new Error('non-file change');
19
+ }
20
+ const link = stat.isSymbolicLink()
21
+ ? Buffer.from(await fsp.readlink(target))
22
+ : null;
23
+ const hash = createHash(algorithm);
24
+ hash.update(`blob ${link ? link.length : stat.size}\0`);
25
+ if (link) {
26
+ hash.update(link);
27
+ } else {
28
+ for await (const chunk of createReadStream(target)) {
29
+ hash.update(chunk);
30
+ }
31
+ }
32
+ return {
33
+ oid: hash.digest('hex'),
34
+ mode: link ? '120000' : stat.mode & 0o111 ? '100755' : '100644',
35
+ };
36
+ }
37
+
38
+ /** All changes must be preserved by one ref; staged changes remain protected. */
39
+ export async function preservedWork(root, git) {
40
+ const status = await git([
41
+ 'status',
42
+ '--porcelain=v1',
43
+ '-z',
44
+ '--untracked-files=all',
45
+ ]);
46
+ if (status.code !== 0) {
47
+ return {};
48
+ }
49
+ const records = status.stdout.split('\0').filter(Boolean);
50
+ const untracked = records.filter((line) => line.startsWith('?? ')).length;
51
+ const result = { untracked, dirty: records.length };
52
+ if (
53
+ !records.length ||
54
+ records.length > 2000 ||
55
+ records.some((line) => ![' ', '?'].includes(line[0]))
56
+ ) {
57
+ return result;
58
+ }
59
+ const refs = await git([
60
+ 'for-each-ref',
61
+ '--format=%(refname)',
62
+ 'refs/remotes',
63
+ ]);
64
+ const format = await git(['rev-parse', '--show-object-format']);
65
+ if (refs.code !== 0 || format.code !== 0) {
66
+ return result;
67
+ }
68
+ const algorithm = format.stdout.trim();
69
+ if (!['sha1', 'sha256'].includes(algorithm)) {
70
+ return result;
71
+ }
72
+ try {
73
+ const files = new Map();
74
+ for (const record of records) {
75
+ const file = record.slice(3);
76
+ files.set(file, await blob(root, file, algorithm));
77
+ }
78
+ for (const ref of refs.stdout
79
+ .trim()
80
+ .split('\n')
81
+ .filter(Boolean)
82
+ .slice(0, 100)) {
83
+ const tree = await git([
84
+ 'ls-tree',
85
+ '-r',
86
+ '-z',
87
+ ref,
88
+ '--',
89
+ ...[...files.keys()].map((file) => `:(literal)${file}`),
90
+ ]);
91
+ if (tree.code !== 0) {
92
+ continue;
93
+ }
94
+ const saved = new Map(
95
+ tree.stdout
96
+ .split('\0')
97
+ .filter(Boolean)
98
+ .map((line) => {
99
+ const tab = line.indexOf('\t');
100
+ const [mode, , oid] = line.slice(0, tab).split(' ');
101
+ return [line.slice(tab + 1), { mode, oid }];
102
+ })
103
+ );
104
+ if (
105
+ [...files].every(([file, current]) =>
106
+ current
107
+ ? saved.get(file)?.oid === current.oid &&
108
+ saved.get(file)?.mode === current.mode
109
+ : !saved.has(file)
110
+ )
111
+ ) {
112
+ result.preservedOn = ref.replace(/^refs\/remotes\//, '');
113
+ break;
114
+ }
115
+ }
116
+ } catch {
117
+ // Unreadable files and unsupported changes keep the original blocker.
118
+ }
119
+ return result;
120
+ }
package/src/git.js CHANGED
@@ -15,6 +15,7 @@ import os from 'node:os';
15
15
  import path from 'node:path';
16
16
 
17
17
  import { trace } from './exec.js';
18
+ import { preservedWork } from './git-preserved.js';
18
19
 
19
20
  const GIT_FLAGS = [
20
21
  '--no-optional-locks',
@@ -93,7 +94,7 @@ export function stateBlockers(label, state) {
93
94
  return [`${label}: ${state.error}`];
94
95
  }
95
96
  const problems = [];
96
- if (state.dirty > 0) {
97
+ if (state.dirty > 0 && !state.preservedOn) {
97
98
  problems.push(plural(state.dirty, 'uncommitted change'));
98
99
  }
99
100
  if (state.unpushed > 0) {
@@ -111,8 +112,9 @@ export class GitInspector {
111
112
  /**
112
113
  * @param {object} env environment adapter
113
114
  */
114
- constructor(env) {
115
+ constructor(env, options = {}) {
115
116
  this.env = env;
117
+ this.options = options;
116
118
  this.states = new Map();
117
119
  this.roots = new Map();
118
120
  this.available = null;
@@ -224,6 +226,9 @@ export class GitInspector {
224
226
  return state;
225
227
  }
226
228
  state.dirty = countLines(status.stdout);
229
+ if (this.options.recognizePreserved && state.dirty > 0) {
230
+ Object.assign(state, await preservedWork(root, git));
231
+ }
227
232
  const unpushed = await git([
228
233
  'log',
229
234
  'HEAD',
@@ -259,7 +264,7 @@ export class GitInspector {
259
264
  }
260
265
 
261
266
  /** Protect an entire temporary work directory, including nested clones. */
262
- async treeBlockers(target) {
267
+ async treeBlockers(target, { allowNoRepo = false } = {}) {
263
268
  const dirs = await this.env.findDirs([target], {
264
269
  globs: ['.git'],
265
270
  maxDepth: 32,
@@ -278,6 +283,9 @@ export class GitInspector {
278
283
  ...files.map((file) => this.env.path.dirname(file)),
279
284
  ]);
280
285
  if (roots.size === 0) {
286
+ if (allowNoRepo) {
287
+ return [];
288
+ }
281
289
  return [`${target}: no Git repository found; cannot verify work`];
282
290
  }
283
291
  if (roots.size > 50) {
@@ -303,9 +311,27 @@ export class GitInspector {
303
311
  const result =
304
312
  this.env.kind === 'host'
305
313
  ? await safeGitDirectory(root, (safeDir) =>
306
- this.#git(root, ['ls-files', '--', relative], safeDir)
314
+ this.#git(
315
+ root,
316
+ [
317
+ 'ls-files',
318
+ '--cached',
319
+ '--others',
320
+ '--exclude-standard',
321
+ '--',
322
+ relative,
323
+ ],
324
+ safeDir
325
+ )
307
326
  )
308
- : await this.#git(root, ['ls-files', '--', relative]);
327
+ : await this.#git(root, [
328
+ 'ls-files',
329
+ '--cached',
330
+ '--others',
331
+ '--exclude-standard',
332
+ '--',
333
+ relative,
334
+ ]);
309
335
  return result.code === 0 ? countLines(result.stdout) : -1;
310
336
  } catch {
311
337
  return -1;
@@ -331,7 +357,9 @@ export class GitInspector {
331
357
  return [`cannot verify Git tracking of ${target}`];
332
358
  }
333
359
  if (tracked > 0) {
334
- return [`${plural(tracked, 'file')} inside ${target} tracked by Git`];
360
+ return [
361
+ `${plural(tracked, 'file')} inside ${target} tracked by Git or not ignored`,
362
+ ];
335
363
  }
336
364
  return requireClean ? this.repoBlockers(root) : [];
337
365
  }
package/src/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface ScanOptions {
15
15
  staleAge?: Duration;
16
16
  /** Alias of `staleAge`. */
17
17
  olderThan?: Duration;
18
- /** Inactivity before whole dependency dirs become `moderate` (`30d`). */
18
+ /** Inactivity before lockfile-backed node_modules become `safe`, other dependency dirs `moderate` (`30d`). */
19
19
  inactive?: Duration;
20
20
  /** Items smaller than this are dropped (`1M`). */
21
21
  minSize?: Size;
@@ -25,7 +25,7 @@ export interface ScanOptions {
25
25
  docker?: boolean | null;
26
26
  /** Docker nesting depth (1 = containers of the host daemon). */
27
27
  dockerDepth?: number;
28
- /** Container ids or names to limit Docker work to. */
28
+ /** Container ids, names or unique prefixes to limit Docker work to. */
29
29
  containers?: string[] | string;
30
30
  scanners?: Array<'projects' | 'global' | 'versions' | 'agents' | 'system'>;
31
31
  /** Only these ecosystems, rules or kinds. */
@@ -34,6 +34,8 @@ export interface ScanOptions {
34
34
  exclude?: string[] | string;
35
35
  includeVolumes?: boolean;
36
36
  removeStoppedContainers?: boolean;
37
+ /** Opt-in age after which failed containers may pass the investigation hold. */
38
+ investigationMaxAge?: Duration | null;
37
39
  /**
38
40
  * Stopped containers (name, id or id prefix of 12+ characters) the
39
41
  * operator approves one by one, including ones kept for investigation.
@@ -63,6 +65,7 @@ export interface CleanOptions extends ScanOptions {
63
65
  confirm?: (item: Item) => boolean | Promise<boolean>;
64
66
  /** `false` skips writing the audit log file. */
65
67
  audit?: boolean;
68
+ signal?: AbortSignal;
66
69
  onEntry?: (entry: AuditEntry, item: Item) => void;
67
70
  }
68
71
 
@@ -97,6 +100,12 @@ export interface Item {
97
100
  path: string | null;
98
101
  paths: string[];
99
102
  bytes: number;
103
+ /** Merged filesystem bytes, including image data, when measured in Docker. */
104
+ totalBytes?: number;
105
+ /** Immutable image bytes excluded from reclaimable totals. */
106
+ imageBytes?: number;
107
+ /** The object was discovered but its reclaimable size could not be measured. */
108
+ sizeUnknown?: boolean;
100
109
  newestMtimeMs: number;
101
110
  tier: Tier;
102
111
  reason: string;
@@ -134,6 +143,7 @@ export interface EnvironmentDescriptor {
134
143
  kind: 'host' | 'container';
135
144
  depth: number;
136
145
  chain: Array<{ containerId: string; name: string }>;
146
+ hint?: string | null;
137
147
  disk?: Disk | null;
138
148
  }
139
149
 
@@ -200,6 +210,8 @@ export interface AuditLog {
200
210
  dryRun: boolean;
201
211
  startedAt: string;
202
212
  finishedAt: string | null;
213
+ aborted?: boolean;
214
+ error?: string;
203
215
  entries: AuditEntry[];
204
216
  freedBytes: number;
205
217
  plannedBytes: number;