disk-space-saviour 0.15.2 → 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/CHANGELOG.md +20 -0
- package/README.md +74 -1
- package/bin/dss.js +22 -5
- package/package.json +1 -1
- package/src/audit.js +19 -2
- package/src/clean.js +153 -11
- package/src/cli.js +102 -14
- package/src/docker/cli.js +11 -7
- package/src/docker/scan.js +25 -4
- package/src/docker/upperdir.js +44 -0
- package/src/docker/writable.js +90 -13
- package/src/emergency.js +37 -6
- package/src/env/batches.js +20 -0
- package/src/env/cache-roots.js +138 -0
- package/src/env/inspection.js +100 -0
- package/src/env/local.js +314 -96
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/proc.js +13 -1
- package/src/env/process-scripts.js +24 -0
- package/src/env/scope.js +95 -0
- package/src/env/shell.js +146 -108
- package/src/exec.js +79 -8
- package/src/fast-clean.js +39 -0
- package/src/health.js +16 -5
- package/src/index.d.ts +52 -1
- package/src/items.js +9 -3
- package/src/liveness.js +112 -25
- package/src/options.js +9 -1
- package/src/report.js +25 -3
- package/src/rules/toolchains.js +66 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +240 -19
- package/src/scanners/agents.js +3 -2
- package/src/scanners/common.js +6 -1
- package/src/scanners/global.js +91 -22
- package/src/scanners/projects.js +114 -20
- package/src/scanners/rust.js +7 -1
- package/src/scanners/system.js +7 -6
- package/src/scanners/versions.js +25 -13
- package/src/swap.js +69 -0
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,9 +60,13 @@ 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';
|
|
67
|
+
import { mapLimit } from './parallel.js';
|
|
68
|
+
import { directoryFingerprint, SIZE_MAX_AGE } from '../scan-state.js';
|
|
69
|
+
import { indexedDirectories } from './platform-index.js';
|
|
64
70
|
|
|
65
71
|
// Directories read concurrently while measuring usage.
|
|
66
72
|
const WALK_BATCH = 64;
|
|
@@ -189,11 +195,11 @@ export class LocalEnv {
|
|
|
189
195
|
this.protectedPaths = [fileURLToPath(new URL('../../', import.meta.url))];
|
|
190
196
|
this.currentHome = os.homedir();
|
|
191
197
|
this.skipNames = new Set(options.skipNames ?? DEFAULT_SKIP_NAMES);
|
|
192
|
-
this.vars =
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
198
|
+
this.vars =
|
|
199
|
+
options.vars ??
|
|
200
|
+
Object.fromEntries(
|
|
201
|
+
CACHE_VARIABLES.map((name) => [name, process.env[name]])
|
|
202
|
+
);
|
|
197
203
|
}
|
|
198
204
|
|
|
199
205
|
recordAccessError(target, error) {
|
|
@@ -249,23 +255,29 @@ export class LocalEnv {
|
|
|
249
255
|
size: stats.size,
|
|
250
256
|
bytes: diskBytes(stats),
|
|
251
257
|
mtimeMs: stats.mtimeMs,
|
|
258
|
+
ctimeMs: stats.ctimeMs,
|
|
252
259
|
inode: `${stats.dev}:${stats.ino}`,
|
|
253
260
|
uid: stats.uid,
|
|
261
|
+
gid: stats.gid,
|
|
262
|
+
mode: stats.mode,
|
|
254
263
|
device: stats.dev,
|
|
255
264
|
};
|
|
256
265
|
}
|
|
257
266
|
|
|
258
267
|
async readText(target, maxBytes = 1024 * 1024) {
|
|
268
|
+
checkSignal(this.signal);
|
|
259
269
|
try {
|
|
260
270
|
const handle = await fsp.open(target, 'r');
|
|
261
271
|
try {
|
|
262
272
|
const buffer = Buffer.alloc(maxBytes);
|
|
263
273
|
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
274
|
+
checkSignal(this.signal);
|
|
264
275
|
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
265
276
|
} finally {
|
|
266
277
|
await handle.close();
|
|
267
278
|
}
|
|
268
279
|
} catch {
|
|
280
|
+
checkSignal(this.signal);
|
|
269
281
|
return null;
|
|
270
282
|
}
|
|
271
283
|
}
|
|
@@ -287,31 +299,36 @@ export class LocalEnv {
|
|
|
287
299
|
return heads;
|
|
288
300
|
}
|
|
289
301
|
|
|
290
|
-
async list(dir) {
|
|
302
|
+
async list(dir, { discovery = false } = {}) {
|
|
291
303
|
let names;
|
|
292
304
|
try {
|
|
293
|
-
names = await (this.options.fileFs ?? fsp).readdir(dir
|
|
305
|
+
names = await (this.options.fileFs ?? fsp).readdir(dir, {
|
|
306
|
+
withFileTypes: true,
|
|
307
|
+
});
|
|
294
308
|
} catch (error) {
|
|
295
309
|
this.recordAccessError(dir, error);
|
|
296
310
|
return [];
|
|
297
311
|
}
|
|
298
|
-
const entries = await
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
const entries = await mapLimit(names, 32, async (entry) => {
|
|
313
|
+
const name = typeof entry === 'string' ? entry : entry.name;
|
|
314
|
+
const full = path.join(dir, name);
|
|
315
|
+
if (discovery && typeof entry !== 'string' && !entry.isDirectory()) {
|
|
316
|
+
return { name, path: full, type: entryType(entry), mtimeMs: 0 };
|
|
317
|
+
}
|
|
318
|
+
const stats = await this.#lstat(full);
|
|
319
|
+
if (!stats) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
name,
|
|
324
|
+
path: full,
|
|
325
|
+
type: entryType(stats),
|
|
326
|
+
size: stats.size,
|
|
327
|
+
bytes: diskBytes(stats),
|
|
328
|
+
mtimeMs: stats.mtimeMs,
|
|
329
|
+
device: stats.dev,
|
|
330
|
+
};
|
|
331
|
+
});
|
|
315
332
|
return entries.filter(Boolean);
|
|
316
333
|
}
|
|
317
334
|
|
|
@@ -320,14 +337,15 @@ export class LocalEnv {
|
|
|
320
337
|
* counted a single time, symlinks not followed. Also reports the newest
|
|
321
338
|
* file mtime.
|
|
322
339
|
*/
|
|
323
|
-
async usage(target) {
|
|
324
|
-
return (await this.usageMany([target])).get(target) ?? null;
|
|
340
|
+
async usage(target, options) {
|
|
341
|
+
return (await this.usageMany([target], options)).get(target) ?? null;
|
|
325
342
|
}
|
|
326
343
|
|
|
327
344
|
async statMany(targets) {
|
|
328
|
-
const entries = await
|
|
329
|
-
|
|
330
|
-
|
|
345
|
+
const entries = await mapLimit(targets, 32, async (target) => [
|
|
346
|
+
target,
|
|
347
|
+
await this.stat(target),
|
|
348
|
+
]);
|
|
331
349
|
return new Map(entries.filter(([, stat]) => stat));
|
|
332
350
|
}
|
|
333
351
|
|
|
@@ -354,6 +372,9 @@ export class LocalEnv {
|
|
|
354
372
|
apparentBytes += stats.size;
|
|
355
373
|
if (!stats.isDirectory()) {
|
|
356
374
|
files++;
|
|
375
|
+
if (this.scanMetrics) {
|
|
376
|
+
this.scanMetrics.filesSized++;
|
|
377
|
+
}
|
|
357
378
|
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
|
|
358
379
|
}
|
|
359
380
|
};
|
|
@@ -367,12 +388,10 @@ export class LocalEnv {
|
|
|
367
388
|
errors++;
|
|
368
389
|
return [];
|
|
369
390
|
}
|
|
370
|
-
const children = await
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
})
|
|
375
|
-
);
|
|
391
|
+
const children = await mapLimit(names, 32, async (name) => {
|
|
392
|
+
const full = path.join(dir, name);
|
|
393
|
+
return [full, await this.#lstat(full)];
|
|
394
|
+
});
|
|
376
395
|
const subdirs = [];
|
|
377
396
|
for (const [full, stats] of children) {
|
|
378
397
|
if (!stats) {
|
|
@@ -394,6 +413,7 @@ export class LocalEnv {
|
|
|
394
413
|
};
|
|
395
414
|
let frontier = root.isDirectory() ? [target] : [];
|
|
396
415
|
while (frontier.length > 0) {
|
|
416
|
+
checkSignal(this.signal);
|
|
397
417
|
const next = [];
|
|
398
418
|
for (let index = 0; index < frontier.length; index += WALK_BATCH) {
|
|
399
419
|
const found = await Promise.all(
|
|
@@ -444,6 +464,10 @@ export class LocalEnv {
|
|
|
444
464
|
return results;
|
|
445
465
|
}
|
|
446
466
|
|
|
467
|
+
indexedDirs(roots, globs, maxDepth) {
|
|
468
|
+
return indexedDirectories(this, roots, globs, maxDepth);
|
|
469
|
+
}
|
|
470
|
+
|
|
447
471
|
/**
|
|
448
472
|
* Finds regular files whose basename matches one of `names`.
|
|
449
473
|
* @returns {Promise<string[]>}
|
|
@@ -459,7 +483,14 @@ export class LocalEnv {
|
|
|
459
483
|
results.push(entry.path);
|
|
460
484
|
}
|
|
461
485
|
}
|
|
462
|
-
return
|
|
486
|
+
return new Set([
|
|
487
|
+
'node_modules',
|
|
488
|
+
'target',
|
|
489
|
+
'vendor',
|
|
490
|
+
'.venv',
|
|
491
|
+
'venv',
|
|
492
|
+
...this.skipNames,
|
|
493
|
+
]);
|
|
463
494
|
});
|
|
464
495
|
return results;
|
|
465
496
|
}
|
|
@@ -471,54 +502,214 @@ export class LocalEnv {
|
|
|
471
502
|
async #walk(roots, maxDepth, skipNames, visit) {
|
|
472
503
|
const skip = skipNames ? new Set(skipNames) : this.skipNames;
|
|
473
504
|
const visited = new Set();
|
|
474
|
-
|
|
505
|
+
let queue = await Promise.all(
|
|
475
506
|
roots.map(async (root) => [root, 0, await this.deviceId(root)])
|
|
476
507
|
);
|
|
477
508
|
while (queue.length > 0) {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
entries
|
|
509
|
+
checkSignal(this.signal);
|
|
510
|
+
const current = queue;
|
|
511
|
+
queue = [];
|
|
512
|
+
const children = await mapLimit(
|
|
513
|
+
current,
|
|
514
|
+
8,
|
|
515
|
+
async ([dir, depth, device]) => {
|
|
516
|
+
if (visited.has(dir)) {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
visited.add(dir);
|
|
520
|
+
const entries = [];
|
|
521
|
+
for (const entry of await this.discoveryList(dir)) {
|
|
522
|
+
if (
|
|
523
|
+
entry.type !== 'dir' ||
|
|
524
|
+
(entry.device ?? (await this.deviceId(entry.path))) === device
|
|
525
|
+
) {
|
|
526
|
+
entries.push(entry);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const pruned = visit(dir, entries) ?? new Set();
|
|
530
|
+
if (depth + 1 >= maxDepth) {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
const next = [];
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
if (
|
|
536
|
+
entry.type === 'dir' &&
|
|
537
|
+
!pruned.has(entry.name) &&
|
|
538
|
+
!this.skipDiscoveryDirectory(dir, entry.name, skip) &&
|
|
539
|
+
(entry.device ?? (await this.deviceId(entry.path))) === device
|
|
540
|
+
) {
|
|
541
|
+
next.push([entry.path, depth + 1, device]);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return next;
|
|
490
545
|
}
|
|
546
|
+
);
|
|
547
|
+
queue = children.flat();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
skipDiscoveryDirectory(dir, name, skip = this.skipNames) {
|
|
552
|
+
if (skip.has(name) || /\.photoslibrary$/i.test(name)) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
const homes = this.options.homes ?? [this.currentHome];
|
|
556
|
+
return (
|
|
557
|
+
(['Music', 'Pictures', 'Movies'].includes(name) && homes.includes(dir)) ||
|
|
558
|
+
(name === 'Volumes' && dir === this.path.parse(dir).root)
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async discoveryStat(dir) {
|
|
563
|
+
let stats = await this.stat(dir);
|
|
564
|
+
// Explicit roots may be aliases (for example /tmp on macOS). Child links
|
|
565
|
+
// are never queued by the walker, so only an explicitly supplied root follows.
|
|
566
|
+
if (stats?.type === 'symlink') {
|
|
567
|
+
const resolved = await this.realPath(dir);
|
|
568
|
+
stats = resolved ? await this.stat(resolved) : null;
|
|
569
|
+
}
|
|
570
|
+
return stats;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async discoveryList(dir) {
|
|
574
|
+
checkSignal(this.signal);
|
|
575
|
+
const stats = await this.discoveryStat(dir);
|
|
576
|
+
if (!stats || stats.type !== 'dir') {
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
const key = `list:${dir}`;
|
|
580
|
+
const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
|
|
581
|
+
let entries = this.scanIndex?.get(key, fingerprint);
|
|
582
|
+
if (entries) {
|
|
583
|
+
this.scanMetrics.indexHits++;
|
|
584
|
+
} else {
|
|
585
|
+
const errors = this.accessErrors?.size ?? 0;
|
|
586
|
+
entries = await this.list(dir, { discovery: true });
|
|
587
|
+
if ((this.accessErrors?.size ?? 0) === errors) {
|
|
588
|
+
this.scanIndex?.set(key, fingerprint, entries);
|
|
491
589
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
590
|
+
}
|
|
591
|
+
// Stored metadata is a discovery hint; never accept external paths from it.
|
|
592
|
+
const listing = entries
|
|
593
|
+
.filter(
|
|
594
|
+
(entry) => entry.name && this.path.basename(entry.name) === entry.name
|
|
595
|
+
)
|
|
596
|
+
.map((entry) => ({ ...entry, path: this.path.join(dir, entry.name) }))
|
|
597
|
+
.filter(
|
|
598
|
+
(entry) => !this.scanPolicy || this.scanPolicy.allows(entry.path)
|
|
599
|
+
);
|
|
600
|
+
// Mounts and link replacements need not change the parent's mtime.
|
|
601
|
+
return (
|
|
602
|
+
await mapLimit(listing, 32, async (entry) => {
|
|
603
|
+
if (entry.type !== 'dir') {
|
|
604
|
+
return entry;
|
|
504
605
|
}
|
|
505
|
-
|
|
606
|
+
const fresh = await this.stat(entry.path);
|
|
607
|
+
return fresh ? { ...entry, ...fresh } : null;
|
|
608
|
+
})
|
|
609
|
+
).filter(Boolean);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Source activity excludes generated trees, and stops as soon as recent work is seen. */
|
|
613
|
+
async projectActivity(project, globs, since) {
|
|
614
|
+
let newest = 0;
|
|
615
|
+
let frontier = [project];
|
|
616
|
+
const device = await this.deviceId(project);
|
|
617
|
+
while (frontier.length && newest < since) {
|
|
618
|
+
const children = await mapLimit(frontier, 8, async (dir) => {
|
|
619
|
+
checkSignal(this.signal);
|
|
620
|
+
const next = [];
|
|
621
|
+
for (const entry of await this.list(dir)) {
|
|
622
|
+
if (entry.type === 'dir') {
|
|
623
|
+
if (
|
|
624
|
+
!this.skipDiscoveryDirectory(dir, entry.name) &&
|
|
625
|
+
!globs.some((glob) => matchesGlob(entry.name, glob)) &&
|
|
626
|
+
entry.device === device
|
|
627
|
+
) {
|
|
628
|
+
next.push(entry.path);
|
|
629
|
+
}
|
|
630
|
+
} else if (entry.type === 'file') {
|
|
631
|
+
newest = Math.max(newest, entry.mtimeMs);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return next;
|
|
635
|
+
});
|
|
636
|
+
frontier = children.flat();
|
|
506
637
|
}
|
|
638
|
+
return newest;
|
|
507
639
|
}
|
|
508
640
|
|
|
509
|
-
async usageMany(targets) {
|
|
510
|
-
const usages = await this.rawUsageMany(targets);
|
|
641
|
+
async usageMany(targets, options) {
|
|
642
|
+
const usages = await this.rawUsageMany(targets, options);
|
|
511
643
|
return this.writableLayer
|
|
512
644
|
? this.writableLayer.measure(this, usages)
|
|
513
645
|
: usages;
|
|
514
646
|
}
|
|
515
647
|
|
|
516
|
-
async rawUsageMany(targets) {
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
648
|
+
async rawUsageMany(targets, { mtime = true, cache = false } = {}) {
|
|
649
|
+
const entries = await mapLimit([...new Set(targets)], 4, async (target) => [
|
|
650
|
+
target,
|
|
651
|
+
!this.writableLayer && cache
|
|
652
|
+
? await this.cachedUsage(target, mtime)
|
|
653
|
+
: !mtime && !this.writableLayer
|
|
654
|
+
? await this.quickUsage(target)
|
|
655
|
+
: await this.rawUsage(target),
|
|
656
|
+
]);
|
|
657
|
+
return new Map(entries);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async cachedUsage(target, mtime) {
|
|
661
|
+
const stats = await this.stat(target);
|
|
662
|
+
if (!stats) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
const key = `usage:${mtime}:${target}`;
|
|
666
|
+
const fingerprint = `${directoryFingerprint(stats)}:${JSON.stringify([this.scanPolicy?.exclude, this.scanPolicy?.pruned])}`;
|
|
667
|
+
const cached = this.scanIndex?.get(key, fingerprint, SIZE_MAX_AGE);
|
|
668
|
+
if (cached) {
|
|
669
|
+
this.scanMetrics.sizeCacheHits++;
|
|
670
|
+
return { ...cached, estimated: true };
|
|
520
671
|
}
|
|
521
|
-
|
|
672
|
+
const usage = mtime
|
|
673
|
+
? await this.rawUsage(target)
|
|
674
|
+
: await this.quickUsage(target);
|
|
675
|
+
if (usage && !usage.errors) {
|
|
676
|
+
this.scanIndex?.set(key, fingerprint, usage);
|
|
677
|
+
}
|
|
678
|
+
return usage;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async quickUsage(target) {
|
|
682
|
+
const stats = await this.stat(target);
|
|
683
|
+
if (!stats) {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
// du cannot enforce path-glob exclusions or detect a nested mount beneath a candidate.
|
|
687
|
+
if (
|
|
688
|
+
this.platform === 'win32' ||
|
|
689
|
+
this.scanPolicy?.exclude.length ||
|
|
690
|
+
this.scanPolicy?.mounts.some(
|
|
691
|
+
(mount) =>
|
|
692
|
+
mount.path !== '/' && mount.path.startsWith(`${target}${path.sep}`)
|
|
693
|
+
)
|
|
694
|
+
) {
|
|
695
|
+
return this.rawUsage(target);
|
|
696
|
+
}
|
|
697
|
+
const result = await this.run(['du', '-skx', target]);
|
|
698
|
+
const match = /^\s*(\d+)\s/.exec(result.stdout);
|
|
699
|
+
if (result.code !== 0 || !match) {
|
|
700
|
+
return this.rawUsage(target);
|
|
701
|
+
}
|
|
702
|
+
const usage = {
|
|
703
|
+
bytes: Number(match[1]) * 1024,
|
|
704
|
+
apparentBytes: Number(match[1]) * 1024,
|
|
705
|
+
newestMtimeMs: 0,
|
|
706
|
+
files: 0,
|
|
707
|
+
errors: 0,
|
|
708
|
+
};
|
|
709
|
+
if (this.scanMetrics) {
|
|
710
|
+
this.scanMetrics.directoriesSized++;
|
|
711
|
+
}
|
|
712
|
+
return usage;
|
|
522
713
|
}
|
|
523
714
|
|
|
524
715
|
async readLink(target) {
|
|
@@ -613,11 +804,15 @@ export class LocalEnv {
|
|
|
613
804
|
|
|
614
805
|
async #linuxProcesses() {
|
|
615
806
|
const io = this.options.procFs ?? fsp;
|
|
807
|
+
this.mountNamespace = await io
|
|
808
|
+
.readlink('/proc/self/ns/mnt')
|
|
809
|
+
.catch(() => null);
|
|
616
810
|
const pids = (await io.readdir('/proc')).filter((name) =>
|
|
617
811
|
/^\d+$/.test(name)
|
|
618
812
|
);
|
|
619
813
|
const processes = [];
|
|
620
814
|
for (const pid of pids) {
|
|
815
|
+
checkSignal(this.signal);
|
|
621
816
|
const base = `/proc/${pid}`;
|
|
622
817
|
const stat = parseProcStat(
|
|
623
818
|
await io.readFile(`${base}/stat`, 'utf8').catch(() => '')
|
|
@@ -641,6 +836,7 @@ export class LocalEnv {
|
|
|
641
836
|
...(await procIdentity(base, io)),
|
|
642
837
|
name,
|
|
643
838
|
startTime: stat.startTime,
|
|
839
|
+
mountNamespace: await io.readlink(`${base}/ns/mnt`).catch(() => null),
|
|
644
840
|
cwd,
|
|
645
841
|
aliases: processAliases(exe, cmdline.split('\0')[0]),
|
|
646
842
|
command: cmdline.replaceAll('\0', ' '),
|
|
@@ -658,29 +854,8 @@ export class LocalEnv {
|
|
|
658
854
|
async openPaths({ strict = true } = {}) {
|
|
659
855
|
try {
|
|
660
856
|
let paths = await this.rawOpenPaths({ strict });
|
|
661
|
-
if (strict
|
|
662
|
-
|
|
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
|
-
}
|
|
857
|
+
if (strict) {
|
|
858
|
+
paths = await this.#completeInspection(paths);
|
|
684
859
|
}
|
|
685
860
|
this.#processHint();
|
|
686
861
|
return paths;
|
|
@@ -688,6 +863,9 @@ export class LocalEnv {
|
|
|
688
863
|
if (this.processInspector) {
|
|
689
864
|
const paths = await this.processInspector.openPaths();
|
|
690
865
|
this.probeDiagnostic = this.processInspector.probeDiagnostic;
|
|
866
|
+
this.unreadableProcesses =
|
|
867
|
+
this.processInspector.unreadableProcesses ?? [];
|
|
868
|
+
this.#processHint();
|
|
691
869
|
return paths;
|
|
692
870
|
}
|
|
693
871
|
this.probeDiagnostic = error.message;
|
|
@@ -695,16 +873,56 @@ export class LocalEnv {
|
|
|
695
873
|
}
|
|
696
874
|
}
|
|
697
875
|
|
|
876
|
+
async #completeInspection(paths) {
|
|
877
|
+
paths = await this.#inspectOwners(paths);
|
|
878
|
+
if (this.unreadableProcesses?.length && this.processInspector) {
|
|
879
|
+
const privileged = await this.processInspector.openPaths();
|
|
880
|
+
if (privileged) {
|
|
881
|
+
this.unreadableProcesses =
|
|
882
|
+
this.processInspector.unreadableProcesses ?? [];
|
|
883
|
+
paths = new Set([...(paths ?? []), ...privileged]);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (this.unreadableProcesses?.length) {
|
|
887
|
+
const { OPEN_PATHS_SCRIPT } = await import('./shell.js');
|
|
888
|
+
const elevated = await this.run([
|
|
889
|
+
'sudo',
|
|
890
|
+
'-n',
|
|
891
|
+
'sh',
|
|
892
|
+
'-c',
|
|
893
|
+
OPEN_PATHS_SCRIPT,
|
|
894
|
+
]);
|
|
895
|
+
if (elevated.code === 0) {
|
|
896
|
+
paths = new Set(
|
|
897
|
+
elevated.stdout.split('\n').filter((line) => line.startsWith('/'))
|
|
898
|
+
);
|
|
899
|
+
this.unreadableProcesses = [];
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return paths;
|
|
903
|
+
}
|
|
904
|
+
|
|
698
905
|
#processHint() {
|
|
699
|
-
this.probeHint = this.unreadableProcesses
|
|
700
|
-
|
|
701
|
-
|
|
906
|
+
this.probeHint = inspectionHint(this.unreadableProcesses ?? []);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async #inspectOwners(paths) {
|
|
910
|
+
if (!this.unreadableProcesses?.length || !(await this.isRoot())) {
|
|
911
|
+
return paths;
|
|
912
|
+
}
|
|
913
|
+
const evidence = await retryProcessOwners(this.executor, {
|
|
914
|
+
paths,
|
|
915
|
+
unreadable: this.unreadableProcesses,
|
|
916
|
+
});
|
|
917
|
+
this.unreadableProcesses = evidence.unreadable;
|
|
918
|
+
return evidence.paths;
|
|
702
919
|
}
|
|
703
920
|
|
|
704
921
|
async rawOpenPaths({ strict = true } = {}) {
|
|
705
922
|
if (this.platform === 'linux') {
|
|
706
923
|
const { paths, unreadable } = await procOpenPaths(
|
|
707
|
-
this.options.procFs ?? fsp
|
|
924
|
+
this.options.procFs ?? fsp,
|
|
925
|
+
this.signal
|
|
708
926
|
);
|
|
709
927
|
this.unreadableProcesses = strict ? unreadable : [];
|
|
710
928
|
return paths;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Finite worker pool; settle all in-flight work before propagating failure. */
|
|
2
|
+
export async function mapLimit(values, limit, operation) {
|
|
3
|
+
const results = new Array(values.length);
|
|
4
|
+
let cursor = 0;
|
|
5
|
+
let failure;
|
|
6
|
+
const worker = async () => {
|
|
7
|
+
while (!failure && cursor < values.length) {
|
|
8
|
+
const index = cursor++;
|
|
9
|
+
try {
|
|
10
|
+
results[index] = await operation(values[index], index);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
failure ??= error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
await Promise.all(
|
|
17
|
+
Array.from({ length: Math.min(limit, values.length) }, worker)
|
|
18
|
+
);
|
|
19
|
+
if (failure) {
|
|
20
|
+
throw failure;
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** OS index hits are hints; stale/missing indexes always fall back to discovery. */
|
|
2
|
+
import { isWithin, matchesGlob } from '../paths.js';
|
|
3
|
+
import { mapLimit } from './parallel.js';
|
|
4
|
+
|
|
5
|
+
export async function indexedDirectories(env, roots, globs, maxDepth) {
|
|
6
|
+
if (!env.scanIndex?.directory || env.platformIndex === false) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
let hits = [];
|
|
10
|
+
try {
|
|
11
|
+
if (env.platform === 'darwin' && (await env.which('mdfind'))) {
|
|
12
|
+
const query = globs
|
|
13
|
+
.map((glob) => `kMDItemFSName == "${glob.replaceAll('"', '\\"')}"`)
|
|
14
|
+
.join(' || ');
|
|
15
|
+
for (const root of roots) {
|
|
16
|
+
const result = await env.run(['mdfind', '-0', '-onlyin', root, query]);
|
|
17
|
+
if (result.code === 0) {
|
|
18
|
+
hits.push(...result.stdout.split('\0').filter(Boolean));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
} else if (env.platform === 'linux') {
|
|
22
|
+
const command = (await env.which('plocate'))
|
|
23
|
+
? 'plocate'
|
|
24
|
+
: (await env.which('locate'))
|
|
25
|
+
? 'locate'
|
|
26
|
+
: null;
|
|
27
|
+
if (command) {
|
|
28
|
+
const result = await env.run([command, '-0', '-e', '-b', ...globs]);
|
|
29
|
+
if (result.code === 0) {
|
|
30
|
+
hits = result.stdout.split('\0').filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Budget/cancellation will be observed by the fallback walk too.
|
|
36
|
+
}
|
|
37
|
+
hits = [...new Set(hits)]
|
|
38
|
+
.filter((target) => {
|
|
39
|
+
const root = roots.find((candidate) =>
|
|
40
|
+
isWithin(target, candidate, env.path)
|
|
41
|
+
);
|
|
42
|
+
if (
|
|
43
|
+
!root ||
|
|
44
|
+
!env.path.isAbsolute(target) ||
|
|
45
|
+
!globs.some((glob) => matchesGlob(env.path.basename(target), glob))
|
|
46
|
+
) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const parts = env.path.relative(root, target).split(/[\\/]/);
|
|
50
|
+
return (
|
|
51
|
+
parts.length <= maxDepth &&
|
|
52
|
+
!parts
|
|
53
|
+
.slice(0, -1)
|
|
54
|
+
.some(
|
|
55
|
+
(name, index) =>
|
|
56
|
+
env.skipDiscoveryDirectory(
|
|
57
|
+
env.path.join(root, ...parts.slice(0, index)),
|
|
58
|
+
name
|
|
59
|
+
) || globs.some((glob) => matchesGlob(name, glob))
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
})
|
|
63
|
+
.slice(0, 20000);
|
|
64
|
+
const candidates = await mapLimit(hits, 8, async (target) => {
|
|
65
|
+
const stat = await env.stat(target);
|
|
66
|
+
const root = roots.find((candidate) =>
|
|
67
|
+
isWithin(target, candidate, env.path)
|
|
68
|
+
);
|
|
69
|
+
if (stat?.type !== 'dir' || stat.device !== (await env.deviceId(root))) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
let parent = env.path.dirname(target);
|
|
73
|
+
while (parent !== root && isWithin(parent, root, env.path)) {
|
|
74
|
+
const ancestor = await env.stat(parent);
|
|
75
|
+
if (ancestor?.type !== 'dir' || ancestor.device !== stat.device) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
parent = env.path.dirname(parent);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
path: target,
|
|
82
|
+
name: env.path.basename(target),
|
|
83
|
+
parent: env.path.dirname(target),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
return candidates.filter(Boolean);
|
|
87
|
+
}
|