disk-space-saviour 0.19.0 → 0.21.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 +30 -0
- package/README.md +47 -16
- package/package.json +1 -1
- package/src/clean.js +21 -1
- package/src/cli.js +2 -1
- package/src/env/local.js +21 -1
- package/src/git-remote.js +168 -0
- package/src/git.js +147 -6
- package/src/index.d.ts +11 -1
- package/src/liveness.js +46 -0
- package/src/playwright-registry.js +77 -0
- package/src/report.js +29 -4
- package/src/rules/apps.js +3 -2
- package/src/rules/downloads.js +21 -9
- package/src/rules/other.js +56 -10
- package/src/rules/versions.js +4 -0
- package/src/scan.js +46 -8
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +115 -12
- package/src/scanners/versions.js +53 -7
- package/src/swap.js +286 -28
package/src/liveness.js
CHANGED
|
@@ -212,6 +212,7 @@ export class LivenessProbe {
|
|
|
212
212
|
this.realPaths = new Map();
|
|
213
213
|
this.pathOwners = new Map();
|
|
214
214
|
this.pathStats = new Map();
|
|
215
|
+
this.inodes = new Map();
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
async refresh(force = false) {
|
|
@@ -230,6 +231,50 @@ export class LivenessProbe {
|
|
|
230
231
|
this.realPaths.clear();
|
|
231
232
|
this.pathStats.clear();
|
|
232
233
|
this.pathOwners.clear();
|
|
234
|
+
this.inodes.clear();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Inodes of `executables` and of the open files that share their names,
|
|
239
|
+
* because a process running a hard link of an executable reports
|
|
240
|
+
* whichever path it was started from.
|
|
241
|
+
* @param {string[]} [executables]
|
|
242
|
+
*/
|
|
243
|
+
async resolveExecutables(executables = []) {
|
|
244
|
+
if (executables.length === 0) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const names = new Set(
|
|
248
|
+
executables.map((target) => this.env.path.basename(target))
|
|
249
|
+
);
|
|
250
|
+
const missing = [
|
|
251
|
+
...executables,
|
|
252
|
+
...[...(this.openPaths ?? [])].filter((open) =>
|
|
253
|
+
names.has(this.env.path.basename(open))
|
|
254
|
+
),
|
|
255
|
+
].filter((target) => !this.inodes.has(target));
|
|
256
|
+
const stats = await Promise.all(
|
|
257
|
+
missing.map((target) => this.env.stat(target))
|
|
258
|
+
);
|
|
259
|
+
missing.forEach((target, index) =>
|
|
260
|
+
this.inodes.set(target, stats[index]?.inode ?? null)
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A process running one of `executables` under any of its links. */
|
|
265
|
+
executableUsage(executables = []) {
|
|
266
|
+
for (const target of executables) {
|
|
267
|
+
const inode = this.inodes.get(target);
|
|
268
|
+
if (!inode) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const open of this.openPaths ?? []) {
|
|
272
|
+
if (open !== target && this.inodes.get(open) === inode) {
|
|
273
|
+
return `in use: ${open} is ${target}`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
233
278
|
}
|
|
234
279
|
|
|
235
280
|
/**
|
|
@@ -511,6 +556,7 @@ export class LivenessProbe {
|
|
|
511
556
|
(checks.mtime === false ? null : this.recentWrite(item.newestMtimeMs)) ??
|
|
512
557
|
this.runningTool(checks.busy, checks.cwd, item) ??
|
|
513
558
|
this.processUsage(paths) ??
|
|
559
|
+
this.executableUsage(checks.executables) ??
|
|
514
560
|
this.uncertainUsage(item.paths ?? [])
|
|
515
561
|
);
|
|
516
562
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser revisions that installed Playwright packages still launch.
|
|
3
|
+
*
|
|
4
|
+
* Playwright records every package it installed browsers for as a file in
|
|
5
|
+
* `<registry>/.links/` holding that package's directory, and the package's
|
|
6
|
+
* `browsers.json` names the revisions it launches. Playwright's own cleanup
|
|
7
|
+
* keeps a revision while a linked package that still exists names it; dss
|
|
8
|
+
* keeps the same revisions, so deleting one never breaks an installed
|
|
9
|
+
* project until its next `playwright install`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Directory of a registry revision: `chromium-headless-shell` → `chromium_headless_shell-1248`. */
|
|
13
|
+
export function browserDirName(name, revision) {
|
|
14
|
+
return `${name.replace(/-/g, '_')}-${revision}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Revision directories one `browsers.json` names, platform overrides included. */
|
|
18
|
+
export function requiredDirs(text) {
|
|
19
|
+
const browsers = JSON.parse(text)?.browsers;
|
|
20
|
+
if (!Array.isArray(browsers)) {
|
|
21
|
+
throw new Error('no browsers list');
|
|
22
|
+
}
|
|
23
|
+
return browsers.flatMap((browser) =>
|
|
24
|
+
[browser.revision, ...Object.values(browser.revisionOverrides ?? {})]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((revision) => browserDirName(browser.name, revision))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Maps each revision directory name to the linked packages that need it;
|
|
32
|
+
* `unreadable` lists links whose package exists but cannot be read.
|
|
33
|
+
*/
|
|
34
|
+
export async function playwrightUsers(env, registry) {
|
|
35
|
+
const users = new Map();
|
|
36
|
+
const unreadable = [];
|
|
37
|
+
for (const link of await env.list(env.path.join(registry, '.links'))) {
|
|
38
|
+
const pkg = (await env.readText(link.path))?.trim();
|
|
39
|
+
if (!pkg) {
|
|
40
|
+
unreadable.push(link.path);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const manifest = env.path.join(pkg, 'browsers.json');
|
|
44
|
+
if (!(await env.exists(manifest))) {
|
|
45
|
+
// The project or its node_modules is gone; Playwright drops the link too.
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
for (const dir of requiredDirs((await env.readText(manifest)) ?? '')) {
|
|
50
|
+
users.set(dir, [...(users.get(dir) ?? []), pkg]);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
unreadable.push(manifest);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { users, unreadable };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Why `target`, a revision directory in a Playwright registry, must stay;
|
|
61
|
+
* null when no installed package needs it.
|
|
62
|
+
* @param {Map<string, Promise<object>>} [cache] registry → playwrightUsers
|
|
63
|
+
*/
|
|
64
|
+
export async function playwrightBlocker(env, target, cache = new Map()) {
|
|
65
|
+
const registry = env.path.dirname(target);
|
|
66
|
+
if (!cache.has(registry)) {
|
|
67
|
+
cache.set(registry, playwrightUsers(env, registry));
|
|
68
|
+
}
|
|
69
|
+
const { users, unreadable } = await cache.get(registry);
|
|
70
|
+
if (unreadable.length > 0) {
|
|
71
|
+
return `cannot read Playwright install record ${unreadable[0]}`;
|
|
72
|
+
}
|
|
73
|
+
const needed = users.get(env.path.basename(target));
|
|
74
|
+
return needed
|
|
75
|
+
? `Playwright in ${needed[0]} still uses ${env.path.basename(target)}`
|
|
76
|
+
: null;
|
|
77
|
+
}
|
package/src/report.js
CHANGED
|
@@ -82,7 +82,14 @@ function tierSection(report, tier, options) {
|
|
|
82
82
|
'',
|
|
83
83
|
`${tier.toUpperCase()} ${plural(items.length, 'item')}, ${formatBytes(bytes)}`,
|
|
84
84
|
...limited(
|
|
85
|
-
items.
|
|
85
|
+
items.flatMap((item) => [
|
|
86
|
+
itemLine(item, showEnv),
|
|
87
|
+
...(item.globalPackages?.length
|
|
88
|
+
? [
|
|
89
|
+
` also removes global packages: ${item.globalPackages.join(', ')}`,
|
|
90
|
+
]
|
|
91
|
+
: []),
|
|
92
|
+
]),
|
|
86
93
|
options.limit
|
|
87
94
|
),
|
|
88
95
|
];
|
|
@@ -351,6 +358,23 @@ function timingLines(report, { verbose }) {
|
|
|
351
358
|
});
|
|
352
359
|
}
|
|
353
360
|
|
|
361
|
+
function holderLine(holder) {
|
|
362
|
+
const details = [`${formatBytes(holder.memoryBytes)} memory`];
|
|
363
|
+
if (holder.compressedBytes !== undefined) {
|
|
364
|
+
details.push(`${formatBytes(holder.compressedBytes)} compressed`);
|
|
365
|
+
}
|
|
366
|
+
if (holder.swappedBytes !== undefined) {
|
|
367
|
+
details.push(`${formatBytes(holder.swappedBytes)} swapped`);
|
|
368
|
+
}
|
|
369
|
+
const change = holder.growth?.memoryBytes;
|
|
370
|
+
if (change) {
|
|
371
|
+
details.push(
|
|
372
|
+
`${change > 0 ? '+' : '-'}${formatBytes(Math.abs(change))} since ${new Date(holder.growth.since).toISOString()}`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return `${holder.pid} ${holder.command}: ${details.join(', ')}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
354
378
|
/**
|
|
355
379
|
* Renders a scan report.
|
|
356
380
|
* @param {object} report
|
|
@@ -368,11 +392,12 @@ export function formatReport(report, options = {}) {
|
|
|
368
392
|
lines.push(
|
|
369
393
|
`Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
|
|
370
394
|
);
|
|
395
|
+
for (const warning of host.swap.warnings ?? []) {
|
|
396
|
+
lines.push(`WARNING: ${warning}`);
|
|
397
|
+
}
|
|
371
398
|
lines.push(host.swap.attribution);
|
|
372
399
|
for (const holder of host.swap.holders) {
|
|
373
|
-
lines.push(
|
|
374
|
-
` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
|
|
375
|
-
);
|
|
400
|
+
lines.push(` ${holderLine(holder)}`);
|
|
376
401
|
}
|
|
377
402
|
lines.push(host.swap.advice);
|
|
378
403
|
}
|
package/src/rules/apps.js
CHANGED
|
@@ -36,13 +36,14 @@ export const APP_RULES = [
|
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
id: 'telegram-downloaded-media',
|
|
39
|
-
excludeNames: ['cache', 'cache-storage', 'short-cache'],
|
|
40
39
|
ecosystem: 'apps',
|
|
41
40
|
tier: 'moderate',
|
|
42
41
|
description:
|
|
43
42
|
'Telegram downloaded media: secret-chat media may not be recoverable',
|
|
43
|
+
// Only media Telegram downloads again from its cloud; `storage` holds
|
|
44
|
+
// the media database and `local-file-*` files the user sent.
|
|
44
45
|
paths: [
|
|
45
|
-
`${telegramAccounts}
|
|
46
|
+
`${telegramAccounts}/telegram-cloud-*`,
|
|
46
47
|
...telegramDesktop.map((root) => `${root}/user_data/downloads`),
|
|
47
48
|
],
|
|
48
49
|
busy: ['Telegram', 'telegram-desktop'],
|
package/src/rules/downloads.js
CHANGED
|
@@ -20,10 +20,12 @@ export const DOWNLOAD_RULES = [
|
|
|
20
20
|
description:
|
|
21
21
|
'Chromium app bundle clones left by exited browsers (an update makes them full copies)',
|
|
22
22
|
// Chrome clones its bundle into the per-user `X` directory next to
|
|
23
|
-
// `$TMPDIR` at launch and removes the clone when it exits. A
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// `$TMPDIR` at launch and removes the clone when it exits. A clone
|
|
24
|
+
// stays while any process runs its hard-linked main executable (see
|
|
25
|
+
// ../scanners/app-clones.js): a browser name would block orphans
|
|
26
|
+
// forever, and open files miss a browser started before the update.
|
|
26
27
|
paths: ['{TMP}/../X/*.code_sign_clone/code_sign_clone.*'],
|
|
28
|
+
appClone: { installed: ['/Applications', '~/Applications'] },
|
|
27
29
|
directoryOnly: true,
|
|
28
30
|
minAge: 'stale',
|
|
29
31
|
busy: [],
|
|
@@ -31,13 +33,23 @@ export const DOWNLOAD_RULES = [
|
|
|
31
33
|
{
|
|
32
34
|
id: 'chrome-on-device-models',
|
|
33
35
|
ecosystem: 'browsers',
|
|
34
|
-
description:
|
|
36
|
+
description:
|
|
37
|
+
'Chromium on-device AI models, downloaded again when a page uses built-in AI',
|
|
38
|
+
// Every installed version matches: the component updater fetches the
|
|
39
|
+
// model again on demand. A loaded model is held open and a download in
|
|
40
|
+
// progress writes to its directory, which the open-file check and the
|
|
41
|
+
// activity window catch.
|
|
35
42
|
paths: [
|
|
36
|
-
'~/Library/Application Support/Google/Chrome
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
'~/Library/Application Support/Google/Chrome*',
|
|
44
|
+
'~/Library/Application Support/Chromium',
|
|
45
|
+
'~/Library/Application Support/Microsoft Edge*',
|
|
46
|
+
'~/.config/google-chrome*',
|
|
47
|
+
'~/.config/chromium',
|
|
48
|
+
'{LOCALAPPDATA}/Google/Chrome*/User Data',
|
|
49
|
+
'{LOCALAPPDATA}/Chromium/User Data',
|
|
50
|
+
'{LOCALAPPDATA}/Microsoft/Edge*/User Data',
|
|
51
|
+
].map((root) => `${root}/OptGuideOnDevice*Model/*`),
|
|
52
|
+
directoryOnly: true,
|
|
41
53
|
minAge: 'stale',
|
|
42
54
|
busy: [],
|
|
43
55
|
},
|
package/src/rules/other.js
CHANGED
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* otherwise each match is its own item. `minAge: 'stale'` only reports
|
|
13
13
|
* matches untouched for the stale-age window, `minAge: 'inactive'` for the
|
|
14
14
|
* project inactivity window. `git: true` refuses matches that are Git work
|
|
15
|
-
* trees with uncommitted, unpushed, or stashed changes.
|
|
15
|
+
* trees with uncommitted, unpushed, or stashed changes. `git: 'clone'`
|
|
16
|
+
* matches only Git work trees with an https or ssh remote, and refuses them
|
|
17
|
+
* unless every worktree is clean and the remote still advertises every
|
|
18
|
+
* local commit. `registry: 'playwright'` keeps revisions that a Playwright
|
|
19
|
+
* package recorded in the registry's `.links` still launches.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
export const OTHER_RULES = [
|
|
@@ -29,15 +33,10 @@ export const OTHER_RULES = [
|
|
|
29
33
|
versionPattern: /^(?<family>[a-z_]+)-(?<version>\d+)$/,
|
|
30
34
|
minAge: 'inactive',
|
|
31
35
|
tier: 'safe',
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'chromium',
|
|
37
|
-
'headless_shell',
|
|
38
|
-
'firefox',
|
|
39
|
-
'webkit',
|
|
40
|
-
],
|
|
36
|
+
registry: 'playwright',
|
|
37
|
+
// A running Playwright launches the revisions its package names, which
|
|
38
|
+
// the registry check keeps; a revision a browser runs from is open.
|
|
39
|
+
busy: [],
|
|
41
40
|
},
|
|
42
41
|
{
|
|
43
42
|
id: 'playwright-go-drivers',
|
|
@@ -519,6 +518,23 @@ export const OTHER_RULES = [
|
|
|
519
518
|
minAge: 'stale',
|
|
520
519
|
busy: ['chrome', 'chromium', 'headless_shell'],
|
|
521
520
|
},
|
|
521
|
+
{
|
|
522
|
+
id: 'tmp-playwright-browsers',
|
|
523
|
+
ecosystem: 'tmp',
|
|
524
|
+
scope: 'tmp',
|
|
525
|
+
description:
|
|
526
|
+
'Playwright browser installs and downloads in temporary directories',
|
|
527
|
+
paths: [
|
|
528
|
+
'{TMP}/playwright-download-*',
|
|
529
|
+
'{TMP}/ms-playwright',
|
|
530
|
+
'{TMP}/ms-playwright-*',
|
|
531
|
+
'{TMP}/*/ms-playwright',
|
|
532
|
+
'{TMP}/*/.cache/ms-playwright',
|
|
533
|
+
'{TMP}/*/Library/Caches/ms-playwright',
|
|
534
|
+
],
|
|
535
|
+
minAge: 'stale',
|
|
536
|
+
busy: ['playwright', 'node', 'chrome', 'chromium', 'headless_shell'],
|
|
537
|
+
},
|
|
522
538
|
{
|
|
523
539
|
id: 'node-tmp-caches',
|
|
524
540
|
ecosystem: 'tmp',
|
|
@@ -533,3 +549,33 @@ export const OTHER_RULES = [
|
|
|
533
549
|
busy: ['node'],
|
|
534
550
|
},
|
|
535
551
|
];
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Checkouts cloned into temporary directories by tools, tests and agents,
|
|
555
|
+
* checked after every named rule so a more specific rule claims its own
|
|
556
|
+
* paths first.
|
|
557
|
+
*/
|
|
558
|
+
export const TEMP_CLONE_RULES = [
|
|
559
|
+
{
|
|
560
|
+
id: 'temp-git-clones',
|
|
561
|
+
ecosystem: 'tmp',
|
|
562
|
+
scope: 'tmp',
|
|
563
|
+
description:
|
|
564
|
+
'Git clones in temporary directories whose commits all exist on a remote',
|
|
565
|
+
tier: 'safe',
|
|
566
|
+
paths: ['{TMP}/*', '{TMP}/*/*'],
|
|
567
|
+
directoryOnly: true,
|
|
568
|
+
minAge: 'stale',
|
|
569
|
+
git: 'clone',
|
|
570
|
+
busy: [
|
|
571
|
+
'git',
|
|
572
|
+
'claude',
|
|
573
|
+
'codex',
|
|
574
|
+
'opencode',
|
|
575
|
+
'agent',
|
|
576
|
+
'node',
|
|
577
|
+
'bun',
|
|
578
|
+
'cargo',
|
|
579
|
+
],
|
|
580
|
+
},
|
|
581
|
+
];
|
package/src/rules/versions.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* matches (nvm runs the newest 20.x for `20`); otherwise it keeps them all.
|
|
11
11
|
* `globalPackages` blocks versions holding packages the user installed
|
|
12
12
|
* globally, which reinstalling the version would not bring back.
|
|
13
|
+
* `locks` names the lock file a manager writes for each running process of
|
|
14
|
+
* a version (`{version}` is replaced); a lock naming a live process, or one
|
|
15
|
+
* that cannot be read, keeps the version.
|
|
13
16
|
*
|
|
14
17
|
* `versionOf(dirPath)` maps an install directory to the version string that
|
|
15
18
|
* defaults and pin files are compared with (see `versionMatches`).
|
|
@@ -114,6 +117,7 @@ export const VERSION_RULES = [
|
|
|
114
117
|
dirs: ['~/.local/share/claude/versions/*'],
|
|
115
118
|
installTypes: ['file'],
|
|
116
119
|
defaultLinks: ['~/.local/bin/claude'],
|
|
120
|
+
locks: '~/.local/state/claude/locks/{version}.lock',
|
|
117
121
|
busy: ['claude'],
|
|
118
122
|
recheck: true,
|
|
119
123
|
},
|
package/src/scan.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
configureContainerHost,
|
|
8
8
|
resetWritableLayers,
|
|
9
9
|
} from './docker/writable.js';
|
|
10
|
+
import path from 'node:path';
|
|
10
11
|
import { setInterval, clearInterval } from 'node:timers';
|
|
11
12
|
import { scanDocker } from './docker/scan.js';
|
|
12
13
|
import { LocalEnv } from './env/local.js';
|
|
@@ -20,10 +21,11 @@ import { LivenessProbe } from './liveness.js';
|
|
|
20
21
|
import { resolveOptions } from './options.js';
|
|
21
22
|
import { existingPaths, isWithin, matchesExcluded } from './paths.js';
|
|
22
23
|
import { scanAgents } from './scanners/agents.js';
|
|
23
|
-
import { scanGlobal } from './scanners/global.js';
|
|
24
|
+
import { GLOBAL_RULES, scanGlobal, targetedRules } from './scanners/global.js';
|
|
24
25
|
import { scanProjects } from './scanners/projects.js';
|
|
25
26
|
import { scanSystem } from './scanners/system.js';
|
|
26
27
|
import { scanVersions } from './scanners/versions.js';
|
|
28
|
+
import { VERSION_RULES } from './rules/versions.js';
|
|
27
29
|
import { readSwap } from './swap.js';
|
|
28
30
|
import {
|
|
29
31
|
authenticateReport,
|
|
@@ -194,6 +196,17 @@ export async function environmentContext(env, options, overrides = {}) {
|
|
|
194
196
|
* @param {{roots?: string[]|null, errors?: object[]}} [extra]
|
|
195
197
|
* @returns {Promise<object[]>} items
|
|
196
198
|
*/
|
|
199
|
+
/** A run limited to global or version rules skips the other scanners. */
|
|
200
|
+
function selectedScanners(options) {
|
|
201
|
+
if (!targetedRules(options.only)) {
|
|
202
|
+
return options.scanners;
|
|
203
|
+
}
|
|
204
|
+
const rules = { global: GLOBAL_RULES, versions: VERSION_RULES };
|
|
205
|
+
return options.scanners.filter(
|
|
206
|
+
(name) => rules[name] && targetedRules(options.only, rules[name]).length > 0
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
197
210
|
export async function scanEnvironment(env, options, extra = {}) {
|
|
198
211
|
const controller = new AbortController();
|
|
199
212
|
const abort = () => controller.abort(options.signal.reason);
|
|
@@ -321,7 +334,7 @@ async function scanEnvironmentScoped(
|
|
|
321
334
|
};
|
|
322
335
|
context.ready = ready;
|
|
323
336
|
let incomplete = false;
|
|
324
|
-
for (const name of options
|
|
337
|
+
for (const name of selectedScanners(options)) {
|
|
325
338
|
checkSignal(env.signal);
|
|
326
339
|
context.scanner = name;
|
|
327
340
|
stage(name);
|
|
@@ -434,12 +447,33 @@ export async function selfContainerIds(env) {
|
|
|
434
447
|
return [...ids];
|
|
435
448
|
}
|
|
436
449
|
|
|
437
|
-
|
|
450
|
+
/**
|
|
451
|
+
* `only` entries name an ecosystem, rule, kind or item id, or a path or
|
|
452
|
+
* glob: an item matches it when each of its paths lies inside the path, or
|
|
453
|
+
* the path or one of its ancestors matches the glob.
|
|
454
|
+
*/
|
|
455
|
+
function matchesOnly(item, only, pathApi = path) {
|
|
438
456
|
return (
|
|
439
457
|
only.length === 0 ||
|
|
440
458
|
only.some(
|
|
441
459
|
(entry) =>
|
|
442
|
-
|
|
460
|
+
[item.ecosystem, item.rule, item.kind, item.id].includes(entry) ||
|
|
461
|
+
insideOnlyPath(item, entry, pathApi)
|
|
462
|
+
)
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function insideOnlyPath(item, entry, pathApi) {
|
|
467
|
+
const glob = /[*?[]/.test(entry);
|
|
468
|
+
if (!glob && !pathApi.isAbsolute(entry) && !path.win32.isAbsolute(entry)) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
return (
|
|
472
|
+
item.paths?.length > 0 &&
|
|
473
|
+
item.paths.every((target) =>
|
|
474
|
+
glob
|
|
475
|
+
? matchesExcluded(target, [entry], pathApi)
|
|
476
|
+
: isWithin(target, entry, pathApi)
|
|
443
477
|
)
|
|
444
478
|
);
|
|
445
479
|
}
|
|
@@ -476,7 +510,7 @@ export function filterItems(items, options, daemons = [], pathApi) {
|
|
|
476
510
|
item.sizeUnknown ||
|
|
477
511
|
item.imageBytes >= options.minSizeBytes ||
|
|
478
512
|
item.container?.pinnedImage?.bytes >= options.minSizeBytes) &&
|
|
479
|
-
matchesOnly(item, options.only) &&
|
|
513
|
+
matchesOnly(item, options.only, pathApi) &&
|
|
480
514
|
!excluded(item, options.exclude, pathApi) &&
|
|
481
515
|
!underDaemonRoot(item, daemons, pathApi)
|
|
482
516
|
);
|
|
@@ -496,13 +530,17 @@ function envDescriptor(env, depth, chain) {
|
|
|
496
530
|
};
|
|
497
531
|
}
|
|
498
532
|
|
|
499
|
-
async function withDisk(env, descriptor) {
|
|
533
|
+
async function withDisk(env, descriptor, options) {
|
|
500
534
|
const target = env.platform === 'win32' ? env.currentHome : '/';
|
|
501
535
|
const disk = await env.diskUsage(target).catch(() => null);
|
|
502
536
|
return {
|
|
503
537
|
...descriptor,
|
|
504
538
|
disk: disk ? { path: target, ...disk } : null,
|
|
505
|
-
swap: await readSwap(env
|
|
539
|
+
swap: await readSwap(env, {
|
|
540
|
+
stateDir: options.cacheDir,
|
|
541
|
+
disk,
|
|
542
|
+
now: options.now?.(),
|
|
543
|
+
}),
|
|
506
544
|
};
|
|
507
545
|
}
|
|
508
546
|
|
|
@@ -632,7 +670,7 @@ async function scanScoped(input) {
|
|
|
632
670
|
environments.push(inner);
|
|
633
671
|
}
|
|
634
672
|
environments[0] = await timed(env.timings, 'disk', () =>
|
|
635
|
-
withDisk(env, environments[0])
|
|
673
|
+
withDisk(env, environments[0], options)
|
|
636
674
|
);
|
|
637
675
|
environments[0].timings = env.timings.toJSON();
|
|
638
676
|
const report = buildReport({
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chromium browsers on macOS clone their app bundle at launch with APFS
|
|
3
|
+
* `clonefile` and replace the clone's main executable with a hard link to
|
|
4
|
+
* the installed one (chrome/browser/mac/code_sign_clone_manager.mm). The
|
|
5
|
+
* browser holds no file open inside its clone: it runs the shared inode,
|
|
6
|
+
* which `lsof` reports under the installed path until an update replaces
|
|
7
|
+
* the app. After an update the clone keeps the running browser's code
|
|
8
|
+
* signature valid, so a clone stays while any process runs its executable.
|
|
9
|
+
* Until then the clone shares every block with the installed app, and
|
|
10
|
+
* removing it frees nothing but directory entries.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const BUNDLE_SUFFIX = '.bundle';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Main executables of the bundle cloned in `clone`, and whether the clone
|
|
17
|
+
* still shares its blocks with an app installed in one of `appDirs`.
|
|
18
|
+
* @returns {Promise<{executables: string[], sharedWith: string|null}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function appCloneDetails(env, clone, appDirs) {
|
|
21
|
+
const executables = [];
|
|
22
|
+
let sharedWith = null;
|
|
23
|
+
for (const bundle of await env.list(clone)) {
|
|
24
|
+
if (
|
|
25
|
+
bundle.type !== 'dir' ||
|
|
26
|
+
!bundle.name.endsWith(`.app${BUNDLE_SUFFIX}`)
|
|
27
|
+
) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const appName = bundle.name.slice(0, -BUNDLE_SUFFIX.length);
|
|
31
|
+
const macos = env.path.join(bundle.path, 'Contents', 'MacOS');
|
|
32
|
+
for (const entry of await env.list(macos)) {
|
|
33
|
+
if (entry.type !== 'file') {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
executables.push(entry.path);
|
|
37
|
+
sharedWith ??= await installedCopy(env, entry, appName, appDirs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { executables, sharedWith };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function installedCopy(env, executable, appName, appDirs) {
|
|
44
|
+
const inode = (await env.stat(executable.path))?.inode;
|
|
45
|
+
for (const dir of appDirs) {
|
|
46
|
+
const app = env.path.join(dir, appName);
|
|
47
|
+
const installed = env.path.join(app, 'Contents', 'MacOS', executable.name);
|
|
48
|
+
if (inode && (await env.stat(installed))?.inode === inode) {
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
package/src/scanners/common.js
CHANGED
|
@@ -27,6 +27,7 @@ export async function listMany(env, dirs) {
|
|
|
27
27
|
*/
|
|
28
28
|
export async function scanTimeBusy(context, item) {
|
|
29
29
|
await resolvePaths(context, item.paths);
|
|
30
|
+
await context.liveness?.resolveExecutables(item.checks?.executables);
|
|
30
31
|
return context.liveness ? context.liveness.busyReason(item) : null;
|
|
31
32
|
}
|
|
32
33
|
|