disk-space-saviour 0.0.0-stage → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Superseded Rust build artifacts inside a cargo `target` directory.
3
+ *
4
+ * Every compilation unit leaves entries named `<crate>-<16 hex hash>` in
5
+ * `<profile>/deps`, `<profile>/build` and `<profile>/.fingerprint`, and
6
+ * incremental state in `<profile>/incremental/<crate>-<hash>`. Changing
7
+ * features, flags, the toolchain or dependency versions creates a new hash
8
+ * next to the old one, so old hashes pile up.
9
+ *
10
+ * The result has two parts:
11
+ *
12
+ * - **library**: `.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`, `.d`, `build/*`
13
+ * and `.fingerprint` entries. A unit's identity is read from its
14
+ * fingerprint (`<kind>-<target>.json` with the `target` and `profile`
15
+ * hashes, which separate lib/bin/test and check/build units). Units
16
+ * without a fingerprint fall back to crate name, artifact kind and crate
17
+ * root source (first entry of the `.d` file). For each identity the
18
+ * newest hash is kept, together with every hash built within the same
19
+ * generation window (one build); older hashes are reported only when all
20
+ * of their files are outside the activity window. A running cargo may
21
+ * already have judged these fresh, so callers must not touch this part
22
+ * while cargo or rustc runs in the project.
23
+ * - **leaf**: nothing links against these, so they stay removable while
24
+ * cargo runs (callers still skip files a process has open):
25
+ * - incremental session directories whose newest file is older than
26
+ * `leafIdleMs` and that hold no `-working` session. Each directory is
27
+ * judged by its own age: lib, test and bin sessions of one crate live
28
+ * side by side and never supersede each other;
29
+ * - extension-less (or `.exe`) executables `<name>-<hash>` in `deps` and
30
+ * `examples` (test and example binaries) older than `leafIdleMs` when a
31
+ * newer build of the same unit exists.
32
+ */
33
+
34
+ import { listMany } from './common.js';
35
+
36
+ const HASHED = /^(.+)-([0-9a-f]{16})(\..*)?$/;
37
+ const INCREMENTAL = /^(.+)-([0-9a-z]{8,20})$/;
38
+ const LIB_PREFIXED = new Set(['.rlib', '.rmeta', '.so', '.dylib', '.a']);
39
+ const KIND_ORDER = ['lib', 'dylib', 'staticlib', 'bin', 'rmeta', 'other'];
40
+ const NON_PROFILES = new Set(['doc', 'package', 'tmp', 'flycheck0']);
41
+ const EXECUTABLE = new Set(['', '.exe']);
42
+ export const DEFAULT_GENERATION_MS = 10 * 60 * 1000;
43
+ export const DEFAULT_LEAF_IDLE_MS = 3 * 60 * 60 * 1000;
44
+
45
+ function fileKind(extension) {
46
+ const kinds = {
47
+ '.rlib': 'lib',
48
+ '.rmeta': 'rmeta',
49
+ '.so': 'dylib',
50
+ '.dylib': 'dylib',
51
+ '.dll': 'dylib',
52
+ '.a': 'staticlib',
53
+ '.lib': 'staticlib',
54
+ '': 'bin',
55
+ '.exe': 'bin',
56
+ };
57
+ return kinds[extension] ?? 'other';
58
+ }
59
+
60
+ /**
61
+ * Splits `libfoo_bar-0123456789abcdef.rlib` into crate, hash, extension.
62
+ * @returns {{crate: string, hash: string, extension: string}|null}
63
+ */
64
+ export function parseArtifactName(name) {
65
+ const match = HASHED.exec(name);
66
+ if (!match) {
67
+ return null;
68
+ }
69
+ const extension = match[3] ?? '';
70
+ let crate = match[1];
71
+ if (LIB_PREFIXED.has(extension) && crate.startsWith('lib')) {
72
+ crate = crate.slice(3);
73
+ }
74
+ return { crate, hash: match[2], extension };
75
+ }
76
+
77
+ /**
78
+ * First dependency of a Makefile-style `.d` file: the crate root source.
79
+ */
80
+ export function rootSourceOf(depInfo) {
81
+ const line = (depInfo ?? '').split('\n')[0];
82
+ const separator = line.search(/:\s/);
83
+ if (separator === -1) {
84
+ return '';
85
+ }
86
+ const deps = line
87
+ .slice(separator + 1)
88
+ .trim()
89
+ .split(/(?<!\\)\s+/);
90
+ return deps[0] ?? '';
91
+ }
92
+
93
+ /**
94
+ * Unit identity from a fingerprint JSON file name and its beginning.
95
+ */
96
+ export function fingerprintIdentity(jsonName, head) {
97
+ const target = /"target":(\d+)/.exec(head ?? '');
98
+ const profile = /"profile":(\d+)/.exec(head ?? '');
99
+ return `${jsonName}:${target?.[1] ?? '?'}:${profile?.[1] ?? '?'}`;
100
+ }
101
+
102
+ /**
103
+ * Profile directories (`target/debug`, `target/<triple>/release`, ...)
104
+ * identified by their `deps` subdirectory.
105
+ * @returns {Promise<string[]>}
106
+ */
107
+ export async function findRustProfiles(env, targetDir) {
108
+ const top = (await env.list(targetDir)).filter(
109
+ (entry) => entry.type === 'dir' && !NON_PROFILES.has(entry.name)
110
+ );
111
+ const hasDeps = (entries) => entries.some((entry) => entry.name === 'deps');
112
+ const listings = await listMany(
113
+ env,
114
+ top.map((entry) => entry.path)
115
+ );
116
+ const profiles = [];
117
+ const nested = [];
118
+ for (const entry of top) {
119
+ const children = listings.get(entry.path) ?? [];
120
+ if (hasDeps(children)) {
121
+ profiles.push(entry.path);
122
+ continue;
123
+ }
124
+ for (const child of children) {
125
+ if (child.type === 'dir' && !NON_PROFILES.has(child.name)) {
126
+ nested.push(env.path.join(entry.path, child.name));
127
+ }
128
+ }
129
+ }
130
+ const nestedListings = await listMany(env, nested);
131
+ profiles.push(...nested.filter((dir) => hasDeps(nestedListings.get(dir))));
132
+ return profiles.sort();
133
+ }
134
+
135
+ async function withDirUsage(env, entries) {
136
+ const dirs = entries.filter((entry) => entry.type === 'dir');
137
+ if (dirs.length === 0) {
138
+ return entries;
139
+ }
140
+ const usages = await env.usageMany(dirs.map((entry) => entry.path));
141
+ return entries.map((entry) => {
142
+ const usage = entry.type === 'dir' ? usages.get(entry.path) : null;
143
+ return usage
144
+ ? {
145
+ ...entry,
146
+ bytes: usage.bytes,
147
+ mtimeMs: Math.max(entry.mtimeMs, usage.newestMtimeMs ?? 0),
148
+ }
149
+ : entry;
150
+ });
151
+ }
152
+
153
+ function hashedEntries(entries) {
154
+ return (entries ?? []).filter((entry) => parseArtifactName(entry.name));
155
+ }
156
+
157
+ function isExecutable(entry) {
158
+ return (
159
+ entry.type !== 'dir' &&
160
+ EXECUTABLE.has(parseArtifactName(entry.name).extension)
161
+ );
162
+ }
163
+
164
+ function summarize(stale, kept) {
165
+ const entries = stale
166
+ .flatMap((unit) => unit.entries)
167
+ .map(({ path, bytes, mtimeMs }) => ({ path, bytes, mtimeMs }));
168
+ return {
169
+ entries,
170
+ bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0),
171
+ kept,
172
+ removed: stale.length,
173
+ };
174
+ }
175
+
176
+ class UnitSet {
177
+ constructor() {
178
+ this.units = new Map();
179
+ }
180
+
181
+ add(key, hash, entry) {
182
+ if (!this.units.has(key)) {
183
+ this.units.set(key, new Map());
184
+ }
185
+ const hashes = this.units.get(key);
186
+ if (!hashes.has(hash)) {
187
+ hashes.set(hash, { hash, entries: [], mtimeMs: 0 });
188
+ }
189
+ const unit = hashes.get(hash);
190
+ unit.entries.push(entry);
191
+ unit.mtimeMs = Math.max(unit.mtimeMs, entry.mtimeMs);
192
+ }
193
+
194
+ superseded({ staleAgeMs, now, generationMs }) {
195
+ const stale = [];
196
+ let kept = 0;
197
+ for (const hashes of this.units.values()) {
198
+ const ordered = [...hashes.values()].sort(
199
+ (a, b) => b.mtimeMs - a.mtimeMs
200
+ );
201
+ const newest = ordered[0].mtimeMs;
202
+ for (const unit of ordered) {
203
+ const sameGeneration = newest - unit.mtimeMs <= generationMs;
204
+ const idle = now - unit.mtimeMs >= staleAgeMs;
205
+ if (sameGeneration || !idle) {
206
+ kept++;
207
+ } else {
208
+ stale.push(unit);
209
+ }
210
+ }
211
+ }
212
+ return { stale, kept };
213
+ }
214
+ }
215
+
216
+ async function fingerprintKeys(env, fingerprints) {
217
+ const listings = await listMany(
218
+ env,
219
+ fingerprints.map((entry) => entry.path)
220
+ );
221
+ const jsonFiles = new Map();
222
+ for (const entry of fingerprints) {
223
+ const json = (listings.get(entry.path) ?? []).find(
224
+ (child) => child.name.endsWith('.json') && !child.name.startsWith('dep-')
225
+ );
226
+ if (json) {
227
+ jsonFiles.set(entry, json);
228
+ }
229
+ }
230
+ const heads = await env.readHeads(
231
+ [...jsonFiles.values()].map((json) => json.path),
232
+ 8192
233
+ );
234
+ const keys = new Map();
235
+ for (const [entry, json] of jsonFiles) {
236
+ const { crate, hash } = parseArtifactName(entry.name);
237
+ keys.set(
238
+ hash,
239
+ `fp:${crate}:${fingerprintIdentity(json.name, heads.get(json.path))}`
240
+ );
241
+ }
242
+ return keys;
243
+ }
244
+
245
+ async function fallbackDepsKeys(env, depsDir, entries, known) {
246
+ const groups = new Map();
247
+ for (const entry of entries) {
248
+ const parsed = parseArtifactName(entry.name);
249
+ if (known.has(parsed.hash)) {
250
+ continue;
251
+ }
252
+ if (!groups.has(parsed.hash)) {
253
+ groups.set(parsed.hash, { crate: parsed.crate, kinds: new Set() });
254
+ }
255
+ groups.get(parsed.hash).kinds.add(fileKind(parsed.extension));
256
+ }
257
+ const depFile = (hash, crate) => env.path.join(depsDir, `${crate}-${hash}.d`);
258
+ const heads = await env.readHeads(
259
+ [...groups].map(([hash, group]) => depFile(hash, group.crate)),
260
+ 4096
261
+ );
262
+ const keys = new Map();
263
+ for (const [hash, group] of groups) {
264
+ const kind = KIND_ORDER.find((k) => group.kinds.has(k)) ?? 'other';
265
+ const root = rootSourceOf(heads.get(depFile(hash, group.crate)));
266
+ keys.set(hash, `deps:${group.crate}:${kind}:${root}`);
267
+ }
268
+ return keys;
269
+ }
270
+
271
+ function buildRole(listing) {
272
+ return (listing ?? []).some((c) => c.name.startsWith('build-script-'))
273
+ ? 'script'
274
+ : 'run';
275
+ }
276
+
277
+ function libraryPart(settings, keys, fallback, buildListings, entries) {
278
+ const units = new UnitSet();
279
+ for (const entry of entries) {
280
+ const { crate, hash } = parseArtifactName(entry.name);
281
+ const key =
282
+ keys.get(hash) ??
283
+ fallback.get(hash) ??
284
+ `build:${crate}:${buildRole(buildListings.get(entry.path))}`;
285
+ units.add(key, hash, entry);
286
+ }
287
+ const { stale, kept } = units.superseded(settings);
288
+ return summarize(stale, kept);
289
+ }
290
+
291
+ function executablePart(settings, keys, executables) {
292
+ const units = new UnitSet();
293
+ for (const { dir, entry } of executables) {
294
+ const { crate, hash } = parseArtifactName(entry.name);
295
+ units.add(`${dir}:${keys.get(hash) ?? crate}`, hash, entry);
296
+ }
297
+ return units.superseded({ ...settings, staleAgeMs: settings.leafIdleMs });
298
+ }
299
+
300
+ async function incrementalPart(env, settings, listing) {
301
+ const dirs = await withDirUsage(
302
+ env,
303
+ (listing ?? []).filter(
304
+ (entry) => entry.type === 'dir' && INCREMENTAL.test(entry.name)
305
+ )
306
+ );
307
+ const sessions = await listMany(
308
+ env,
309
+ dirs.map((entry) => entry.path)
310
+ );
311
+ const stale = [];
312
+ let kept = 0;
313
+ for (const entry of dirs) {
314
+ const working = (sessions.get(entry.path) ?? []).some((child) =>
315
+ child.name.endsWith('-working')
316
+ );
317
+ if (working || settings.now - entry.mtimeMs < settings.leafIdleMs) {
318
+ kept++;
319
+ } else {
320
+ stale.push({ entries: [entry] });
321
+ }
322
+ }
323
+ return { stale, kept };
324
+ }
325
+
326
+ /**
327
+ * Superseded artifacts of one profile directory.
328
+ * @param {object} env
329
+ * @param {string} profileDir
330
+ * @param {{staleAgeMs: number, now: number, generationMs?: number,
331
+ * leafIdleMs?: number}} options `leafIdleMs` defaults to the larger of
332
+ * 3 hours and `staleAgeMs`
333
+ * @returns {Promise<{entries: Array<{path: string, bytes: number,
334
+ * mtimeMs: number}>, bytes: number, kept: number, removed: number,
335
+ * library: object, leaf: object}>} both parts together plus `library`
336
+ * and `leaf` with the same shape
337
+ */
338
+ export async function analyzeRustProfile(env, profileDir, options) {
339
+ const settings = {
340
+ generationMs: DEFAULT_GENERATION_MS,
341
+ ...options,
342
+ leafIdleMs:
343
+ options.leafIdleMs ??
344
+ Math.max(DEFAULT_LEAF_IDLE_MS, options.staleAgeMs ?? 0),
345
+ };
346
+ const sub = (name) => env.path.join(profileDir, name);
347
+ const listings = await listMany(env, [
348
+ sub('deps'),
349
+ sub('build'),
350
+ sub('incremental'),
351
+ sub('.fingerprint'),
352
+ sub('examples'),
353
+ ]);
354
+ const fingerprints = await withDirUsage(
355
+ env,
356
+ hashedEntries(listings.get(sub('.fingerprint')))
357
+ );
358
+ const deps = await withDirUsage(
359
+ env,
360
+ hashedEntries(listings.get(sub('deps')))
361
+ );
362
+ const builds = await withDirUsage(
363
+ env,
364
+ hashedEntries(listings.get(sub('build')))
365
+ );
366
+ const keys = await fingerprintKeys(env, fingerprints);
367
+ const fallback = await fallbackDepsKeys(env, sub('deps'), deps, keys);
368
+ const buildListings = await listMany(
369
+ env,
370
+ builds
371
+ .filter((entry) => !keys.has(parseArtifactName(entry.name).hash))
372
+ .map((entry) => entry.path)
373
+ );
374
+
375
+ const library = libraryPart(settings, keys, fallback, buildListings, [
376
+ ...deps.filter((entry) => !isExecutable(entry)),
377
+ ...builds,
378
+ ...fingerprints,
379
+ ]);
380
+ const executables = executablePart(settings, keys, [
381
+ ...deps.filter(isExecutable).map((entry) => ({ dir: 'deps', entry })),
382
+ ...hashedEntries(listings.get(sub('examples')))
383
+ .filter(isExecutable)
384
+ .map((entry) => ({ dir: 'examples', entry })),
385
+ ]);
386
+ const incremental = await incrementalPart(
387
+ env,
388
+ settings,
389
+ listings.get(sub('incremental'))
390
+ );
391
+ const leaf = summarize(
392
+ [...incremental.stale, ...executables.stale],
393
+ incremental.kept + executables.kept
394
+ );
395
+ const entries = [...library.entries, ...leaf.entries];
396
+ return {
397
+ entries,
398
+ bytes: library.bytes + leaf.bytes,
399
+ kept: library.kept + leaf.kept,
400
+ removed: library.removed + leaf.removed,
401
+ library,
402
+ leaf,
403
+ };
404
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * System reclaimers that only the owning tool can clean correctly:
3
+ * journald archives (`journalctl --vacuum-size`), disabled snap revisions
4
+ * (`snap remove --revision`) and Homebrew's old versions and downloads
5
+ * (`brew cleanup`). Plain cache directories live in ../rules/other.js.
6
+ */
7
+
8
+ import { block, makeItem } from '../items.js';
9
+ import { parseSize } from '../units.js';
10
+ import { scanTimeBusy } from './common.js';
11
+
12
+ export const DEFAULT_JOURNAL_KEEP = 512 * 1024 ** 2;
13
+
14
+ /**
15
+ * Bytes reported by `journalctl --disk-usage`.
16
+ * @returns {number|null}
17
+ */
18
+ export function parseJournalUsage(output) {
19
+ const match = /take up ([\d.]+\s*[A-Za-z]*) in the file system/.exec(
20
+ output ?? ''
21
+ );
22
+ return match ? parseSize(match[1]) : null;
23
+ }
24
+
25
+ /**
26
+ * Disabled revisions from `snap list --all`.
27
+ * @returns {Array<{name: string, revision: string}>}
28
+ */
29
+ export function parseDisabledSnaps(output) {
30
+ const lines = (output ?? '').split('\n').filter(Boolean);
31
+ const header = lines.shift() ?? '';
32
+ if (!/^Name\s+Version\s+Rev\b/.test(header)) {
33
+ return [];
34
+ }
35
+ return lines
36
+ .map((line) => line.trim().split(/\s+/))
37
+ .filter((cols) => cols.length >= 6 && /\bdisabled\b/.test(cols.at(-1)))
38
+ .map((cols) => ({ name: cols[0], revision: cols[2] }));
39
+ }
40
+
41
+ /**
42
+ * Size freed according to `brew cleanup --dry-run`.
43
+ * @returns {number}
44
+ */
45
+ export function parseBrewCleanup(output) {
46
+ const total = /would free approximately ([\d.]+\s*[A-Za-z]+)/.exec(
47
+ output ?? ''
48
+ );
49
+ if (total) {
50
+ return parseSize(total[1]);
51
+ }
52
+ return [...(output ?? '').matchAll(/,\s*([\d.]+\s*[KMGT]?B)\)$/gm)].reduce(
53
+ (sum, match) => sum + parseSize(match[1]),
54
+ 0
55
+ );
56
+ }
57
+
58
+ function withBusy(context, item) {
59
+ const busy = scanTimeBusy(context, item);
60
+ if (busy) {
61
+ block(item, `busy: ${busy}`);
62
+ }
63
+ return item;
64
+ }
65
+
66
+ async function journalItems(context, isRoot) {
67
+ const { env, options } = context;
68
+ if (!(await env.which('journalctl'))) {
69
+ return [];
70
+ }
71
+ const result = await env.run(['journalctl', '--disk-usage']);
72
+ const total = parseJournalUsage(`${result.stdout}\n${result.stderr}`);
73
+ const keep = options.journalKeepBytes ?? DEFAULT_JOURNAL_KEEP;
74
+ if (total === null || total <= keep) {
75
+ return [];
76
+ }
77
+ const item = makeItem(env, {
78
+ rule: 'journald-archives',
79
+ kind: 'logs',
80
+ ecosystem: 'system',
81
+ description: 'systemd journal archives above the kept size',
82
+ path: '/var/log/journal',
83
+ bytes: total - keep,
84
+ tier: 'moderate',
85
+ reason: `journals take ${total} bytes, vacuuming keeps the newest ${keep}`,
86
+ action: {
87
+ type: 'command',
88
+ argv: ['journalctl', `--vacuum-size=${keep}`],
89
+ measure: ['/var/log/journal', '/run/log/journal'],
90
+ },
91
+ checks: { busy: [], cwd: null, mtime: false },
92
+ });
93
+ if (!isRoot) {
94
+ block(item, 'system location, run as root to clean it');
95
+ }
96
+ return [item];
97
+ }
98
+
99
+ async function snapItems(context, isRoot) {
100
+ const { env } = context;
101
+ if (!(await env.which('snap'))) {
102
+ return [];
103
+ }
104
+ const result = await env.run(['snap', 'list', '--all']);
105
+ const items = [];
106
+ for (const { name, revision } of parseDisabledSnaps(result.stdout)) {
107
+ const file = `/var/lib/snapd/snaps/${name}_${revision}.snap`;
108
+ const stat = await env.stat(file);
109
+ const item = makeItem(env, {
110
+ rule: 'snap-disabled-revision',
111
+ kind: 'package',
112
+ ecosystem: 'system',
113
+ description: `disabled snap revision ${name} ${revision}`,
114
+ path: file,
115
+ bytes: stat?.bytes ?? 0,
116
+ newestMtimeMs: stat?.mtimeMs ?? 0,
117
+ tier: 'safe',
118
+ reason: 'superseded revision kept only for rollback',
119
+ action: {
120
+ type: 'command',
121
+ argv: ['snap', 'remove', name, `--revision=${revision}`],
122
+ measure: [file],
123
+ },
124
+ checks: { busy: ['snap', 'snapd'], cwd: null, mtime: false },
125
+ });
126
+ if (!isRoot) {
127
+ block(item, 'system location, run as root to clean it');
128
+ }
129
+ items.push(withBusy(context, item));
130
+ }
131
+ return items;
132
+ }
133
+
134
+ async function brewItems(context, isRoot) {
135
+ const { env } = context;
136
+ if (isRoot || !(await env.which('brew'))) {
137
+ return [];
138
+ }
139
+ const result = await env.run(['brew', 'cleanup', '--dry-run']);
140
+ const bytes = result.code === 0 ? parseBrewCleanup(result.stdout) : 0;
141
+ if (bytes === 0) {
142
+ return [];
143
+ }
144
+ const prefix = (await env.run(['brew', '--prefix'])).stdout.trim();
145
+ const item = makeItem(env, {
146
+ rule: 'brew-cleanup',
147
+ kind: 'package',
148
+ ecosystem: 'system',
149
+ description: 'Homebrew outdated versions and stale downloads',
150
+ path: prefix || 'brew',
151
+ bytes,
152
+ tier: 'moderate',
153
+ reason: 'Homebrew may remove old kegs and unneeded dependencies',
154
+ action: { type: 'command', argv: ['brew', 'cleanup'], measure: [] },
155
+ checks: { busy: ['brew'], cwd: null, mtime: false },
156
+ });
157
+ return [withBusy(context, item)];
158
+ }
159
+
160
+ /**
161
+ * Scans journald, snap and Homebrew.
162
+ * @returns {Promise<object[]>} report items
163
+ */
164
+ export async function scanSystem(context) {
165
+ const isRoot = await context.env.isRoot();
166
+ return [
167
+ ...(await journalItems(context, isRoot)),
168
+ ...(await snapItems(context, isRoot)),
169
+ ...(await brewItems(context, isRoot)),
170
+ ];
171
+ }