disk-space-saviour 0.13.1 → 0.15.2

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/local.js CHANGED
@@ -56,6 +56,8 @@ export function parseLsofCwds(output) {
56
56
  import { promises as fsp } from 'node:fs';
57
57
  import os from 'node:os';
58
58
  import path from 'node:path';
59
+ import { URL, fileURLToPath } from 'node:url';
60
+ import { procIdentity, procOpenPaths } from './proc.js';
59
61
 
60
62
  import { hostExecutor } from '../exec.js';
61
63
  import { matchesGlob } from '../paths.js';
@@ -184,6 +186,7 @@ export class LocalEnv {
184
186
  this.platform = options.platform ?? process.platform;
185
187
  this.executor = options.executor ?? hostExecutor();
186
188
  this.options = options;
189
+ this.protectedPaths = [fileURLToPath(new URL('../../', import.meta.url))];
187
190
  this.currentHome = os.homedir();
188
191
  this.skipNames = new Set(options.skipNames ?? DEFAULT_SKIP_NAMES);
189
192
  this.vars = options.vars ?? {
@@ -193,6 +196,30 @@ export class LocalEnv {
193
196
  };
194
197
  }
195
198
 
199
+ recordAccessError(target, error) {
200
+ if (!['EACCES', 'EPERM'].includes(error.code)) {
201
+ return;
202
+ }
203
+ this.accessErrors ??= new Map();
204
+ const advice =
205
+ this.platform === 'darwin' && /Safari|WebKit/.test(target)
206
+ ? '; grant Full Disk Access to the terminal running dss'
207
+ : '';
208
+ this.accessErrors.set(
209
+ target,
210
+ `cannot inspect ${target}: ${error.code}${advice}`
211
+ );
212
+ }
213
+
214
+ async #lstat(target) {
215
+ try {
216
+ return await (this.options.fileFs ?? fsp).lstat(target);
217
+ } catch (error) {
218
+ this.recordAccessError(target, error);
219
+ return null;
220
+ }
221
+ }
222
+
196
223
  homeDirs() {
197
224
  return Promise.resolve(this.options.homes ?? [os.homedir()]);
198
225
  }
@@ -209,11 +236,11 @@ export class LocalEnv {
209
236
  }
210
237
 
211
238
  async exists(target) {
212
- return (await safeLstat(target)) !== null;
239
+ return (await this.#lstat(target)) !== null;
213
240
  }
214
241
 
215
242
  async stat(target) {
216
- const stats = await safeLstat(target);
243
+ const stats = await this.#lstat(target);
217
244
  if (!stats) {
218
245
  return null;
219
246
  }
@@ -222,6 +249,9 @@ export class LocalEnv {
222
249
  size: stats.size,
223
250
  bytes: diskBytes(stats),
224
251
  mtimeMs: stats.mtimeMs,
252
+ inode: `${stats.dev}:${stats.ino}`,
253
+ uid: stats.uid,
254
+ device: stats.dev,
225
255
  };
226
256
  }
227
257
 
@@ -260,14 +290,15 @@ export class LocalEnv {
260
290
  async list(dir) {
261
291
  let names;
262
292
  try {
263
- names = await fsp.readdir(dir);
264
- } catch {
293
+ names = await (this.options.fileFs ?? fsp).readdir(dir);
294
+ } catch (error) {
295
+ this.recordAccessError(dir, error);
265
296
  return [];
266
297
  }
267
298
  const entries = await Promise.all(
268
299
  names.map(async (name) => {
269
300
  const full = path.join(dir, name);
270
- const stats = await safeLstat(full);
301
+ const stats = await this.#lstat(full);
271
302
  if (!stats) {
272
303
  return null;
273
304
  }
@@ -290,7 +321,18 @@ export class LocalEnv {
290
321
  * file mtime.
291
322
  */
292
323
  async usage(target) {
293
- const root = await safeLstat(target);
324
+ return (await this.usageMany([target])).get(target) ?? null;
325
+ }
326
+
327
+ async statMany(targets) {
328
+ const entries = await Promise.all(
329
+ targets.map(async (target) => [target, await this.stat(target)])
330
+ );
331
+ return new Map(entries.filter(([, stat]) => stat));
332
+ }
333
+
334
+ async rawUsage(target) {
335
+ const root = await this.#lstat(target);
294
336
  if (!root) {
295
337
  return null;
296
338
  }
@@ -319,15 +361,16 @@ export class LocalEnv {
319
361
  const visit = async (dir) => {
320
362
  let names;
321
363
  try {
322
- names = await fsp.readdir(dir);
323
- } catch {
364
+ names = await (this.options.fileFs ?? fsp).readdir(dir);
365
+ } catch (error) {
366
+ this.recordAccessError(dir, error);
324
367
  errors++;
325
368
  return [];
326
369
  }
327
370
  const children = await Promise.all(
328
371
  names.map(async (name) => {
329
372
  const full = path.join(dir, name);
330
- return [full, await safeLstat(full)];
373
+ return [full, await this.#lstat(full)];
331
374
  })
332
375
  );
333
376
  const subdirs = [];
@@ -336,6 +379,12 @@ export class LocalEnv {
336
379
  errors++;
337
380
  continue;
338
381
  }
382
+ if (
383
+ stats.dev !== root.dev ||
384
+ (this.scanPolicy && !this.scanPolicy.allows(full))
385
+ ) {
386
+ continue;
387
+ }
339
388
  account(stats);
340
389
  if (stats.isDirectory()) {
341
390
  subdirs.push(full);
@@ -422,14 +471,24 @@ export class LocalEnv {
422
471
  async #walk(roots, maxDepth, skipNames, visit) {
423
472
  const skip = skipNames ? new Set(skipNames) : this.skipNames;
424
473
  const visited = new Set();
425
- const queue = roots.map((root) => [root, 0]);
474
+ const queue = await Promise.all(
475
+ roots.map(async (root) => [root, 0, await this.deviceId(root)])
476
+ );
426
477
  while (queue.length > 0) {
427
- const [dir, depth] = queue.shift();
478
+ const [dir, depth, device] = queue.shift();
428
479
  if (visited.has(dir)) {
429
480
  continue;
430
481
  }
431
482
  visited.add(dir);
432
- const entries = await this.list(dir);
483
+ const entries = [];
484
+ for (const entry of await this.list(dir)) {
485
+ if (
486
+ entry.type !== 'dir' ||
487
+ (await this.deviceId(entry.path)) === device
488
+ ) {
489
+ entries.push(entry);
490
+ }
491
+ }
433
492
  const pruned = visit(dir, entries) ?? new Set();
434
493
  if (depth + 1 >= maxDepth) {
435
494
  continue;
@@ -438,18 +497,26 @@ export class LocalEnv {
438
497
  if (
439
498
  entry.type === 'dir' &&
440
499
  !pruned.has(entry.name) &&
441
- !skip.has(entry.name)
500
+ !skip.has(entry.name) &&
501
+ (await this.deviceId(entry.path)) === device
442
502
  ) {
443
- queue.push([entry.path, depth + 1]);
503
+ queue.push([entry.path, depth + 1, device]);
444
504
  }
445
505
  }
446
506
  }
447
507
  }
448
508
 
449
509
  async usageMany(targets) {
510
+ const usages = await this.rawUsageMany(targets);
511
+ return this.writableLayer
512
+ ? this.writableLayer.measure(this, usages)
513
+ : usages;
514
+ }
515
+
516
+ async rawUsageMany(targets) {
450
517
  const usages = new Map();
451
518
  for (const target of targets) {
452
- usages.set(target, await this.usage(target));
519
+ usages.set(target, await this.rawUsage(target));
453
520
  }
454
521
  return usages;
455
522
  }
@@ -492,6 +559,13 @@ export class LocalEnv {
492
559
 
493
560
  async processes() {
494
561
  const selfPids = new Set([process.pid, process.ppid]);
562
+ if (this.processInspector?.probeExecutor) {
563
+ return (
564
+ (await this.processInspector.processes())?.filter(
565
+ (proc) => !selfPids.has(proc.pid)
566
+ ) ?? null
567
+ );
568
+ }
495
569
  if (this.platform === 'linux') {
496
570
  return (await this.#linuxProcesses()).filter(
497
571
  (proc) => !selfPids.has(proc.pid)
@@ -507,6 +581,13 @@ export class LocalEnv {
507
581
  .filter((proc) => !selfPids.has(proc.pid));
508
582
  }
509
583
  const result = await this.run(['ps', '-axo', 'pid=,comm=']);
584
+ const commands = new Map(
585
+ (await this.run(['ps', '-axo', 'pid=,args='])).stdout
586
+ .split('\n')
587
+ .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
588
+ .filter(Boolean)
589
+ .map((match) => [Number(match[1]), match[2]])
590
+ );
510
591
  const cwds =
511
592
  this.platform === 'darwin'
512
593
  ? parseLsofCwds(
@@ -525,44 +606,45 @@ export class LocalEnv {
525
606
  pid: Number(m[1]),
526
607
  name: path.basename(m[2].trim()),
527
608
  cwd: cwds.get(Number(m[1])) ?? null,
609
+ command: commands.get(Number(m[1])) ?? '',
528
610
  }))
529
611
  .filter((proc) => !selfPids.has(proc.pid));
530
612
  }
531
613
 
532
614
  async #linuxProcesses() {
533
- let pids = [];
534
- try {
535
- pids = (await fsp.readdir('/proc')).filter((name) => /^\d+$/.test(name));
536
- } catch {
537
- return [];
538
- }
615
+ const io = this.options.procFs ?? fsp;
616
+ const pids = (await io.readdir('/proc')).filter((name) =>
617
+ /^\d+$/.test(name)
618
+ );
539
619
  const processes = [];
540
620
  for (const pid of pids) {
541
621
  const base = `/proc/${pid}`;
622
+ const stat = parseProcStat(
623
+ await io.readFile(`${base}/stat`, 'utf8').catch(() => '')
624
+ );
625
+ if (stat.state === 'Z') {
626
+ continue;
627
+ }
542
628
  let name;
543
629
  try {
544
- name = (await fsp.readFile(`${base}/comm`, 'utf8')).trim();
630
+ name = (await io.readFile(`${base}/comm`, 'utf8')).trim();
545
631
  } catch {
546
632
  continue;
547
633
  }
548
- const cwd = await fsp.readlink(`${base}/cwd`).catch(() => null);
549
- const exe = await fsp.readlink(`${base}/exe`).catch(() => '');
550
- const cmdline = await fsp
634
+ const cwd = await io.readlink(`${base}/cwd`).catch(() => null);
635
+ const exe = await io.readlink(`${base}/exe`).catch(() => '');
636
+ const cmdline = await io
551
637
  .readFile(`${base}/cmdline`, 'utf8')
552
638
  .catch(() => '');
553
- const stat = parseProcStat(
554
- await fsp.readFile(`${base}/stat`, 'utf8').catch(() => '')
555
- );
556
- if (stat.state === 'Z') {
557
- continue;
558
- }
559
639
  processes.push({
560
640
  pid: Number(pid),
641
+ ...(await procIdentity(base, io)),
561
642
  name,
562
643
  startTime: stat.startTime,
563
644
  cwd,
564
645
  aliases: processAliases(exe, cmdline.split('\0')[0]),
565
646
  command: cmdline.replaceAll('\0', ' '),
647
+ argv: cmdline.split('\0').filter(Boolean),
566
648
  });
567
649
  }
568
650
  return processes;
@@ -574,55 +656,57 @@ export class LocalEnv {
574
656
  * @returns {Promise<Set<string>|null>}
575
657
  */
576
658
  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
- }
659
+ try {
660
+ let paths = await this.rawOpenPaths({ strict });
661
+ if (strict && this.unreadableProcesses?.length && this.processInspector) {
662
+ const privileged = await this.processInspector.openPaths();
663
+ if (privileged) {
664
+ this.unreadableProcesses =
665
+ this.processInspector.unreadableProcesses ?? [];
666
+ paths = privileged;
602
667
  }
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
- }
668
+ }
669
+ if (strict && this.unreadableProcesses?.length) {
670
+ const { OPEN_PATHS_SCRIPT } = await import('./shell.js');
671
+ const elevated = await this.run([
672
+ 'sudo',
673
+ '-n',
674
+ 'sh',
675
+ '-c',
676
+ OPEN_PATHS_SCRIPT,
677
+ ]);
678
+ if (elevated.code === 0) {
679
+ paths = new Set(
680
+ elevated.stdout.split('\n').filter((line) => line.startsWith('/'))
681
+ );
682
+ this.unreadableProcesses = [];
624
683
  }
625
684
  }
685
+ this.#processHint();
686
+ return paths;
687
+ } catch (error) {
688
+ if (this.processInspector) {
689
+ const paths = await this.processInspector.openPaths();
690
+ this.probeDiagnostic = this.processInspector.probeDiagnostic;
691
+ return paths;
692
+ }
693
+ this.probeDiagnostic = error.message;
694
+ throw error;
695
+ }
696
+ }
697
+
698
+ #processHint() {
699
+ this.probeHint = this.unreadableProcesses?.length
700
+ ? `${this.unreadableProcesses.length} processes are unreadable; uncertainty is scoped to their paths and UID. Re-run as root for complete inspection (docker exec -u 0 -e HOME=<user-home> <container> dss scan).`
701
+ : null;
702
+ }
703
+
704
+ async rawOpenPaths({ strict = true } = {}) {
705
+ if (this.platform === 'linux') {
706
+ const { paths, unreadable } = await procOpenPaths(
707
+ this.options.procFs ?? fsp
708
+ );
709
+ this.unreadableProcesses = strict ? unreadable : [];
626
710
  return paths;
627
711
  }
628
712
  if (this.platform === 'darwin') {
@@ -654,7 +738,7 @@ export class LocalEnv {
654
738
  : [''];
655
739
  for (const dir of dirs.filter(Boolean)) {
656
740
  for (const extension of extensions) {
657
- const stats = await safeLstat(path.join(dir, command + extension));
741
+ const stats = await this.#lstat(path.join(dir, command + extension));
658
742
  if (stats && !stats.isDirectory()) {
659
743
  return true;
660
744
  }
@@ -0,0 +1,155 @@
1
+ /** Shared traversal and deletion boundaries for every scanner/environment. */
2
+ import { isWithin, matchesExcluded } from '../paths.js';
3
+ import { trace } from '../exec.js';
4
+
5
+ const RUNTIME_ROOTS = [
6
+ '/var/lib/docker',
7
+ '/var/lib/containerd',
8
+ '/run/containerd',
9
+ '/var/lib/containers',
10
+ ];
11
+ const unescapeMount = (value) =>
12
+ value.replace(/\\([0-7]{3})/g, (_, octal) =>
13
+ String.fromCharCode(parseInt(octal, 8))
14
+ );
15
+
16
+ export function parseMountInfo(text) {
17
+ return (text ?? '').split('\n').flatMap((line) => {
18
+ const [left, right] = line.split(' - ');
19
+ if (!right) {
20
+ return [];
21
+ }
22
+ const fields = left.split(' ');
23
+ const [type, source, options = ''] = right.split(' ');
24
+ return [
25
+ {
26
+ path: unescapeMount(fields[4]),
27
+ type,
28
+ source,
29
+ readonly: fields[5].split(',').includes('ro'),
30
+ options,
31
+ },
32
+ ];
33
+ });
34
+ }
35
+
36
+ export class ScanPolicy {
37
+ constructor(env, options, mounts) {
38
+ this.env = env;
39
+ this.exclude = options.exclude ?? [];
40
+ this.roots = options.roots ?? [];
41
+ this.mounts = mounts;
42
+ this.excludedPaths = new Set();
43
+ this.pruned = mounts
44
+ .filter(
45
+ (mount) =>
46
+ mount.path !== '/' &&
47
+ (mount.readonly ||
48
+ mount.type === 'overlay' ||
49
+ mount.type === 'fuse.overlayfs' ||
50
+ !this.roots.some((root) => isWithin(root, mount.path, env.path)))
51
+ )
52
+ .map((mount) => mount.path);
53
+ this.overlay = mounts.find(
54
+ (mount) =>
55
+ mount.path === '/' && ['overlay', 'fuse.overlayfs'].includes(mount.type)
56
+ );
57
+ }
58
+
59
+ allows(target) {
60
+ const { path } = this.env;
61
+ if (matchesExcluded(target, this.exclude, path)) {
62
+ this.excludedPaths.add(target);
63
+ return false;
64
+ }
65
+ return (
66
+ !RUNTIME_ROOTS.some((root) => isWithin(target, root, path)) &&
67
+ !matchesExcluded(
68
+ target,
69
+ ['/tmp/containerd-mount*', '/private/tmp/containerd-mount*'],
70
+ path
71
+ ) &&
72
+ !this.pruned.some((root) => isWithin(target, root, path)) &&
73
+ !this.mounts.some(
74
+ (mount) => mount.readonly && isWithin(target, mount.path, path)
75
+ )
76
+ );
77
+ }
78
+
79
+ removalReason(target) {
80
+ if (!this.allows(target)) {
81
+ return 'path is excluded, mounted, read-only or owned by a container runtime';
82
+ }
83
+ if (
84
+ [...this.excludedPaths].some((root) =>
85
+ isWithin(root, target, this.env.path)
86
+ ) ||
87
+ this.exclude.some(
88
+ (pattern) =>
89
+ !/[?*[]/.test(pattern) && isWithin(pattern, target, this.env.path)
90
+ )
91
+ ) {
92
+ return 'contains an excluded path';
93
+ }
94
+ if (this.pruned.some((root) => isWithin(root, target, this.env.path))) {
95
+ return 'contains a mounted filesystem';
96
+ }
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** Install boundaries before any scanner expands paths or traverses roots. */
102
+ export async function configureScanPolicy(env, options) {
103
+ const mounts =
104
+ env.platform === 'linux' && env.readText
105
+ ? parseMountInfo(await env.readText('/proc/self/mountinfo'))
106
+ : [];
107
+ const excluded = env.scanPolicy?.excludedPaths;
108
+ env.scanPolicy = new ScanPolicy(env, options, mounts);
109
+ if (excluded) {
110
+ env.scanPolicy.excludedPaths = excluded;
111
+ }
112
+ if (env.policyInstalled) {
113
+ return;
114
+ }
115
+ env.policyInstalled = true;
116
+ const allowed = (target) => env.scanPolicy.allows(target);
117
+ for (const name of ['list', 'stat', 'exists']) {
118
+ if (!env[name]) {
119
+ continue;
120
+ }
121
+ const original = env[name].bind(env);
122
+ env[name] = async (target, ...args) => {
123
+ if (!allowed(target)) {
124
+ trace('pruned', target);
125
+ return name === 'list' ? [] : name === 'exists' ? false : null;
126
+ }
127
+ const value = await original(target, ...args);
128
+ return name === 'list'
129
+ ? value.filter((entry) =>
130
+ allowed(entry.path ?? env.path.join(target, entry.name))
131
+ )
132
+ : value;
133
+ };
134
+ }
135
+ for (const name of ['listMany', 'existsMany', 'statMany', 'rawUsageMany']) {
136
+ if (!env[name]) {
137
+ continue;
138
+ }
139
+ const original = env[name].bind(env);
140
+ env[name] = async (targets, ...args) => {
141
+ const value = await original(targets.filter(allowed), ...args);
142
+ if (name !== 'listMany') {
143
+ return value;
144
+ }
145
+ return new Map(
146
+ [...value].map(([dir, entries]) => [
147
+ dir,
148
+ entries.filter((entry) =>
149
+ allowed(entry.path ?? env.path.join(dir, entry.name))
150
+ ),
151
+ ])
152
+ );
153
+ };
154
+ }
155
+ }
@@ -0,0 +1,53 @@
1
+ /** Linux proc inspection preserves evidence when only some PIDs are denied. */
2
+ import { promises as fsp } from 'node:fs';
3
+
4
+ export async function procIdentity(base, io = fsp) {
5
+ const read = (name) => io.readFile(`${base}/${name}`, 'utf8').catch(() => '');
6
+ const [status, comm, command] = await Promise.all([
7
+ read('status'),
8
+ read('comm'),
9
+ read('cmdline'),
10
+ ]);
11
+ const uid = /^Uid:\s+(\d+)/m.exec(status)?.[1];
12
+ return {
13
+ pid: Number(base.split('/').at(-1)),
14
+ uid: uid === undefined ? null : Number(uid),
15
+ name: comm.trim(),
16
+ argv: command.split('\0').filter(Boolean),
17
+ command: command.replaceAll('\0', ' '),
18
+ };
19
+ }
20
+
21
+ export async function procOpenPaths(io = fsp) {
22
+ const paths = new Set();
23
+ const unreadable = [];
24
+ const pids = (await io.readdir('/proc')).filter((pid) => /^\d+$/.test(pid));
25
+ for (const pid of pids) {
26
+ const base = `/proc/${pid}`;
27
+ const stat = await io.readFile(`${base}/stat`, 'utf8').catch(() => '');
28
+ if (stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z ')) {
29
+ continue;
30
+ }
31
+ let denied = false;
32
+ const failure = (error) => {
33
+ if (['EACCES', 'EPERM'].includes(error.code)) {
34
+ denied = true;
35
+ }
36
+ return '';
37
+ };
38
+ const fds = await io.readdir(`${base}/fd`).catch((error) => {
39
+ failure(error);
40
+ return [];
41
+ });
42
+ for (const link of ['cwd', 'exe', ...fds.map((fd) => `fd/${fd}`)]) {
43
+ const target = await io.readlink(`${base}/${link}`).catch(failure);
44
+ if (target.startsWith('/')) {
45
+ paths.add(target.replace(/ \(deleted\)$/, ''));
46
+ }
47
+ }
48
+ if (denied) {
49
+ unreadable.push(await procIdentity(base, io));
50
+ }
51
+ }
52
+ return { paths, unreadable };
53
+ }