disk-space-saviour 0.15.2 → 0.16.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.
@@ -3,11 +3,13 @@ import { DockerCli } from './cli.js';
3
3
  import { isWithin } from '../paths.js';
4
4
  import { trace } from '../exec.js';
5
5
  import { resetDiffSnapshots } from './snapshots.js';
6
+ import { rootUpperdir, upperdirBytes } from './upperdir.js';
6
7
 
7
8
  let measurementQueue = Promise.resolve();
8
9
  const layers = new WeakMap();
9
10
 
10
11
  export function sharedWritableLayer(executor, id) {
12
+ executor = executor.unscoped ?? executor;
11
13
  if (!layers.has(executor)) {
12
14
  layers.set(executor, new Map());
13
15
  }
@@ -19,12 +21,14 @@ export function sharedWritableLayer(executor, id) {
19
21
  }
20
22
 
21
23
  export function resetWritableLayers(executor) {
24
+ executor = executor.unscoped ?? executor;
22
25
  layers.delete(executor);
23
26
  resetDiffSnapshots(executor);
24
27
  }
25
28
 
26
29
  /** Overlay without an authoritative upper layer must never count merged bytes. */
27
30
  export class UnknownWritableLayer {
31
+ source = 'unknown-overlay';
28
32
  measure(env, usages) {
29
33
  return Promise.resolve(
30
34
  new Map(
@@ -67,14 +71,14 @@ export class WritableLayer {
67
71
  if (!container) {
68
72
  return { diff: null, container };
69
73
  }
70
- const upperdir =
71
- container.GraphDriver?.Data?.UpperDir ?? this.upperdirCandidate;
74
+ const { upperdir, source } = await this.upperdir(container);
72
75
  if (upperdir?.startsWith('/')) {
73
76
  const result = await this.docker.executor
74
77
  .run(['stat', '-c', '%F', '--', upperdir])
75
78
  .catch(() => null);
76
79
  if (result?.code === 0 && result.stdout.trim() === 'directory') {
77
80
  trace('using authoritative upperdir', this.id);
81
+ this.source = source;
78
82
  return { container, upperdir, diff: null };
79
83
  }
80
84
  }
@@ -86,6 +90,7 @@ export class WritableLayer {
86
90
  return { diff: null, container };
87
91
  }
88
92
  const diff = await this.docker.diff(this.id);
93
+ this.source = diff === null ? 'unknown' : 'docker-diff';
89
94
  trace('finished measuring container', this.id);
90
95
  // Bound the retained change set; unknown is safer than a partial result.
91
96
  return {
@@ -102,6 +107,27 @@ export class WritableLayer {
102
107
  return this.snapshotPromise;
103
108
  }
104
109
 
110
+ async upperdir(container) {
111
+ if (container.GraphDriver?.Data?.UpperDir) {
112
+ return {
113
+ upperdir: container.GraphDriver.Data.UpperDir,
114
+ source: 'graphdriver-upperdir',
115
+ };
116
+ }
117
+ const pid = container.State?.Pid;
118
+ if (Number.isInteger(pid) && pid > 0) {
119
+ const info = await this.docker.executor.run([
120
+ 'cat',
121
+ `/proc/${pid}/mountinfo`,
122
+ ]);
123
+ const upperdir = info.code === 0 ? rootUpperdir(info.stdout) : null;
124
+ if (upperdir) {
125
+ return { upperdir, source: 'mountinfo-upperdir' };
126
+ }
127
+ }
128
+ return { upperdir: this.upperdirCandidate, source: 'overlay-upperdir' };
129
+ }
130
+
105
131
  forget(target, pathApi) {
106
132
  this.removed.add(target);
107
133
  for (const file of this.stats.keys()) {
@@ -111,11 +137,55 @@ export class WritableLayer {
111
137
  }
112
138
  }
113
139
 
140
+ /** Allocated upper blocks before/after deletion, independent of merged data. */
141
+ async allocatedBytes(paths, pathApi) {
142
+ const { container, upperdir } = await this.snapshot();
143
+ if (
144
+ !upperdir ||
145
+ (container.Mounts ?? []).some((mount) =>
146
+ paths.some(
147
+ (target) =>
148
+ isWithin(target, mount.Destination, pathApi) ||
149
+ isWithin(mount.Destination, target, pathApi)
150
+ )
151
+ )
152
+ ) {
153
+ return null;
154
+ }
155
+ const measured = await upperdirBytes(
156
+ this.docker.executor,
157
+ paths.map((target) => `${upperdir}/${target.replace(/^\/+/, '')}`)
158
+ );
159
+ const values = [...measured.values()];
160
+ return values.length === paths.length &&
161
+ values.every((value) => value !== null)
162
+ ? values.reduce((sum, value) => sum + value, 0)
163
+ : null;
164
+ }
165
+
114
166
  async measureUpperdir(env, usages, container, upperdir) {
115
167
  const result = new Map();
116
168
  const mounts = (container.Mounts ?? [])
117
169
  .map((mount) => mount.Destination)
118
170
  .filter(Boolean);
171
+ const upperPath = (target) => `${upperdir}/${target.replace(/^\/+/, '')}`;
172
+ const targets = [...usages.keys()].filter(
173
+ (target) => !mounts.some((mount) => isWithin(target, mount, env.path))
174
+ );
175
+ const measurements = await upperdirBytes(
176
+ this.docker.executor,
177
+ targets.map(upperPath)
178
+ );
179
+ const mountRoots = mounts.filter(
180
+ (mount) =>
181
+ targets.some((target) => isWithin(mount, target, env.path)) &&
182
+ !mounts.some(
183
+ (other) => other !== mount && isWithin(mount, other, env.path)
184
+ )
185
+ );
186
+ const mountedUsage = mountRoots.length
187
+ ? await env.rawUsageMany(mountRoots)
188
+ : new Map();
119
189
  for (const [target, usage] of usages) {
120
190
  if (mounts.some((mount) => isWithin(target, mount, env.path))) {
121
191
  result.set(target, {
@@ -125,17 +195,24 @@ export class WritableLayer {
125
195
  });
126
196
  continue;
127
197
  }
128
- const measured = await this.docker.executor.run([
129
- 'du',
130
- '-skx',
131
- '--',
132
- `${upperdir}/${target.replace(/^\/+/, '')}`,
133
- ]);
134
- const kib = Number(/^([0-9]+)\s/m.exec(measured.stdout)?.[1]);
135
- // Missing upperdir leaf means image-only. Other errors remain unknown.
136
- const absent = /no such file|not found/i.test(measured.stderr);
137
- const known = (measured.code === 0 && Number.isFinite(kib)) || absent;
138
- const bytes = known ? Math.min(usage.bytes, absent ? 0 : kib * 1024) : 0;
198
+ const measured = measurements.get(upperPath(target));
199
+ const contained = mountRoots.filter((mount) =>
200
+ isWithin(mount, target, env.path)
201
+ );
202
+ const known =
203
+ measured !== null &&
204
+ measured !== undefined &&
205
+ contained.every((mount) => mountedUsage.has(mount));
206
+ const bytes = known
207
+ ? Math.min(
208
+ usage.bytes,
209
+ measured +
210
+ contained.reduce(
211
+ (sum, mount) => sum + mountedUsage.get(mount).bytes,
212
+ 0
213
+ )
214
+ )
215
+ : 0;
139
216
  result.set(target, {
140
217
  ...usage,
141
218
  bytes,
@@ -0,0 +1,20 @@
1
+ /** Bound argument count and encoded size below common exec limits. */
2
+ export function batches(items, size = 200, maxBytes = 48000) {
3
+ const result = [];
4
+ let batch = [],
5
+ bytes = 0;
6
+ for (const item of items) {
7
+ const length = Buffer.byteLength(String(item)) + 1;
8
+ if (batch.length && (batch.length >= size || bytes + length > maxBytes)) {
9
+ result.push(batch);
10
+ batch = [];
11
+ bytes = 0;
12
+ }
13
+ batch.push(item);
14
+ bytes += length;
15
+ }
16
+ if (batch.length) {
17
+ result.push(batch);
18
+ }
19
+ return result;
20
+ }
@@ -0,0 +1,138 @@
1
+ /** Resolve cache configuration once per environment, using only that user's tools. */
2
+ export const CACHE_VARIABLES = [
3
+ 'LOCALAPPDATA',
4
+ 'APPDATA',
5
+ 'TMPDIR',
6
+ 'XDG_CACHE_HOME',
7
+ 'CARGO_HOME',
8
+ 'RUSTUP_HOME',
9
+ 'GRADLE_USER_HOME',
10
+ 'DENO_DIR',
11
+ 'BUN_INSTALL_CACHE_DIR',
12
+ 'GOMODCACHE',
13
+ 'GOCACHE',
14
+ 'GOPATH',
15
+ 'NVM_DIR',
16
+ 'SDKMAN_DIR',
17
+ 'PERLBREW_ROOT',
18
+ 'RBENV_ROOT',
19
+ 'GEM_HOME',
20
+ 'GEM_PATH',
21
+ 'npm_config_cache',
22
+ 'NPM_CONFIG_CACHE',
23
+ 'PIP_CACHE_DIR',
24
+ 'YARN_CACHE_FOLDER',
25
+ 'PNPM_STORE_DIR',
26
+ ];
27
+
28
+ const ROOTS = [
29
+ ['~/.cargo', 'CARGO_HOME'],
30
+ ['~/.rustup', 'RUSTUP_HOME'],
31
+ ['~/.gradle', 'GRADLE_USER_HOME'],
32
+ ['~/.nvm', 'NVM_DIR'],
33
+ ['~/.sdkman', 'SDKMAN_DIR'],
34
+ ['~/.perl5', 'PERLBREW_ROOT'],
35
+ ['~/perl5/perlbrew', 'PERLBREW_ROOT'],
36
+ ['~/.rbenv', 'RBENV_ROOT'],
37
+ ['~/.bun/install/cache', 'BUN_INSTALL_CACHE_DIR'],
38
+ ['~/.cache/deno', 'DENO_DIR'],
39
+ ['~/.npm', 'npm_config_cache', 'NPM_CONFIG_CACHE'],
40
+ ['~/.cache/pip', 'PIP_CACHE_DIR'],
41
+ ['~/.cache/yarn', 'YARN_CACHE_FOLDER'],
42
+ ['~/.local/share/pnpm/store', 'PNPM_STORE_DIR'],
43
+ ['~/go/pkg/mod', 'GOMODCACHE'],
44
+ ['~/.cache/go-build', 'GOCACHE'],
45
+ ['~/Library/Caches/go-build', 'GOCACHE'],
46
+ ['{LOCALAPPDATA}/go-build', 'GOCACHE'],
47
+ ['~/Library/Caches/deno', 'DENO_DIR'],
48
+ ['{LOCALAPPDATA}/deno', 'DENO_DIR'],
49
+ ['{LOCALAPPDATA}/npm-cache', 'npm_config_cache', 'NPM_CONFIG_CACHE'],
50
+ ['~/Library/Caches/pip', 'PIP_CACHE_DIR'],
51
+ ['{LOCALAPPDATA}/pip/Cache', 'PIP_CACHE_DIR'],
52
+ ['~/Library/Caches/Yarn', 'YARN_CACHE_FOLDER'],
53
+ ['{LOCALAPPDATA}/Yarn/Cache', 'YARN_CACHE_FOLDER'],
54
+ ['~/Library/pnpm/store', 'PNPM_STORE_DIR'],
55
+ ['~/.pnpm-store', 'PNPM_STORE_DIR'],
56
+ ['{LOCALAPPDATA}/pnpm/store', 'PNPM_STORE_DIR'],
57
+ ['~/.cache', 'XDG_CACHE_HOME'],
58
+ ];
59
+
60
+ export function configuredPattern(env, home, pattern) {
61
+ if (home !== env.currentHome) {
62
+ return pattern;
63
+ }
64
+ for (const [prefix, ...keys] of ROOTS) {
65
+ if (pattern !== prefix && !pattern.startsWith(`${prefix}/`)) {
66
+ continue;
67
+ }
68
+ const root = keys
69
+ .map((key) => env.vars?.[key])
70
+ .find((value) => value && env.path.isAbsolute(value));
71
+ if (root) {
72
+ return env.path.join(root, pattern.slice(prefix.length));
73
+ }
74
+ }
75
+ return pattern;
76
+ }
77
+
78
+ function applyQuery(vars, keys, values, pathApi) {
79
+ for (const key of keys) {
80
+ if (
81
+ !vars[key] &&
82
+ typeof values[key] === 'string' &&
83
+ pathApi.isAbsolute(values[key])
84
+ ) {
85
+ vars[key] = values[key];
86
+ }
87
+ }
88
+ }
89
+
90
+ export async function configureCacheRoots(env, homes) {
91
+ if (env.variables) {
92
+ Object.assign(env.vars, await env.variables(CACHE_VARIABLES));
93
+ }
94
+ if (!homes.includes(env.currentHome)) {
95
+ return;
96
+ }
97
+ const vars = env.vars;
98
+ if (!vars.GOMODCACHE && vars.GOPATH) {
99
+ vars.GOMODCACHE = env.path.join(
100
+ vars.GOPATH.split(env.platform === 'win32' ? ';' : ':')[0],
101
+ 'pkg/mod'
102
+ );
103
+ }
104
+ const queries = [
105
+ [
106
+ 'go',
107
+ ['go', 'env', '-json', 'GOMODCACHE', 'GOCACHE'],
108
+ ['GOMODCACHE', 'GOCACHE'],
109
+ ],
110
+ ['npm', ['npm', 'config', 'get', 'cache'], ['npm_config_cache']],
111
+ ['pip', ['pip', 'cache', 'dir'], ['PIP_CACHE_DIR']],
112
+ ['yarn', ['yarn', 'cache', 'dir'], ['YARN_CACHE_FOLDER']],
113
+ ['pnpm', ['pnpm', 'store', 'path'], ['PNPM_STORE_DIR']],
114
+ ];
115
+ for (const [tool, argv, keys] of queries) {
116
+ if (
117
+ keys.every((key) => vars[key]) ||
118
+ (tool === 'npm' && vars.NPM_CONFIG_CACHE) ||
119
+ !(await env.which(tool))
120
+ ) {
121
+ continue;
122
+ }
123
+ const result = await env.run(argv, { timeoutMs: 5000 });
124
+ if (result.code !== 0) {
125
+ continue;
126
+ }
127
+ let values;
128
+ try {
129
+ values =
130
+ tool === 'go'
131
+ ? JSON.parse(result.stdout)
132
+ : { [keys[0]]: result.stdout.trim().split('\n').at(-1) };
133
+ } catch {
134
+ continue;
135
+ }
136
+ applyQuery(vars, keys, values, env.path);
137
+ }
138
+ }
@@ -0,0 +1,100 @@
1
+ import { OPEN_PATHS_SCRIPT } from './process-scripts.js';
2
+ import { trace } from '../exec.js';
3
+
4
+ export function parseProcessProbe(result) {
5
+ const gids = new Map(
6
+ result.stderr
7
+ .split('\n')
8
+ .filter((line) => line.startsWith('DSS_GID|'))
9
+ .map((line) => {
10
+ const [, pid, gid] = line.split('|');
11
+ return [Number(pid), gid ? Number(gid) : null];
12
+ })
13
+ );
14
+ return {
15
+ paths: new Set(
16
+ result.stdout
17
+ .split('\n')
18
+ .filter((line) => line.startsWith('/'))
19
+ .map((line) => line.replace(/ \(deleted\)$/, ''))
20
+ ),
21
+ unreadable: result.stderr
22
+ .split('\n')
23
+ .filter((line) => line.startsWith('DSS_UNREADABLE|'))
24
+ .map((line) => {
25
+ const [, pid, uid, name, ...command] = line.split('|');
26
+ return {
27
+ pid: Number(pid),
28
+ uid: uid ? Number(uid) : null,
29
+ gid: gids.get(Number(pid)) ?? null,
30
+ name,
31
+ command: command.join('|'),
32
+ };
33
+ }),
34
+ };
35
+ }
36
+
37
+ /** Root without ptrace can often inspect a dumpable process as its own UID/GID. */
38
+ export async function retryProcessOwners(executor, evidence) {
39
+ const groups = new Map();
40
+ for (const proc of evidence.unreadable) {
41
+ if (
42
+ !Number.isInteger(proc.uid) ||
43
+ !Number.isInteger(proc.gid) ||
44
+ proc.uid === 0
45
+ ) {
46
+ continue;
47
+ }
48
+ const key = `${proc.uid}:${proc.gid}`;
49
+ groups.set(key, [...(groups.get(key) ?? []), proc]);
50
+ }
51
+ const unresolved = new Map(
52
+ evidence.unreadable.map((proc) => [proc.pid, proc])
53
+ );
54
+ for (const [identity, procs] of groups) {
55
+ const [uid, gid] = identity.split(':');
56
+ trace(
57
+ 'process inspection as owning uid/gid',
58
+ identity,
59
+ procs.map((proc) => proc.pid)
60
+ );
61
+ const result = await executor.run([
62
+ 'setpriv',
63
+ `--reuid=${uid}`,
64
+ `--regid=${gid}`,
65
+ '--clear-groups',
66
+ 'sh',
67
+ '-c',
68
+ OPEN_PATHS_SCRIPT,
69
+ 'dss',
70
+ ...procs.map((proc) => `/proc/${proc.pid}`),
71
+ ]);
72
+ const retried = parseProcessProbe(result);
73
+ for (const target of retried.paths) {
74
+ evidence.paths.add(target);
75
+ }
76
+ if (result.code === 0 || retried.unreadable.length) {
77
+ for (const proc of procs) {
78
+ unresolved.delete(proc.pid);
79
+ }
80
+ for (const proc of retried.unreadable) {
81
+ unresolved.set(proc.pid, proc);
82
+ }
83
+ }
84
+ }
85
+ return { paths: evidence.paths, unreadable: [...unresolved.values()] };
86
+ }
87
+
88
+ export function inspectionHint(unreadable) {
89
+ if (!unreadable.length) {
90
+ return null;
91
+ }
92
+ const identities = unreadable
93
+ .slice(0, 5)
94
+ .map(
95
+ (proc) =>
96
+ `${proc.name || 'unknown'} pid ${proc.pid} uid ${proc.uid ?? '?'} gid ${proc.gid ?? '?'}`
97
+ )
98
+ .join('; ');
99
+ return `${unreadable.length} processes are unreadable (${identities}). Activity uncertainty is scoped to daemon storage and accessible paths. Inspect as the owning UID/GID (setpriv), or grant CAP_SYS_PTRACE for read-only inspection; root alone may lack that capability.`;
100
+ }
package/src/env/local.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { checkSignal } from './scope.js';
2
+ import { retryProcessOwners, inspectionHint } from './inspection.js';
1
3
  /**
2
4
  * Base names of a process's executable and `argv[0]`, without duplicates
3
5
  * and without the ` (deleted)` suffix of replaced binaries.
@@ -58,6 +60,7 @@ import os from 'node:os';
58
60
  import path from 'node:path';
59
61
  import { URL, fileURLToPath } from 'node:url';
60
62
  import { procIdentity, procOpenPaths } from './proc.js';
63
+ import { CACHE_VARIABLES } from './cache-roots.js';
61
64
 
62
65
  import { hostExecutor } from '../exec.js';
63
66
  import { matchesGlob } from '../paths.js';
@@ -189,11 +192,11 @@ export class LocalEnv {
189
192
  this.protectedPaths = [fileURLToPath(new URL('../../', import.meta.url))];
190
193
  this.currentHome = os.homedir();
191
194
  this.skipNames = new Set(options.skipNames ?? DEFAULT_SKIP_NAMES);
192
- this.vars = options.vars ?? {
193
- LOCALAPPDATA: process.env.LOCALAPPDATA,
194
- APPDATA: process.env.APPDATA,
195
- TMPDIR: process.env.TMPDIR,
196
- };
195
+ this.vars =
196
+ options.vars ??
197
+ Object.fromEntries(
198
+ CACHE_VARIABLES.map((name) => [name, process.env[name]])
199
+ );
197
200
  }
198
201
 
199
202
  recordAccessError(target, error) {
@@ -251,21 +254,26 @@ export class LocalEnv {
251
254
  mtimeMs: stats.mtimeMs,
252
255
  inode: `${stats.dev}:${stats.ino}`,
253
256
  uid: stats.uid,
257
+ gid: stats.gid,
258
+ mode: stats.mode,
254
259
  device: stats.dev,
255
260
  };
256
261
  }
257
262
 
258
263
  async readText(target, maxBytes = 1024 * 1024) {
264
+ checkSignal(this.signal);
259
265
  try {
260
266
  const handle = await fsp.open(target, 'r');
261
267
  try {
262
268
  const buffer = Buffer.alloc(maxBytes);
263
269
  const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
270
+ checkSignal(this.signal);
264
271
  return buffer.subarray(0, bytesRead).toString('utf8');
265
272
  } finally {
266
273
  await handle.close();
267
274
  }
268
275
  } catch {
276
+ checkSignal(this.signal);
269
277
  return null;
270
278
  }
271
279
  }
@@ -394,6 +402,7 @@ export class LocalEnv {
394
402
  };
395
403
  let frontier = root.isDirectory() ? [target] : [];
396
404
  while (frontier.length > 0) {
405
+ checkSignal(this.signal);
397
406
  const next = [];
398
407
  for (let index = 0; index < frontier.length; index += WALK_BATCH) {
399
408
  const found = await Promise.all(
@@ -475,6 +484,7 @@ export class LocalEnv {
475
484
  roots.map(async (root) => [root, 0, await this.deviceId(root)])
476
485
  );
477
486
  while (queue.length > 0) {
487
+ checkSignal(this.signal);
478
488
  const [dir, depth, device] = queue.shift();
479
489
  if (visited.has(dir)) {
480
490
  continue;
@@ -613,11 +623,15 @@ export class LocalEnv {
613
623
 
614
624
  async #linuxProcesses() {
615
625
  const io = this.options.procFs ?? fsp;
626
+ this.mountNamespace = await io
627
+ .readlink('/proc/self/ns/mnt')
628
+ .catch(() => null);
616
629
  const pids = (await io.readdir('/proc')).filter((name) =>
617
630
  /^\d+$/.test(name)
618
631
  );
619
632
  const processes = [];
620
633
  for (const pid of pids) {
634
+ checkSignal(this.signal);
621
635
  const base = `/proc/${pid}`;
622
636
  const stat = parseProcStat(
623
637
  await io.readFile(`${base}/stat`, 'utf8').catch(() => '')
@@ -641,6 +655,7 @@ export class LocalEnv {
641
655
  ...(await procIdentity(base, io)),
642
656
  name,
643
657
  startTime: stat.startTime,
658
+ mountNamespace: await io.readlink(`${base}/ns/mnt`).catch(() => null),
644
659
  cwd,
645
660
  aliases: processAliases(exe, cmdline.split('\0')[0]),
646
661
  command: cmdline.replaceAll('\0', ' '),
@@ -658,29 +673,8 @@ export class LocalEnv {
658
673
  async openPaths({ strict = true } = {}) {
659
674
  try {
660
675
  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;
667
- }
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 = [];
683
- }
676
+ if (strict) {
677
+ paths = await this.#completeInspection(paths);
684
678
  }
685
679
  this.#processHint();
686
680
  return paths;
@@ -688,6 +682,9 @@ export class LocalEnv {
688
682
  if (this.processInspector) {
689
683
  const paths = await this.processInspector.openPaths();
690
684
  this.probeDiagnostic = this.processInspector.probeDiagnostic;
685
+ this.unreadableProcesses =
686
+ this.processInspector.unreadableProcesses ?? [];
687
+ this.#processHint();
691
688
  return paths;
692
689
  }
693
690
  this.probeDiagnostic = error.message;
@@ -695,16 +692,56 @@ export class LocalEnv {
695
692
  }
696
693
  }
697
694
 
695
+ async #completeInspection(paths) {
696
+ paths = await this.#inspectOwners(paths);
697
+ if (this.unreadableProcesses?.length && this.processInspector) {
698
+ const privileged = await this.processInspector.openPaths();
699
+ if (privileged) {
700
+ this.unreadableProcesses =
701
+ this.processInspector.unreadableProcesses ?? [];
702
+ paths = new Set([...(paths ?? []), ...privileged]);
703
+ }
704
+ }
705
+ if (this.unreadableProcesses?.length) {
706
+ const { OPEN_PATHS_SCRIPT } = await import('./shell.js');
707
+ const elevated = await this.run([
708
+ 'sudo',
709
+ '-n',
710
+ 'sh',
711
+ '-c',
712
+ OPEN_PATHS_SCRIPT,
713
+ ]);
714
+ if (elevated.code === 0) {
715
+ paths = new Set(
716
+ elevated.stdout.split('\n').filter((line) => line.startsWith('/'))
717
+ );
718
+ this.unreadableProcesses = [];
719
+ }
720
+ }
721
+ return paths;
722
+ }
723
+
698
724
  #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;
725
+ this.probeHint = inspectionHint(this.unreadableProcesses ?? []);
726
+ }
727
+
728
+ async #inspectOwners(paths) {
729
+ if (!this.unreadableProcesses?.length || !(await this.isRoot())) {
730
+ return paths;
731
+ }
732
+ const evidence = await retryProcessOwners(this.executor, {
733
+ paths,
734
+ unreadable: this.unreadableProcesses,
735
+ });
736
+ this.unreadableProcesses = evidence.unreadable;
737
+ return evidence.paths;
702
738
  }
703
739
 
704
740
  async rawOpenPaths({ strict = true } = {}) {
705
741
  if (this.platform === 'linux') {
706
742
  const { paths, unreadable } = await procOpenPaths(
707
- this.options.procFs ?? fsp
743
+ this.options.procFs ?? fsp,
744
+ this.signal
708
745
  );
709
746
  this.unreadableProcesses = strict ? unreadable : [];
710
747
  return paths;
package/src/env/proc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Linux proc inspection preserves evidence when only some PIDs are denied. */
2
2
  import { promises as fsp } from 'node:fs';
3
+ import { checkSignal } from './scope.js';
3
4
 
4
5
  export async function procIdentity(base, io = fsp) {
5
6
  const read = (name) => io.readFile(`${base}/${name}`, 'utf8').catch(() => '');
@@ -9,20 +10,30 @@ export async function procIdentity(base, io = fsp) {
9
10
  read('cmdline'),
10
11
  ]);
11
12
  const uid = /^Uid:\s+(\d+)/m.exec(status)?.[1];
13
+ const gid = /^Gid:\s+(\d+)/m.exec(status)?.[1];
12
14
  return {
13
15
  pid: Number(base.split('/').at(-1)),
14
16
  uid: uid === undefined ? null : Number(uid),
17
+ gid: gid === undefined ? null : Number(gid),
18
+ groups:
19
+ /^Groups:\s*(.*)$/m
20
+ .exec(status)?.[1]
21
+ .trim()
22
+ .split(/\s+/)
23
+ .filter(Boolean)
24
+ .map(Number) ?? null,
15
25
  name: comm.trim(),
16
26
  argv: command.split('\0').filter(Boolean),
17
27
  command: command.replaceAll('\0', ' '),
18
28
  };
19
29
  }
20
30
 
21
- export async function procOpenPaths(io = fsp) {
31
+ export async function procOpenPaths(io = fsp, signal) {
22
32
  const paths = new Set();
23
33
  const unreadable = [];
24
34
  const pids = (await io.readdir('/proc')).filter((pid) => /^\d+$/.test(pid));
25
35
  for (const pid of pids) {
36
+ checkSignal(signal);
26
37
  const base = `/proc/${pid}`;
27
38
  const stat = await io.readFile(`${base}/stat`, 'utf8').catch(() => '');
28
39
  if (stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z ')) {
@@ -40,6 +51,7 @@ export async function procOpenPaths(io = fsp) {
40
51
  return [];
41
52
  });
42
53
  for (const link of ['cwd', 'exe', ...fds.map((fd) => `fd/${fd}`)]) {
54
+ checkSignal(signal);
43
55
  const target = await io.readlink(`${base}/${link}`).catch(failure);
44
56
  if (target.startsWith('/')) {
45
57
  paths.add(target.replace(/ \(deleted\)$/, ''));