disk-space-saviour 0.0.0-stage → 0.11.30

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,715 @@
1
+ /**
2
+ * Base names of a process's executable and `argv[0]`, without duplicates
3
+ * and without the ` (deleted)` suffix of replaced binaries.
4
+ * @param {string} exe target of `/proc/<pid>/exe`
5
+ * @param {string} argv0 first entry of `/proc/<pid>/cmdline`
6
+ * @returns {string[]}
7
+ */
8
+ export function processAliases(exe, argv0) {
9
+ const names = [exe.replace(/ \(deleted\)$/, ''), argv0.split(' ')[0]]
10
+ .filter(Boolean)
11
+ .map((value) => path.posix.basename(value));
12
+ return [...new Set(names)];
13
+ }
14
+
15
+ /**
16
+ * State letter and start time (clock ticks since boot) of a process from
17
+ * `/proc/<pid>/stat`. Fields are counted after the parenthesized name,
18
+ * which may hold spaces.
19
+ * @param {string} stat
20
+ * @returns {{state: string|null, startTime: string|null}}
21
+ */
22
+ export function parseProcStat(stat) {
23
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
24
+ return {
25
+ state: fields[0] || null,
26
+ startTime: /^\d+$/.test(fields[19] ?? '') ? fields[19] : null,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Working directories by pid from `lsof -F pn -d cwd` field output
32
+ * (`p<pid>` starts a process, `n<path>` names its file).
33
+ * @param {string} output
34
+ * @returns {Map<number, string>}
35
+ */
36
+ export function parseLsofCwds(output) {
37
+ const cwds = new Map();
38
+ let pid = null;
39
+ for (const line of output.split('\n')) {
40
+ if (line.startsWith('p')) {
41
+ pid = Number(line.slice(1));
42
+ } else if (line.startsWith('n/') && pid !== null) {
43
+ cwds.set(pid, line.slice(1));
44
+ }
45
+ }
46
+ return cwds;
47
+ }
48
+
49
+ /**
50
+ * Environment adapter for the machine running dss, backed by node:fs.
51
+ *
52
+ * Scanners only talk to environment adapters, so the same rules run on the
53
+ * host and inside containers (see shell.js).
54
+ */
55
+
56
+ import { promises as fsp } from 'node:fs';
57
+ import os from 'node:os';
58
+ import path from 'node:path';
59
+
60
+ import { hostExecutor } from '../exec.js';
61
+ import { matchesGlob } from '../paths.js';
62
+
63
+ // Directories read concurrently while measuring usage.
64
+ const WALK_BATCH = 64;
65
+
66
+ export const DEFAULT_SKIP_NAMES = [
67
+ '.git',
68
+ '.hg',
69
+ '.svn',
70
+ '.Trash',
71
+ '.cache',
72
+ '.config',
73
+ '.local',
74
+ '.npm',
75
+ '.bun',
76
+ '.yarn',
77
+ '.pnpm-store',
78
+ '.deno',
79
+ '.cargo',
80
+ '.rustup',
81
+ '.m2',
82
+ '.nvm',
83
+ '.pyenv',
84
+ '.rbenv',
85
+ '.sdkman',
86
+ '.elan',
87
+ '.ghcup',
88
+ '.opam',
89
+ '.julia',
90
+ '.gem',
91
+ '.nuget',
92
+ '.pub-cache',
93
+ '.stack',
94
+ '.cabal',
95
+ '.ivy2',
96
+ '.sbt',
97
+ '.hex',
98
+ '.mix',
99
+ '.cpanm',
100
+ '.luarocks',
101
+ '.nimble',
102
+ '.conda',
103
+ '.ccache',
104
+ '.vscode-server',
105
+ '.vscode',
106
+ '.cursor',
107
+ '.cursor-server',
108
+ '.windsurf',
109
+ '.codeium',
110
+ '.qoder',
111
+ '.kiro',
112
+ '.trae',
113
+ '.docker',
114
+ 'Library',
115
+ 'miniconda3',
116
+ 'anaconda3',
117
+ 'snap',
118
+ 'proc',
119
+ 'sys',
120
+ 'dev',
121
+ ];
122
+
123
+ function entryType(stats) {
124
+ if (stats.isSymbolicLink()) {
125
+ return 'symlink';
126
+ }
127
+ if (stats.isDirectory()) {
128
+ return 'dir';
129
+ }
130
+ if (stats.isFile()) {
131
+ return 'file';
132
+ }
133
+ return 'other';
134
+ }
135
+
136
+ function diskBytes(stats) {
137
+ return typeof stats.blocks === 'number' && stats.blocks >= 0
138
+ ? stats.blocks * 512
139
+ : stats.size;
140
+ }
141
+
142
+ async function safeLstat(target) {
143
+ try {
144
+ return await fsp.lstat(target);
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ async function makeWritable(target) {
151
+ const stats = await safeLstat(target);
152
+ if (!stats || stats.isSymbolicLink()) {
153
+ return;
154
+ }
155
+ try {
156
+ await fsp.chmod(target, stats.mode | 0o700);
157
+ } catch {
158
+ return;
159
+ }
160
+ if (stats.isDirectory()) {
161
+ let names = [];
162
+ try {
163
+ names = await fsp.readdir(target);
164
+ } catch {
165
+ return;
166
+ }
167
+ for (const name of names) {
168
+ await makeWritable(path.join(target, name));
169
+ }
170
+ }
171
+ }
172
+
173
+ export class LocalEnv {
174
+ /**
175
+ * @param {{homes?: string[], tmpDirs?: string[], executor?: object,
176
+ * platform?: string, vars?: object, skipNames?: string[], id?: string,
177
+ * label?: string}} [options]
178
+ */
179
+ constructor(options = {}) {
180
+ this.id = options.id ?? 'host';
181
+ this.kind = 'host';
182
+ this.label = options.label ?? `host (${os.hostname()})`;
183
+ this.path = path;
184
+ this.platform = options.platform ?? process.platform;
185
+ this.executor = options.executor ?? hostExecutor();
186
+ this.options = options;
187
+ this.currentHome = os.homedir();
188
+ this.skipNames = new Set(options.skipNames ?? DEFAULT_SKIP_NAMES);
189
+ this.vars = options.vars ?? {
190
+ LOCALAPPDATA: process.env.LOCALAPPDATA,
191
+ APPDATA: process.env.APPDATA,
192
+ TMPDIR: process.env.TMPDIR,
193
+ };
194
+ }
195
+
196
+ homeDirs() {
197
+ return Promise.resolve(this.options.homes ?? [os.homedir()]);
198
+ }
199
+
200
+ async tmpDirs() {
201
+ if (this.options.tmpDirs) {
202
+ return this.options.tmpDirs;
203
+ }
204
+ const dirs = new Set([os.tmpdir()]);
205
+ if (this.platform !== 'win32' && (await this.exists('/tmp'))) {
206
+ dirs.add('/tmp');
207
+ }
208
+ return [...dirs];
209
+ }
210
+
211
+ async exists(target) {
212
+ return (await safeLstat(target)) !== null;
213
+ }
214
+
215
+ async stat(target) {
216
+ const stats = await safeLstat(target);
217
+ if (!stats) {
218
+ return null;
219
+ }
220
+ return {
221
+ type: entryType(stats),
222
+ size: stats.size,
223
+ bytes: diskBytes(stats),
224
+ mtimeMs: stats.mtimeMs,
225
+ };
226
+ }
227
+
228
+ async readText(target, maxBytes = 1024 * 1024) {
229
+ try {
230
+ const handle = await fsp.open(target, 'r');
231
+ try {
232
+ const buffer = Buffer.alloc(maxBytes);
233
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
234
+ return buffer.subarray(0, bytesRead).toString('utf8');
235
+ } finally {
236
+ await handle.close();
237
+ }
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Reads the first bytes of many files in one batch (Rust `.d` files).
245
+ * @param {string[]} files
246
+ * @param {number} maxBytes
247
+ * @returns {Promise<Map<string,string>>}
248
+ */
249
+ async readHeads(files, maxBytes = 4096) {
250
+ const heads = new Map();
251
+ for (const file of files) {
252
+ const text = await this.readText(file, maxBytes);
253
+ if (text !== null) {
254
+ heads.set(file, text);
255
+ }
256
+ }
257
+ return heads;
258
+ }
259
+
260
+ async list(dir) {
261
+ let names;
262
+ try {
263
+ names = await fsp.readdir(dir);
264
+ } catch {
265
+ return [];
266
+ }
267
+ const entries = await Promise.all(
268
+ names.map(async (name) => {
269
+ const full = path.join(dir, name);
270
+ const stats = await safeLstat(full);
271
+ if (!stats) {
272
+ return null;
273
+ }
274
+ return {
275
+ name,
276
+ path: full,
277
+ type: entryType(stats),
278
+ size: stats.size,
279
+ bytes: diskBytes(stats),
280
+ mtimeMs: stats.mtimeMs,
281
+ };
282
+ })
283
+ );
284
+ return entries.filter(Boolean);
285
+ }
286
+
287
+ /**
288
+ * Disk usage like `du -s`: allocated blocks, each hard-linked inode
289
+ * counted a single time, symlinks not followed. Also reports the newest
290
+ * file mtime.
291
+ */
292
+ async usage(target) {
293
+ const root = await safeLstat(target);
294
+ if (!root) {
295
+ return null;
296
+ }
297
+ const seen = new Set();
298
+ let bytes = 0;
299
+ let apparentBytes = 0;
300
+ let files = 0;
301
+ let errors = 0;
302
+ let newestMtimeMs = 0;
303
+ const account = (stats) => {
304
+ if (stats.nlink > 1 && !stats.isDirectory()) {
305
+ const key = `${stats.dev}:${stats.ino}`;
306
+ if (seen.has(key)) {
307
+ return;
308
+ }
309
+ seen.add(key);
310
+ }
311
+ bytes += diskBytes(stats);
312
+ apparentBytes += stats.size;
313
+ if (!stats.isDirectory()) {
314
+ files++;
315
+ newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
316
+ }
317
+ };
318
+ account(root);
319
+ const visit = async (dir) => {
320
+ let names;
321
+ try {
322
+ names = await fsp.readdir(dir);
323
+ } catch {
324
+ errors++;
325
+ return [];
326
+ }
327
+ const children = await Promise.all(
328
+ names.map(async (name) => {
329
+ const full = path.join(dir, name);
330
+ return [full, await safeLstat(full)];
331
+ })
332
+ );
333
+ const subdirs = [];
334
+ for (const [full, stats] of children) {
335
+ if (!stats) {
336
+ errors++;
337
+ continue;
338
+ }
339
+ account(stats);
340
+ if (stats.isDirectory()) {
341
+ subdirs.push(full);
342
+ }
343
+ }
344
+ return subdirs;
345
+ };
346
+ let frontier = root.isDirectory() ? [target] : [];
347
+ while (frontier.length > 0) {
348
+ const next = [];
349
+ for (let index = 0; index < frontier.length; index += WALK_BATCH) {
350
+ const found = await Promise.all(
351
+ frontier.slice(index, index + WALK_BATCH).map(visit)
352
+ );
353
+ next.push(...found.flat());
354
+ }
355
+ frontier = next;
356
+ }
357
+ if (files === 0) {
358
+ newestMtimeMs = root.mtimeMs;
359
+ }
360
+ return { bytes, apparentBytes, files, errors, newestMtimeMs };
361
+ }
362
+
363
+ /**
364
+ * Finds directories whose basename matches one of `globs`, without
365
+ * descending into matches or into `skipNames`.
366
+ * @returns {Promise<Array<{path: string, name: string, parent: string,
367
+ * siblings: Array<{name: string, type: string, mtimeMs: number}>}>>}
368
+ */
369
+ async findDirs(roots, { globs, maxDepth = 6, skipNames } = {}) {
370
+ const results = [];
371
+ await this.#walk(roots, maxDepth, skipNames, (dir, entries) => {
372
+ const matched = new Set();
373
+ let siblings = null;
374
+ for (const entry of entries) {
375
+ if (
376
+ entry.type === 'dir' &&
377
+ globs.some((glob) => matchesGlob(entry.name, glob))
378
+ ) {
379
+ siblings ??= entries.map(({ name, type, mtimeMs }) => ({
380
+ name,
381
+ type,
382
+ mtimeMs,
383
+ }));
384
+ results.push({
385
+ path: entry.path,
386
+ name: entry.name,
387
+ parent: dir,
388
+ siblings,
389
+ });
390
+ matched.add(entry.name);
391
+ }
392
+ }
393
+ return matched;
394
+ });
395
+ return results;
396
+ }
397
+
398
+ /**
399
+ * Finds regular files whose basename matches one of `names`.
400
+ * @returns {Promise<string[]>}
401
+ */
402
+ async findFiles(roots, { names, maxDepth = 6, skipNames } = {}) {
403
+ const results = [];
404
+ await this.#walk(roots, maxDepth, skipNames, (_dir, entries) => {
405
+ for (const entry of entries) {
406
+ if (
407
+ entry.type === 'file' &&
408
+ names.some((glob) => matchesGlob(entry.name, glob))
409
+ ) {
410
+ results.push(entry.path);
411
+ }
412
+ }
413
+ return null;
414
+ });
415
+ return results;
416
+ }
417
+
418
+ /**
419
+ * Breadth-first directory walk. `visit(dir, entries)` may return a set of
420
+ * child names that must not be descended into.
421
+ */
422
+ async #walk(roots, maxDepth, skipNames, visit) {
423
+ const skip = skipNames ? new Set(skipNames) : this.skipNames;
424
+ const visited = new Set();
425
+ const queue = roots.map((root) => [root, 0]);
426
+ while (queue.length > 0) {
427
+ const [dir, depth] = queue.shift();
428
+ if (visited.has(dir)) {
429
+ continue;
430
+ }
431
+ visited.add(dir);
432
+ const entries = await this.list(dir);
433
+ const pruned = visit(dir, entries) ?? new Set();
434
+ if (depth + 1 >= maxDepth) {
435
+ continue;
436
+ }
437
+ for (const entry of entries) {
438
+ if (
439
+ entry.type === 'dir' &&
440
+ !pruned.has(entry.name) &&
441
+ !skip.has(entry.name)
442
+ ) {
443
+ queue.push([entry.path, depth + 1]);
444
+ }
445
+ }
446
+ }
447
+ }
448
+
449
+ async usageMany(targets) {
450
+ const usages = new Map();
451
+ for (const target of targets) {
452
+ usages.set(target, await this.usage(target));
453
+ }
454
+ return usages;
455
+ }
456
+
457
+ async readLink(target) {
458
+ try {
459
+ return await fsp.readlink(target);
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+
465
+ async realPath(target) {
466
+ try {
467
+ return await fsp.realpath(target);
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+
473
+ isRoot() {
474
+ return Promise.resolve(process.getuid?.() === 0);
475
+ }
476
+
477
+ async remove(targets) {
478
+ for (const target of targets) {
479
+ try {
480
+ await fsp.rm(target, { recursive: true, force: true });
481
+ } catch (error) {
482
+ if (error.code !== 'EACCES' && error.code !== 'EPERM') {
483
+ throw error;
484
+ }
485
+ // Read-only trees (Go module cache, some toolchains) need write
486
+ // permission on directories before their entries can be unlinked.
487
+ await makeWritable(target);
488
+ await fsp.rm(target, { recursive: true, force: true });
489
+ }
490
+ }
491
+ }
492
+
493
+ async processes() {
494
+ const selfPids = new Set([process.pid, process.ppid]);
495
+ if (this.platform === 'linux') {
496
+ return (await this.#linuxProcesses()).filter(
497
+ (proc) => !selfPids.has(proc.pid)
498
+ );
499
+ }
500
+ if (this.platform === 'win32') {
501
+ const result = await this.run(['tasklist', '/fo', 'csv', '/nh']);
502
+ return result.stdout
503
+ .split(/\r?\n/)
504
+ .map((line) => /^"([^"]+)","(\d+)"/.exec(line))
505
+ .filter(Boolean)
506
+ .map((m) => ({ pid: Number(m[2]), name: m[1].replace(/\.exe$/i, '') }))
507
+ .filter((proc) => !selfPids.has(proc.pid));
508
+ }
509
+ const result = await this.run(['ps', '-axo', 'pid=,comm=']);
510
+ const cwds =
511
+ this.platform === 'darwin'
512
+ ? parseLsofCwds(
513
+ (
514
+ await this.run(['lsof', '-a', '-d', 'cwd', '-n', '-F', 'pn'], {
515
+ timeoutMs: 30000,
516
+ })
517
+ ).stdout
518
+ )
519
+ : new Map();
520
+ return result.stdout
521
+ .split('\n')
522
+ .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
523
+ .filter(Boolean)
524
+ .map((m) => ({
525
+ pid: Number(m[1]),
526
+ name: path.basename(m[2].trim()),
527
+ cwd: cwds.get(Number(m[1])) ?? null,
528
+ }))
529
+ .filter((proc) => !selfPids.has(proc.pid));
530
+ }
531
+
532
+ async #linuxProcesses() {
533
+ let pids = [];
534
+ try {
535
+ pids = (await fsp.readdir('/proc')).filter((name) => /^\d+$/.test(name));
536
+ } catch {
537
+ return [];
538
+ }
539
+ const processes = [];
540
+ for (const pid of pids) {
541
+ const base = `/proc/${pid}`;
542
+ let name;
543
+ try {
544
+ name = (await fsp.readFile(`${base}/comm`, 'utf8')).trim();
545
+ } catch {
546
+ continue;
547
+ }
548
+ const cwd = await fsp.readlink(`${base}/cwd`).catch(() => null);
549
+ const exe = await fsp.readlink(`${base}/exe`).catch(() => '');
550
+ const cmdline = await fsp
551
+ .readFile(`${base}/cmdline`, 'utf8')
552
+ .catch(() => '');
553
+ const stat = parseProcStat(
554
+ await fsp.readFile(`${base}/stat`, 'utf8').catch(() => '')
555
+ );
556
+ if (stat.state === 'Z') {
557
+ continue;
558
+ }
559
+ processes.push({
560
+ pid: Number(pid),
561
+ name,
562
+ startTime: stat.startTime,
563
+ cwd,
564
+ aliases: processAliases(exe, cmdline.split('\0')[0]),
565
+ command: cmdline.replaceAll('\0', ' '),
566
+ });
567
+ }
568
+ return processes;
569
+ }
570
+
571
+ /**
572
+ * Paths currently held open (fds, cwds, executables) by any process.
573
+ * Returns null when the platform gives no reliable answer.
574
+ * @returns {Promise<Set<string>|null>}
575
+ */
576
+ async openPaths({ strict = true } = {}) {
577
+ if (this.platform === 'linux') {
578
+ const paths = new Set();
579
+ let pids = [];
580
+ try {
581
+ pids = (await fsp.readdir('/proc')).filter((n) => /^\d+$/.test(n));
582
+ } catch {
583
+ return null;
584
+ }
585
+ for (const pid of pids) {
586
+ const base = `/proc/${pid}`;
587
+ for (const link of ['cwd', 'exe']) {
588
+ const target = await fsp
589
+ .readlink(`${base}/${link}`)
590
+ .catch((error) => {
591
+ if (
592
+ strict &&
593
+ (error.code === 'EACCES' || error.code === 'EPERM')
594
+ ) {
595
+ throw error;
596
+ }
597
+ return '';
598
+ });
599
+ if (target.startsWith('/')) {
600
+ paths.add(target.replace(/ \(deleted\)$/, ''));
601
+ }
602
+ }
603
+ const fds = await fsp.readdir(`${base}/fd`).catch((error) => {
604
+ if (strict && (error.code === 'EACCES' || error.code === 'EPERM')) {
605
+ throw error;
606
+ }
607
+ return [];
608
+ });
609
+ for (const fd of fds) {
610
+ const target = await fsp
611
+ .readlink(`${base}/fd/${fd}`)
612
+ .catch((error) => {
613
+ if (
614
+ strict &&
615
+ (error.code === 'EACCES' || error.code === 'EPERM')
616
+ ) {
617
+ throw error;
618
+ }
619
+ return '';
620
+ });
621
+ if (target.startsWith('/')) {
622
+ paths.add(target.replace(/ \(deleted\)$/, ''));
623
+ }
624
+ }
625
+ }
626
+ return paths;
627
+ }
628
+ if (this.platform === 'darwin') {
629
+ const result = await this.run(['lsof', '-n', '-F', 'n'], {
630
+ timeoutMs: 30000,
631
+ });
632
+ if (result.code !== 0 && !result.stdout) {
633
+ return null;
634
+ }
635
+ return new Set(
636
+ result.stdout
637
+ .split('\n')
638
+ .filter((line) => line.startsWith('n/'))
639
+ .map((line) => line.slice(1))
640
+ );
641
+ }
642
+ return null;
643
+ }
644
+
645
+ run(argv, options) {
646
+ return this.executor.run(argv, options);
647
+ }
648
+
649
+ async which(command) {
650
+ const dirs = (process.env.PATH ?? '').split(path.delimiter);
651
+ const extensions =
652
+ this.platform === 'win32'
653
+ ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';')
654
+ : [''];
655
+ for (const dir of dirs.filter(Boolean)) {
656
+ for (const extension of extensions) {
657
+ const stats = await safeLstat(path.join(dir, command + extension));
658
+ if (stats && !stats.isDirectory()) {
659
+ return true;
660
+ }
661
+ }
662
+ }
663
+ return false;
664
+ }
665
+
666
+ /**
667
+ * Filesystem capacity of the volume holding `target`.
668
+ * @returns {Promise<{total: number, free: number, used: number}|null>}
669
+ */
670
+ async diskUsage(target) {
671
+ if (typeof fsp.statfs === 'function') {
672
+ try {
673
+ const stats = await fsp.statfs(target);
674
+ const total = stats.blocks * stats.bsize;
675
+ const free = stats.bavail * stats.bsize;
676
+ const used = total - stats.bfree * stats.bsize;
677
+ return { total, free, used };
678
+ } catch {
679
+ // Fall through to df.
680
+ }
681
+ }
682
+ if (this.platform === 'win32') {
683
+ return null;
684
+ }
685
+ const result = await this.run(['df', '-Pk', target]);
686
+ return parseDf(result.stdout);
687
+ }
688
+
689
+ async deviceId(target) {
690
+ try {
691
+ return (await fsp.stat(target)).dev;
692
+ } catch {
693
+ return null;
694
+ }
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Parses POSIX `df -Pk` output.
700
+ * @param {string} output
701
+ */
702
+ export function parseDf(output) {
703
+ const line = output.trim().split('\n').slice(1).join(' ');
704
+ const fields = line.trim().split(/\s+/);
705
+ if (fields.length < 4) {
706
+ return null;
707
+ }
708
+ const total = Number(fields[1]) * 1024;
709
+ const used = Number(fields[2]) * 1024;
710
+ const free = Number(fields[3]) * 1024;
711
+ if (![total, used, free].every(Number.isFinite)) {
712
+ return null;
713
+ }
714
+ return { total, free, used };
715
+ }