disk-space-saviour 0.16.0 → 0.17.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/src/options.js CHANGED
@@ -16,7 +16,7 @@ export const DEFAULTS = {
16
16
  docker: null,
17
17
  dockerDepth: 3,
18
18
  containers: [],
19
- scanners: ['projects', 'global', 'versions', 'agents', 'system'],
19
+ scanners: ['global', 'projects', 'versions', 'agents', 'system'],
20
20
  only: [],
21
21
  exclude: [],
22
22
  includeVolumes: false,
@@ -33,6 +33,8 @@ export const DEFAULTS = {
33
33
  backupDir: null,
34
34
  auditDir: null,
35
35
  scanBudget: '2m',
36
+ cacheDir: false,
37
+ platformIndex: true,
36
38
  };
37
39
 
38
40
  function toBytes(value) {
package/src/report.js CHANGED
@@ -56,7 +56,7 @@ function itemLine(item, showEnv) {
56
56
  : item.imageBytes
57
57
  ? ` (${formatBytes(item.imageBytes)} in image${item.imageRef ? ` ${item.imageRef}` : ''}; 0 B of that data freeable now; remove the stopped container and unused image after the task completes)`
58
58
  : '';
59
- return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
59
+ return ` ${pad(formatBytes(item.bytes), 10)}${item.sizeEstimated ? ' (estimated)' : ''} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
60
60
  }
61
61
 
62
62
  function limited(lines, limit) {
@@ -318,6 +318,18 @@ export function formatReport(report, options = {}) {
318
318
  `disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
319
319
  formatDisk(host.disk),
320
320
  ];
321
+ if (host.swap) {
322
+ lines.push(
323
+ `Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
324
+ );
325
+ lines.push(host.swap.attribution);
326
+ for (const holder of host.swap.holders) {
327
+ lines.push(
328
+ ` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
329
+ );
330
+ }
331
+ lines.push(host.swap.advice);
332
+ }
321
333
  for (const tier of TIERS) {
322
334
  lines.push(...tierSection(report, tier, { limit }));
323
335
  }
@@ -325,7 +337,11 @@ export function formatReport(report, options = {}) {
325
337
  if (env.layerSource) {
326
338
  lines.push(` ${env.label}: layer source ${env.layerSource}`);
327
339
  }
328
- if (env.scanStatus && env.scanStatus !== 'complete') {
340
+ if (env.scanStatus === 'goal-reached') {
341
+ lines.push(
342
+ ` ${env.label}: scan stopped after the free-space goal was met`
343
+ );
344
+ } else if (env.scanStatus && env.scanStatus !== 'complete') {
329
345
  lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
330
346
  }
331
347
  if (env.hint) {
@@ -0,0 +1,239 @@
1
+ /** Advisory discovery/size index and authenticated, short-lived scan plans. */
2
+ import {
3
+ createHash,
4
+ createHmac,
5
+ randomBytes,
6
+ timingSafeEqual,
7
+ } from 'node:crypto';
8
+ import { promises as fs } from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { trace } from './exec.js';
12
+
13
+ export const REPORT_MAX_AGE = 30 * 60 * 1000;
14
+ export const SIZE_MAX_AGE = 60 * 60 * 1000;
15
+ const LIMIT = 20000;
16
+
17
+ export function defaultStateDirectory(vars = process.env) {
18
+ return path.join(
19
+ vars.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'),
20
+ 'disk-space-saviour'
21
+ );
22
+ }
23
+
24
+ function payload(report) {
25
+ const { authentication, ...fields } = report;
26
+ void authentication;
27
+ return JSON.stringify(fields);
28
+ }
29
+
30
+ async function secret(directory) {
31
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
32
+ const file = path.join(directory, 'scan-key');
33
+ try {
34
+ await fs.writeFile(file, randomBytes(32), { flag: 'wx', mode: 0o600 });
35
+ } catch (error) {
36
+ if (error.code !== 'EEXIST') {
37
+ throw error;
38
+ }
39
+ }
40
+ return fs.readFile(file);
41
+ }
42
+
43
+ export async function authenticateReport(report, directory) {
44
+ if (!directory) {
45
+ return report;
46
+ }
47
+ try {
48
+ report.authentication = {
49
+ hostname: os.hostname(),
50
+ uid: process.getuid?.() ?? os.userInfo().username,
51
+ digest: createHmac('sha256', await secret(directory))
52
+ .update(payload(report))
53
+ .digest('hex'),
54
+ };
55
+ } catch (error) {
56
+ trace('scan state unavailable', error.message);
57
+ }
58
+ return report;
59
+ }
60
+
61
+ function recentLocalReport(report, now) {
62
+ const age = now - Date.parse(report.createdAt);
63
+ const auth = report.authentication;
64
+ if (
65
+ !Number.isFinite(age) ||
66
+ age < 0 ||
67
+ age > REPORT_MAX_AGE ||
68
+ report.aborted ||
69
+ auth?.hostname !== os.hostname() ||
70
+ auth?.uid !== (process.getuid?.() ?? os.userInfo().username)
71
+ ) {
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ export async function authenticatedReport(report, directory, now = Date.now()) {
78
+ if (
79
+ !directory ||
80
+ report?.schema !== 1 ||
81
+ report.tool !== 'disk-space-saviour' ||
82
+ !recentLocalReport(report, now)
83
+ ) {
84
+ return false;
85
+ }
86
+ try {
87
+ const key = await fs.readFile(path.join(directory, 'scan-key'));
88
+ const expected = createHmac('sha256', key).update(payload(report)).digest();
89
+ const actual = Buffer.from(report.authentication.digest, 'hex');
90
+ return (
91
+ expected.length === actual.length && timingSafeEqual(expected, actual)
92
+ );
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ export function scopeKey(options) {
99
+ const names = [
100
+ 'roots',
101
+ 'scanners',
102
+ 'host',
103
+ 'docker',
104
+ 'dockerDepth',
105
+ 'maxDepth',
106
+ 'staleAgeMs',
107
+ 'inactiveMs',
108
+ 'minSizeBytes',
109
+ 'only',
110
+ 'exclude',
111
+ 'includeVolumes',
112
+ 'noNative',
113
+ 'journalKeepBytes',
114
+ 'containerFilter',
115
+ 'removeStoppedContainers',
116
+ 'investigationMaxAgeMs',
117
+ 'removeContainers',
118
+ 'removeImages',
119
+ 'allowDirtyRepos',
120
+ 'allowDirtyContainers',
121
+ ];
122
+ return createHash('sha256')
123
+ .update(JSON.stringify(names.map((name) => [name, options[name]])))
124
+ .digest('hex');
125
+ }
126
+
127
+ export async function saveScan(report, directory) {
128
+ if (
129
+ !directory ||
130
+ !report.authentication ||
131
+ report.aborted ||
132
+ report.environments.some((env) => env.scanStatus === 'goal-reached')
133
+ ) {
134
+ return;
135
+ }
136
+ try {
137
+ const dir = path.join(directory, 'scans');
138
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
139
+ const file = path.join(dir, `${scopeKey(report.options)}.json`);
140
+ const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}`;
141
+ await fs.writeFile(temporary, JSON.stringify(report), { mode: 0o600 });
142
+ await fs.rename(temporary, file);
143
+ } catch (error) {
144
+ trace('cannot save scan', error.message);
145
+ }
146
+ }
147
+
148
+ export async function loadScan(options) {
149
+ if (!options.cacheDir) {
150
+ return null;
151
+ }
152
+ try {
153
+ const report = JSON.parse(
154
+ await fs.readFile(
155
+ path.join(options.cacheDir, 'scans', `${scopeKey(options)}.json`),
156
+ 'utf8'
157
+ )
158
+ );
159
+ return (await authenticatedReport(report, options.cacheDir, options.now()))
160
+ ? report
161
+ : null;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /** Cached listings are validated by each directory's own metadata, never its parent alone. */
168
+ export class ScanIndex {
169
+ constructor(directory) {
170
+ this.directory = directory;
171
+ this.entries = new Map();
172
+ this.dirty = false;
173
+ }
174
+
175
+ async load() {
176
+ if (!this.directory) {
177
+ return;
178
+ }
179
+ try {
180
+ const data = JSON.parse(
181
+ await fs.readFile(
182
+ path.join(this.directory, 'index', 'directories.json'),
183
+ 'utf8'
184
+ )
185
+ );
186
+ if (data.schema === 1 && Array.isArray(data.entries)) {
187
+ this.entries = new Map(data.entries.slice(-LIMIT));
188
+ }
189
+ } catch {
190
+ // Missing/corrupt state only loses an optimization.
191
+ }
192
+ }
193
+
194
+ get(key, fingerprint, maxAge = Infinity) {
195
+ const entry = this.entries.get(key);
196
+ return entry &&
197
+ entry.fingerprint === fingerprint &&
198
+ Date.now() - entry.time < maxAge
199
+ ? entry.value
200
+ : null;
201
+ }
202
+
203
+ set(key, fingerprint, value) {
204
+ this.entries.delete(key);
205
+ this.entries.set(key, { fingerprint, value, time: Date.now() });
206
+ while (this.entries.size > LIMIT) {
207
+ this.entries.delete(this.entries.keys().next().value);
208
+ }
209
+ this.dirty = true;
210
+ }
211
+
212
+ async save() {
213
+ if (!this.directory || !this.dirty) {
214
+ return;
215
+ }
216
+ const dir = path.join(this.directory, 'index');
217
+ const temporary = path.join(
218
+ dir,
219
+ `directories.${process.pid}.${randomBytes(4).toString('hex')}.json`
220
+ );
221
+ try {
222
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
223
+ await fs.writeFile(
224
+ temporary,
225
+ JSON.stringify({ schema: 1, entries: [...this.entries] }),
226
+ { mode: 0o600 }
227
+ );
228
+ await fs.rename(temporary, path.join(dir, 'directories.json'));
229
+ this.dirty = false;
230
+ } catch (error) {
231
+ trace('cannot save discovery index', error.message);
232
+ } finally {
233
+ await fs.rm(temporary, { force: true }).catch(() => {});
234
+ }
235
+ }
236
+ }
237
+
238
+ export const directoryFingerprint = (stats) =>
239
+ `${stats.device}:${stats.inode}:${stats.mtimeMs}:${stats.ctimeMs}`;
package/src/scan.js CHANGED
@@ -24,6 +24,14 @@ import { scanGlobal } from './scanners/global.js';
24
24
  import { scanProjects } from './scanners/projects.js';
25
25
  import { scanSystem } from './scanners/system.js';
26
26
  import { scanVersions } from './scanners/versions.js';
27
+ import { readSwap } from './swap.js';
28
+ import {
29
+ authenticateReport,
30
+ authenticatedReport,
31
+ saveScan,
32
+ ScanIndex,
33
+ scopeKey,
34
+ } from './scan-state.js';
27
35
 
28
36
  export const SCANNERS = {
29
37
  projects: scanProjects,
@@ -76,20 +84,47 @@ function sealReport(report) {
76
84
  return report;
77
85
  }
78
86
 
87
+ function unchangedDiscoveryScope(report, input) {
88
+ const original = resolveOptions(report.options);
89
+ const overrides = Object.fromEntries(
90
+ Object.entries(input).filter(
91
+ ([name, value]) =>
92
+ RESCAN_OPTIONS.has(name) && value !== undefined && value !== null
93
+ )
94
+ );
95
+ return (
96
+ scopeKey(original) ===
97
+ scopeKey(resolveOptions({ ...original, ...overrides }))
98
+ );
99
+ }
100
+
79
101
  /** Rebuild scanner-derived actions when a report came from JSON or changed. */
80
102
  export async function revalidateReport(saved, input = {}) {
81
103
  if (trustedReports.get(saved) === safetySnapshot(saved)) {
82
104
  return saved;
83
105
  }
84
- const scope = {
85
- roots: saved.options?.roots,
86
- scanners: saved.options?.scanners,
87
- containers: saved.options?.containers,
88
- host: saved.options?.host,
89
- docker: saved.options?.docker,
90
- dockerDepth: saved.options?.dockerDepth,
91
- maxDepth: saved.options?.maxDepth,
92
- };
106
+ saved = saved.report?.schema === REPORT_SCHEMA ? saved.report : saved;
107
+ if (
108
+ unchangedDiscoveryScope(saved, input) &&
109
+ (await authenticatedReport(
110
+ saved,
111
+ input.cacheDir,
112
+ input.now?.() ?? Date.now()
113
+ ))
114
+ ) {
115
+ return sealReport(saved);
116
+ }
117
+ const scope = Object.fromEntries(
118
+ [
119
+ 'roots',
120
+ 'scanners',
121
+ 'containers',
122
+ 'host',
123
+ 'docker',
124
+ 'dockerDepth',
125
+ 'maxDepth',
126
+ ].map((name) => [name, saved.options?.[name]])
127
+ );
93
128
  for (const [key, value] of Object.entries(input)) {
94
129
  if (RESCAN_OPTIONS.has(key) && value !== undefined && value !== null) {
95
130
  scope[key] = value;
@@ -172,6 +207,7 @@ export async function scanEnvironment(env, options, extra = {}) {
172
207
  options.scanBudgetMs
173
208
  );
174
209
  const found = new Map();
210
+ const completed = new Set();
175
211
  let scanner = 'context';
176
212
  const progress = () =>
177
213
  options.onScanProgress?.({
@@ -179,6 +215,11 @@ export async function scanEnvironment(env, options, extra = {}) {
179
215
  scanner,
180
216
  elapsedMs: Date.now() - started,
181
217
  items: found.size,
218
+ candidates: found.size,
219
+ filesSized: env.scanMetrics?.filesSized ?? 0,
220
+ directoriesSized: env.scanMetrics?.directoriesSized ?? 0,
221
+ indexHits: env.scanMetrics?.indexHits ?? 0,
222
+ sizeCacheHits: env.scanMetrics?.sizeCacheHits ?? 0,
182
223
  status: env.scanStatus,
183
224
  });
184
225
  env.scanStatus = 'scanning';
@@ -189,10 +230,17 @@ export async function scanEnvironment(env, options, extra = {}) {
189
230
  controller.signal,
190
231
  () => {
191
232
  progress();
192
- return scanEnvironmentScoped(env, options, extra, found, (name) => {
193
- scanner = name;
194
- progress();
195
- });
233
+ return scanEnvironmentScoped(
234
+ env,
235
+ options,
236
+ extra,
237
+ found,
238
+ completed,
239
+ (name) => {
240
+ scanner = name;
241
+ progress();
242
+ }
243
+ );
196
244
  },
197
245
  deadline
198
246
  );
@@ -205,7 +253,9 @@ export async function scanEnvironment(env, options, extra = {}) {
205
253
  (extra.errors ?? []).push({ env: env.id, scanner, message: error.message });
206
254
  const items = [...found.values()];
207
255
  for (const item of items) {
208
- block(item, `scan incomplete: ${error.message}`);
256
+ if (options.signal?.aborted || !completed.has(item.id)) {
257
+ block(item, `scan incomplete: ${error.message}`);
258
+ }
209
259
  }
210
260
  return items;
211
261
  } finally {
@@ -216,13 +266,54 @@ export async function scanEnvironment(env, options, extra = {}) {
216
266
  }
217
267
  }
218
268
 
219
- async function scanEnvironmentScoped(env, options, extra, found, stage) {
269
+ async function scanEnvironmentScoped(
270
+ env,
271
+ options,
272
+ extra,
273
+ found,
274
+ completed,
275
+ stage
276
+ ) {
220
277
  const errors = extra.errors ?? [];
221
278
  const context = await environmentContext(env, options, extra);
222
279
  context.emit = (item) => found.set(item.id, item);
280
+ let stopped = false;
281
+ const ready = async (items) => {
282
+ await attributeStorage(env, items);
283
+ for (const item of items) {
284
+ for (const target of item.paths) {
285
+ block(item, env.scanPolicy.removalReason(target));
286
+ }
287
+ completed.add(item.id);
288
+ }
289
+ if (
290
+ env.kind === 'host' &&
291
+ context.scanner === 'global' &&
292
+ options.onScanPhase
293
+ ) {
294
+ const result = await options.onScanPhase(
295
+ sealReport({
296
+ schema: REPORT_SCHEMA,
297
+ tool: 'disk-space-saviour',
298
+ createdAt: new Date(options.now()).toISOString(),
299
+ host: { env: env.id, label: env.label, platform: env.platform },
300
+ options: reportOptions(options),
301
+ environments: [envDescriptor(env, 0, [])],
302
+ items: filterItems(items, options, [], env.path),
303
+ totals: tierTotals(items),
304
+ errors: [],
305
+ docker: null,
306
+ })
307
+ );
308
+ stopped ||= result === false;
309
+ }
310
+ return !stopped;
311
+ };
312
+ context.ready = ready;
223
313
  let incomplete = false;
224
314
  for (const name of options.scanners) {
225
315
  checkSignal(env.signal);
316
+ context.scanner = name;
226
317
  stage(name);
227
318
  const scanner = SCANNERS[name];
228
319
  if (!scanner) {
@@ -237,21 +328,25 @@ async function scanEnvironmentScoped(env, options, extra, found, stage) {
237
328
  for (const item of entries) {
238
329
  context.emit(item);
239
330
  }
331
+ await ready(entries.filter((item) => !completed.has(item.id)));
240
332
  } catch (error) {
241
333
  checkSignal(env.signal);
242
334
  incomplete = true;
243
335
  for (const [id, item] of found) {
244
- if (!previous.has(id)) {
336
+ if (![previous, completed].some((set) => set.has(id))) {
245
337
  block(item, `scan incomplete: ${error.message}`);
246
338
  }
247
339
  }
248
340
  errors.push({ env: env.id, scanner: name, message: error.message });
249
341
  }
250
342
  trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
343
+ if (stopped) {
344
+ env.scanStatus = 'goal-reached';
345
+ return [...found.values()];
346
+ }
251
347
  }
252
348
  stage('storage');
253
349
  const items = [...found.values()];
254
- await attributeStorage(env, items);
255
350
  for (const item of items) {
256
351
  for (const target of item.paths) {
257
352
  block(item, env.scanPolicy.removalReason(target));
@@ -391,7 +486,11 @@ function envDescriptor(env, depth, chain) {
391
486
  async function withDisk(env, descriptor) {
392
487
  const target = env.platform === 'win32' ? env.currentHome : '/';
393
488
  const disk = await env.diskUsage(target).catch(() => null);
394
- return { ...descriptor, disk: disk ? { path: target, ...disk } : null };
489
+ return {
490
+ ...descriptor,
491
+ disk: disk ? { path: target, ...disk } : null,
492
+ swap: await readSwap(env),
493
+ };
395
494
  }
396
495
 
397
496
  async function dockerSection(env, options, errors) {
@@ -479,6 +578,15 @@ async function scanScoped(input) {
479
578
  const env = input.env ?? new LocalEnv();
480
579
  const startedAt = options.now();
481
580
  const errors = [];
581
+ env.scanMetrics = {
582
+ filesSized: 0,
583
+ directoriesSized: 0,
584
+ indexHits: 0,
585
+ sizeCacheHits: 0,
586
+ };
587
+ env.scanIndex = new ScanIndex(options.cacheDir);
588
+ env.platformIndex = options.platformIndex;
589
+ await env.scanIndex.load();
482
590
  resetWritableLayers(env.executor);
483
591
  env.writableLayer = null;
484
592
  env.scanPolicy = null;
@@ -492,7 +600,10 @@ async function scanScoped(input) {
492
600
  options.host === false
493
601
  ? []
494
602
  : await scanEnvironment(env, options, { roots: options.roots, errors });
495
- const docker = await dockerSection(env, options, errors);
603
+ const docker =
604
+ env.scanStatus === 'goal-reached'
605
+ ? null
606
+ : await dockerSection(env, options, errors);
496
607
  blockMountedHostItems(hostItems, docker?.bindMounts ?? [], env.path);
497
608
  const items = filterItems(
498
609
  [...hostItems, ...(docker?.items ?? [])],
@@ -515,6 +626,10 @@ async function scanScoped(input) {
515
626
  startedAt,
516
627
  });
517
628
  reportEnvironments.set(report, env);
629
+ await env.scanIndex.save();
630
+ await authenticateReport(report, options.cacheDir);
631
+ sealReport(report);
632
+ await saveScan(report, options.cacheDir);
518
633
  return report;
519
634
  }
520
635
 
@@ -524,6 +639,7 @@ function reportOptions(options) {
524
639
  delete serializable.env;
525
640
  delete serializable.signal;
526
641
  delete serializable.onScanProgress;
642
+ delete serializable.onScanPhase;
527
643
  return serializable;
528
644
  }
529
645
 
@@ -135,6 +135,10 @@ function ageFilter(context, rule) {
135
135
  return () => true;
136
136
  }
137
137
 
138
+ function requiresMtime(rule) {
139
+ return rule.mtime ?? (rule.scope === 'tmp' || Boolean(rule.minAge));
140
+ }
141
+
138
142
  /** Keep the latest installed revision of each browser and platform family. */
139
143
  function olderVersions(env, rule, paths, stats) {
140
144
  if (!rule.versionPattern) {
@@ -181,6 +185,7 @@ async function buildItem(context, rule, base, group) {
181
185
  path: paths[0],
182
186
  paths,
183
187
  bytes: group.reduce((sum, entry) => sum + entry.usage.bytes, 0),
188
+ sizeEstimated: group.some((entry) => entry.usage.estimated),
184
189
  newestMtimeMs: Math.max(...group.map((e) => e.usage.newestMtimeMs ?? 0)),
185
190
  tier: rule.tier ?? 'safe',
186
191
  reason: rule.minAge
@@ -269,13 +274,21 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
269
274
  found.push({ rule, base, paths });
270
275
  }
271
276
  }
272
- const usages = await env.usageMany(found.flatMap((entry) => entry.paths));
273
277
  await resolvePaths(
274
278
  context,
275
279
  found.flatMap((entry) => entry.paths)
276
280
  );
277
281
  const items = scanItems(context);
282
+ // Deliver cheap native measurements before rules needing recursive activity
283
+ // timestamps, so an emergency goal can avoid the expensive work entirely.
284
+ found.sort(
285
+ (a, b) => Number(requiresMtime(a.rule)) - Number(requiresMtime(b.rule))
286
+ );
278
287
  for (const { rule, base, paths } of found) {
288
+ const usages = await env.usageMany(paths, {
289
+ mtime: requiresMtime(rule),
290
+ cache: true,
291
+ });
279
292
  const accept = ageFilter(context, rule);
280
293
  const measured = paths
281
294
  .map((target) => ({ path: target, usage: usages.get(target) }))
@@ -284,7 +297,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
284
297
  ? [measured]
285
298
  : measured.map((entry) => [entry]);
286
299
  for (const group of groups.filter((g) => g.length > 0)) {
287
- items.push(await buildItem(context, rule, base, group));
300
+ const item = await buildItem(context, rule, base, group);
301
+ items.push(item);
302
+ if ((await context.ready?.([item])) === false) {
303
+ return items;
304
+ }
288
305
  }
289
306
  }
290
307
  return items;