disk-space-saviour 0.16.0 → 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 +6 -0
- package/README.md +40 -1
- package/package.json +1 -1
- package/src/clean.js +29 -5
- package/src/cli.js +79 -13
- package/src/emergency.js +37 -6
- package/src/env/local.js +245 -64
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +41 -1
- package/src/fast-clean.js +39 -0
- package/src/health.js +3 -3
- package/src/index.d.ts +27 -0
- package/src/options.js +3 -1
- package/src/report.js +18 -2
- package/src/scan-state.js +239 -0
- package/src/scan.js +135 -19
- package/src/scanners/global.js +19 -2
- package/src/scanners/projects.js +89 -20
- package/src/scanners/versions.js +7 -4
- package/src/swap.js +69 -0
package/src/options.js
CHANGED
|
@@ -16,7 +16,7 @@ export const DEFAULTS = {
|
|
|
16
16
|
docker: null,
|
|
17
17
|
dockerDepth: 3,
|
|
18
18
|
containers: [],
|
|
19
|
-
scanners: ['
|
|
19
|
+
scanners: ['global', 'projects', 'versions', 'agents', 'system'],
|
|
20
20
|
only: [],
|
|
21
21
|
exclude: [],
|
|
22
22
|
includeVolumes: false,
|
|
@@ -33,6 +33,8 @@ export const DEFAULTS = {
|
|
|
33
33
|
backupDir: null,
|
|
34
34
|
auditDir: null,
|
|
35
35
|
scanBudget: '2m',
|
|
36
|
+
cacheDir: false,
|
|
37
|
+
platformIndex: true,
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
function toBytes(value) {
|
package/src/report.js
CHANGED
|
@@ -56,7 +56,7 @@ function itemLine(item, showEnv) {
|
|
|
56
56
|
: item.imageBytes
|
|
57
57
|
? ` (${formatBytes(item.imageBytes)} in image${item.imageRef ? ` ${item.imageRef}` : ''}; 0 B of that data freeable now; remove the stopped container and unused image after the task completes)`
|
|
58
58
|
: '';
|
|
59
|
-
return ` ${pad(formatBytes(item.bytes), 10)} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
|
|
59
|
+
return ` ${pad(formatBytes(item.bytes), 10)}${item.sizeEstimated ? ' (estimated)' : ''} ${env}${item.rule} ${itemLocation(item)}${confirm}${storage}`;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function limited(lines, limit) {
|
|
@@ -318,6 +318,18 @@ export function formatReport(report, options = {}) {
|
|
|
318
318
|
`disk-space-saviour scan of ${host.label} in ${(report.durationMs / 1000).toFixed(1)}s (report only, nothing was deleted)`,
|
|
319
319
|
formatDisk(host.disk),
|
|
320
320
|
];
|
|
321
|
+
if (host.swap) {
|
|
322
|
+
lines.push(
|
|
323
|
+
`Swap: ${formatBytes(host.swap.used)} used of ${formatBytes(host.swap.total)} (managed by macOS)`
|
|
324
|
+
);
|
|
325
|
+
lines.push(host.swap.attribution);
|
|
326
|
+
for (const holder of host.swap.holders) {
|
|
327
|
+
lines.push(
|
|
328
|
+
` ${holder.pid} ${holder.command}: ${formatBytes(holder.memoryBytes)} memory, ${formatBytes(holder.compressedBytes)} compressed`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
lines.push(host.swap.advice);
|
|
332
|
+
}
|
|
321
333
|
for (const tier of TIERS) {
|
|
322
334
|
lines.push(...tierSection(report, tier, { limit }));
|
|
323
335
|
}
|
|
@@ -325,7 +337,11 @@ export function formatReport(report, options = {}) {
|
|
|
325
337
|
if (env.layerSource) {
|
|
326
338
|
lines.push(` ${env.label}: layer source ${env.layerSource}`);
|
|
327
339
|
}
|
|
328
|
-
if (env.scanStatus
|
|
340
|
+
if (env.scanStatus === 'goal-reached') {
|
|
341
|
+
lines.push(
|
|
342
|
+
` ${env.label}: scan stopped after the free-space goal was met`
|
|
343
|
+
);
|
|
344
|
+
} else if (env.scanStatus && env.scanStatus !== 'complete') {
|
|
329
345
|
lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
|
|
330
346
|
}
|
|
331
347
|
if (env.hint) {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/** Advisory discovery/size index and authenticated, short-lived scan plans. */
|
|
2
|
+
import {
|
|
3
|
+
createHash,
|
|
4
|
+
createHmac,
|
|
5
|
+
randomBytes,
|
|
6
|
+
timingSafeEqual,
|
|
7
|
+
} from 'node:crypto';
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { trace } from './exec.js';
|
|
12
|
+
|
|
13
|
+
export const REPORT_MAX_AGE = 30 * 60 * 1000;
|
|
14
|
+
export const SIZE_MAX_AGE = 60 * 60 * 1000;
|
|
15
|
+
const LIMIT = 20000;
|
|
16
|
+
|
|
17
|
+
export function defaultStateDirectory(vars = process.env) {
|
|
18
|
+
return path.join(
|
|
19
|
+
vars.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'),
|
|
20
|
+
'disk-space-saviour'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function payload(report) {
|
|
25
|
+
const { authentication, ...fields } = report;
|
|
26
|
+
void authentication;
|
|
27
|
+
return JSON.stringify(fields);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function secret(directory) {
|
|
31
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
32
|
+
const file = path.join(directory, 'scan-key');
|
|
33
|
+
try {
|
|
34
|
+
await fs.writeFile(file, randomBytes(32), { flag: 'wx', mode: 0o600 });
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code !== 'EEXIST') {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return fs.readFile(file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function authenticateReport(report, directory) {
|
|
44
|
+
if (!directory) {
|
|
45
|
+
return report;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
report.authentication = {
|
|
49
|
+
hostname: os.hostname(),
|
|
50
|
+
uid: process.getuid?.() ?? os.userInfo().username,
|
|
51
|
+
digest: createHmac('sha256', await secret(directory))
|
|
52
|
+
.update(payload(report))
|
|
53
|
+
.digest('hex'),
|
|
54
|
+
};
|
|
55
|
+
} catch (error) {
|
|
56
|
+
trace('scan state unavailable', error.message);
|
|
57
|
+
}
|
|
58
|
+
return report;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recentLocalReport(report, now) {
|
|
62
|
+
const age = now - Date.parse(report.createdAt);
|
|
63
|
+
const auth = report.authentication;
|
|
64
|
+
if (
|
|
65
|
+
!Number.isFinite(age) ||
|
|
66
|
+
age < 0 ||
|
|
67
|
+
age > REPORT_MAX_AGE ||
|
|
68
|
+
report.aborted ||
|
|
69
|
+
auth?.hostname !== os.hostname() ||
|
|
70
|
+
auth?.uid !== (process.getuid?.() ?? os.userInfo().username)
|
|
71
|
+
) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function authenticatedReport(report, directory, now = Date.now()) {
|
|
78
|
+
if (
|
|
79
|
+
!directory ||
|
|
80
|
+
report?.schema !== 1 ||
|
|
81
|
+
report.tool !== 'disk-space-saviour' ||
|
|
82
|
+
!recentLocalReport(report, now)
|
|
83
|
+
) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const key = await fs.readFile(path.join(directory, 'scan-key'));
|
|
88
|
+
const expected = createHmac('sha256', key).update(payload(report)).digest();
|
|
89
|
+
const actual = Buffer.from(report.authentication.digest, 'hex');
|
|
90
|
+
return (
|
|
91
|
+
expected.length === actual.length && timingSafeEqual(expected, actual)
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function scopeKey(options) {
|
|
99
|
+
const names = [
|
|
100
|
+
'roots',
|
|
101
|
+
'scanners',
|
|
102
|
+
'host',
|
|
103
|
+
'docker',
|
|
104
|
+
'dockerDepth',
|
|
105
|
+
'maxDepth',
|
|
106
|
+
'staleAgeMs',
|
|
107
|
+
'inactiveMs',
|
|
108
|
+
'minSizeBytes',
|
|
109
|
+
'only',
|
|
110
|
+
'exclude',
|
|
111
|
+
'includeVolumes',
|
|
112
|
+
'noNative',
|
|
113
|
+
'journalKeepBytes',
|
|
114
|
+
'containerFilter',
|
|
115
|
+
'removeStoppedContainers',
|
|
116
|
+
'investigationMaxAgeMs',
|
|
117
|
+
'removeContainers',
|
|
118
|
+
'removeImages',
|
|
119
|
+
'allowDirtyRepos',
|
|
120
|
+
'allowDirtyContainers',
|
|
121
|
+
];
|
|
122
|
+
return createHash('sha256')
|
|
123
|
+
.update(JSON.stringify(names.map((name) => [name, options[name]])))
|
|
124
|
+
.digest('hex');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function saveScan(report, directory) {
|
|
128
|
+
if (
|
|
129
|
+
!directory ||
|
|
130
|
+
!report.authentication ||
|
|
131
|
+
report.aborted ||
|
|
132
|
+
report.environments.some((env) => env.scanStatus === 'goal-reached')
|
|
133
|
+
) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const dir = path.join(directory, 'scans');
|
|
138
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
139
|
+
const file = path.join(dir, `${scopeKey(report.options)}.json`);
|
|
140
|
+
const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
141
|
+
await fs.writeFile(temporary, JSON.stringify(report), { mode: 0o600 });
|
|
142
|
+
await fs.rename(temporary, file);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
trace('cannot save scan', error.message);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function loadScan(options) {
|
|
149
|
+
if (!options.cacheDir) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const report = JSON.parse(
|
|
154
|
+
await fs.readFile(
|
|
155
|
+
path.join(options.cacheDir, 'scans', `${scopeKey(options)}.json`),
|
|
156
|
+
'utf8'
|
|
157
|
+
)
|
|
158
|
+
);
|
|
159
|
+
return (await authenticatedReport(report, options.cacheDir, options.now()))
|
|
160
|
+
? report
|
|
161
|
+
: null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Cached listings are validated by each directory's own metadata, never its parent alone. */
|
|
168
|
+
export class ScanIndex {
|
|
169
|
+
constructor(directory) {
|
|
170
|
+
this.directory = directory;
|
|
171
|
+
this.entries = new Map();
|
|
172
|
+
this.dirty = false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async load() {
|
|
176
|
+
if (!this.directory) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const data = JSON.parse(
|
|
181
|
+
await fs.readFile(
|
|
182
|
+
path.join(this.directory, 'index', 'directories.json'),
|
|
183
|
+
'utf8'
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
if (data.schema === 1 && Array.isArray(data.entries)) {
|
|
187
|
+
this.entries = new Map(data.entries.slice(-LIMIT));
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// Missing/corrupt state only loses an optimization.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get(key, fingerprint, maxAge = Infinity) {
|
|
195
|
+
const entry = this.entries.get(key);
|
|
196
|
+
return entry &&
|
|
197
|
+
entry.fingerprint === fingerprint &&
|
|
198
|
+
Date.now() - entry.time < maxAge
|
|
199
|
+
? entry.value
|
|
200
|
+
: null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
set(key, fingerprint, value) {
|
|
204
|
+
this.entries.delete(key);
|
|
205
|
+
this.entries.set(key, { fingerprint, value, time: Date.now() });
|
|
206
|
+
while (this.entries.size > LIMIT) {
|
|
207
|
+
this.entries.delete(this.entries.keys().next().value);
|
|
208
|
+
}
|
|
209
|
+
this.dirty = true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async save() {
|
|
213
|
+
if (!this.directory || !this.dirty) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const dir = path.join(this.directory, 'index');
|
|
217
|
+
const temporary = path.join(
|
|
218
|
+
dir,
|
|
219
|
+
`directories.${process.pid}.${randomBytes(4).toString('hex')}.json`
|
|
220
|
+
);
|
|
221
|
+
try {
|
|
222
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
223
|
+
await fs.writeFile(
|
|
224
|
+
temporary,
|
|
225
|
+
JSON.stringify({ schema: 1, entries: [...this.entries] }),
|
|
226
|
+
{ mode: 0o600 }
|
|
227
|
+
);
|
|
228
|
+
await fs.rename(temporary, path.join(dir, 'directories.json'));
|
|
229
|
+
this.dirty = false;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
trace('cannot save discovery index', error.message);
|
|
232
|
+
} finally {
|
|
233
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export const directoryFingerprint = (stats) =>
|
|
239
|
+
`${stats.device}:${stats.inode}:${stats.mtimeMs}:${stats.ctimeMs}`;
|
package/src/scan.js
CHANGED
|
@@ -24,6 +24,14 @@ import { scanGlobal } from './scanners/global.js';
|
|
|
24
24
|
import { scanProjects } from './scanners/projects.js';
|
|
25
25
|
import { scanSystem } from './scanners/system.js';
|
|
26
26
|
import { scanVersions } from './scanners/versions.js';
|
|
27
|
+
import { readSwap } from './swap.js';
|
|
28
|
+
import {
|
|
29
|
+
authenticateReport,
|
|
30
|
+
authenticatedReport,
|
|
31
|
+
saveScan,
|
|
32
|
+
ScanIndex,
|
|
33
|
+
scopeKey,
|
|
34
|
+
} from './scan-state.js';
|
|
27
35
|
|
|
28
36
|
export const SCANNERS = {
|
|
29
37
|
projects: scanProjects,
|
|
@@ -76,20 +84,47 @@ function sealReport(report) {
|
|
|
76
84
|
return report;
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
function unchangedDiscoveryScope(report, input) {
|
|
88
|
+
const original = resolveOptions(report.options);
|
|
89
|
+
const overrides = Object.fromEntries(
|
|
90
|
+
Object.entries(input).filter(
|
|
91
|
+
([name, value]) =>
|
|
92
|
+
RESCAN_OPTIONS.has(name) && value !== undefined && value !== null
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
return (
|
|
96
|
+
scopeKey(original) ===
|
|
97
|
+
scopeKey(resolveOptions({ ...original, ...overrides }))
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
/** Rebuild scanner-derived actions when a report came from JSON or changed. */
|
|
80
102
|
export async function revalidateReport(saved, input = {}) {
|
|
81
103
|
if (trustedReports.get(saved) === safetySnapshot(saved)) {
|
|
82
104
|
return saved;
|
|
83
105
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
saved = saved.report?.schema === REPORT_SCHEMA ? saved.report : saved;
|
|
107
|
+
if (
|
|
108
|
+
unchangedDiscoveryScope(saved, input) &&
|
|
109
|
+
(await authenticatedReport(
|
|
110
|
+
saved,
|
|
111
|
+
input.cacheDir,
|
|
112
|
+
input.now?.() ?? Date.now()
|
|
113
|
+
))
|
|
114
|
+
) {
|
|
115
|
+
return sealReport(saved);
|
|
116
|
+
}
|
|
117
|
+
const scope = Object.fromEntries(
|
|
118
|
+
[
|
|
119
|
+
'roots',
|
|
120
|
+
'scanners',
|
|
121
|
+
'containers',
|
|
122
|
+
'host',
|
|
123
|
+
'docker',
|
|
124
|
+
'dockerDepth',
|
|
125
|
+
'maxDepth',
|
|
126
|
+
].map((name) => [name, saved.options?.[name]])
|
|
127
|
+
);
|
|
93
128
|
for (const [key, value] of Object.entries(input)) {
|
|
94
129
|
if (RESCAN_OPTIONS.has(key) && value !== undefined && value !== null) {
|
|
95
130
|
scope[key] = value;
|
|
@@ -172,6 +207,7 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
172
207
|
options.scanBudgetMs
|
|
173
208
|
);
|
|
174
209
|
const found = new Map();
|
|
210
|
+
const completed = new Set();
|
|
175
211
|
let scanner = 'context';
|
|
176
212
|
const progress = () =>
|
|
177
213
|
options.onScanProgress?.({
|
|
@@ -179,6 +215,11 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
179
215
|
scanner,
|
|
180
216
|
elapsedMs: Date.now() - started,
|
|
181
217
|
items: found.size,
|
|
218
|
+
candidates: found.size,
|
|
219
|
+
filesSized: env.scanMetrics?.filesSized ?? 0,
|
|
220
|
+
directoriesSized: env.scanMetrics?.directoriesSized ?? 0,
|
|
221
|
+
indexHits: env.scanMetrics?.indexHits ?? 0,
|
|
222
|
+
sizeCacheHits: env.scanMetrics?.sizeCacheHits ?? 0,
|
|
182
223
|
status: env.scanStatus,
|
|
183
224
|
});
|
|
184
225
|
env.scanStatus = 'scanning';
|
|
@@ -189,10 +230,17 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
189
230
|
controller.signal,
|
|
190
231
|
() => {
|
|
191
232
|
progress();
|
|
192
|
-
return scanEnvironmentScoped(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
233
|
+
return scanEnvironmentScoped(
|
|
234
|
+
env,
|
|
235
|
+
options,
|
|
236
|
+
extra,
|
|
237
|
+
found,
|
|
238
|
+
completed,
|
|
239
|
+
(name) => {
|
|
240
|
+
scanner = name;
|
|
241
|
+
progress();
|
|
242
|
+
}
|
|
243
|
+
);
|
|
196
244
|
},
|
|
197
245
|
deadline
|
|
198
246
|
);
|
|
@@ -205,7 +253,9 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
205
253
|
(extra.errors ?? []).push({ env: env.id, scanner, message: error.message });
|
|
206
254
|
const items = [...found.values()];
|
|
207
255
|
for (const item of items) {
|
|
208
|
-
|
|
256
|
+
if (options.signal?.aborted || !completed.has(item.id)) {
|
|
257
|
+
block(item, `scan incomplete: ${error.message}`);
|
|
258
|
+
}
|
|
209
259
|
}
|
|
210
260
|
return items;
|
|
211
261
|
} finally {
|
|
@@ -216,13 +266,54 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
216
266
|
}
|
|
217
267
|
}
|
|
218
268
|
|
|
219
|
-
async function scanEnvironmentScoped(
|
|
269
|
+
async function scanEnvironmentScoped(
|
|
270
|
+
env,
|
|
271
|
+
options,
|
|
272
|
+
extra,
|
|
273
|
+
found,
|
|
274
|
+
completed,
|
|
275
|
+
stage
|
|
276
|
+
) {
|
|
220
277
|
const errors = extra.errors ?? [];
|
|
221
278
|
const context = await environmentContext(env, options, extra);
|
|
222
279
|
context.emit = (item) => found.set(item.id, item);
|
|
280
|
+
let stopped = false;
|
|
281
|
+
const ready = async (items) => {
|
|
282
|
+
await attributeStorage(env, items);
|
|
283
|
+
for (const item of items) {
|
|
284
|
+
for (const target of item.paths) {
|
|
285
|
+
block(item, env.scanPolicy.removalReason(target));
|
|
286
|
+
}
|
|
287
|
+
completed.add(item.id);
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
env.kind === 'host' &&
|
|
291
|
+
context.scanner === 'global' &&
|
|
292
|
+
options.onScanPhase
|
|
293
|
+
) {
|
|
294
|
+
const result = await options.onScanPhase(
|
|
295
|
+
sealReport({
|
|
296
|
+
schema: REPORT_SCHEMA,
|
|
297
|
+
tool: 'disk-space-saviour',
|
|
298
|
+
createdAt: new Date(options.now()).toISOString(),
|
|
299
|
+
host: { env: env.id, label: env.label, platform: env.platform },
|
|
300
|
+
options: reportOptions(options),
|
|
301
|
+
environments: [envDescriptor(env, 0, [])],
|
|
302
|
+
items: filterItems(items, options, [], env.path),
|
|
303
|
+
totals: tierTotals(items),
|
|
304
|
+
errors: [],
|
|
305
|
+
docker: null,
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
stopped ||= result === false;
|
|
309
|
+
}
|
|
310
|
+
return !stopped;
|
|
311
|
+
};
|
|
312
|
+
context.ready = ready;
|
|
223
313
|
let incomplete = false;
|
|
224
314
|
for (const name of options.scanners) {
|
|
225
315
|
checkSignal(env.signal);
|
|
316
|
+
context.scanner = name;
|
|
226
317
|
stage(name);
|
|
227
318
|
const scanner = SCANNERS[name];
|
|
228
319
|
if (!scanner) {
|
|
@@ -237,21 +328,25 @@ async function scanEnvironmentScoped(env, options, extra, found, stage) {
|
|
|
237
328
|
for (const item of entries) {
|
|
238
329
|
context.emit(item);
|
|
239
330
|
}
|
|
331
|
+
await ready(entries.filter((item) => !completed.has(item.id)));
|
|
240
332
|
} catch (error) {
|
|
241
333
|
checkSignal(env.signal);
|
|
242
334
|
incomplete = true;
|
|
243
335
|
for (const [id, item] of found) {
|
|
244
|
-
if (!previous.has(id)) {
|
|
336
|
+
if (![previous, completed].some((set) => set.has(id))) {
|
|
245
337
|
block(item, `scan incomplete: ${error.message}`);
|
|
246
338
|
}
|
|
247
339
|
}
|
|
248
340
|
errors.push({ env: env.id, scanner: name, message: error.message });
|
|
249
341
|
}
|
|
250
342
|
trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
|
|
343
|
+
if (stopped) {
|
|
344
|
+
env.scanStatus = 'goal-reached';
|
|
345
|
+
return [...found.values()];
|
|
346
|
+
}
|
|
251
347
|
}
|
|
252
348
|
stage('storage');
|
|
253
349
|
const items = [...found.values()];
|
|
254
|
-
await attributeStorage(env, items);
|
|
255
350
|
for (const item of items) {
|
|
256
351
|
for (const target of item.paths) {
|
|
257
352
|
block(item, env.scanPolicy.removalReason(target));
|
|
@@ -391,7 +486,11 @@ function envDescriptor(env, depth, chain) {
|
|
|
391
486
|
async function withDisk(env, descriptor) {
|
|
392
487
|
const target = env.platform === 'win32' ? env.currentHome : '/';
|
|
393
488
|
const disk = await env.diskUsage(target).catch(() => null);
|
|
394
|
-
return {
|
|
489
|
+
return {
|
|
490
|
+
...descriptor,
|
|
491
|
+
disk: disk ? { path: target, ...disk } : null,
|
|
492
|
+
swap: await readSwap(env),
|
|
493
|
+
};
|
|
395
494
|
}
|
|
396
495
|
|
|
397
496
|
async function dockerSection(env, options, errors) {
|
|
@@ -479,6 +578,15 @@ async function scanScoped(input) {
|
|
|
479
578
|
const env = input.env ?? new LocalEnv();
|
|
480
579
|
const startedAt = options.now();
|
|
481
580
|
const errors = [];
|
|
581
|
+
env.scanMetrics = {
|
|
582
|
+
filesSized: 0,
|
|
583
|
+
directoriesSized: 0,
|
|
584
|
+
indexHits: 0,
|
|
585
|
+
sizeCacheHits: 0,
|
|
586
|
+
};
|
|
587
|
+
env.scanIndex = new ScanIndex(options.cacheDir);
|
|
588
|
+
env.platformIndex = options.platformIndex;
|
|
589
|
+
await env.scanIndex.load();
|
|
482
590
|
resetWritableLayers(env.executor);
|
|
483
591
|
env.writableLayer = null;
|
|
484
592
|
env.scanPolicy = null;
|
|
@@ -492,7 +600,10 @@ async function scanScoped(input) {
|
|
|
492
600
|
options.host === false
|
|
493
601
|
? []
|
|
494
602
|
: await scanEnvironment(env, options, { roots: options.roots, errors });
|
|
495
|
-
const docker =
|
|
603
|
+
const docker =
|
|
604
|
+
env.scanStatus === 'goal-reached'
|
|
605
|
+
? null
|
|
606
|
+
: await dockerSection(env, options, errors);
|
|
496
607
|
blockMountedHostItems(hostItems, docker?.bindMounts ?? [], env.path);
|
|
497
608
|
const items = filterItems(
|
|
498
609
|
[...hostItems, ...(docker?.items ?? [])],
|
|
@@ -515,6 +626,10 @@ async function scanScoped(input) {
|
|
|
515
626
|
startedAt,
|
|
516
627
|
});
|
|
517
628
|
reportEnvironments.set(report, env);
|
|
629
|
+
await env.scanIndex.save();
|
|
630
|
+
await authenticateReport(report, options.cacheDir);
|
|
631
|
+
sealReport(report);
|
|
632
|
+
await saveScan(report, options.cacheDir);
|
|
518
633
|
return report;
|
|
519
634
|
}
|
|
520
635
|
|
|
@@ -524,6 +639,7 @@ function reportOptions(options) {
|
|
|
524
639
|
delete serializable.env;
|
|
525
640
|
delete serializable.signal;
|
|
526
641
|
delete serializable.onScanProgress;
|
|
642
|
+
delete serializable.onScanPhase;
|
|
527
643
|
return serializable;
|
|
528
644
|
}
|
|
529
645
|
|
package/src/scanners/global.js
CHANGED
|
@@ -135,6 +135,10 @@ function ageFilter(context, rule) {
|
|
|
135
135
|
return () => true;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
function requiresMtime(rule) {
|
|
139
|
+
return rule.mtime ?? (rule.scope === 'tmp' || Boolean(rule.minAge));
|
|
140
|
+
}
|
|
141
|
+
|
|
138
142
|
/** Keep the latest installed revision of each browser and platform family. */
|
|
139
143
|
function olderVersions(env, rule, paths, stats) {
|
|
140
144
|
if (!rule.versionPattern) {
|
|
@@ -181,6 +185,7 @@ async function buildItem(context, rule, base, group) {
|
|
|
181
185
|
path: paths[0],
|
|
182
186
|
paths,
|
|
183
187
|
bytes: group.reduce((sum, entry) => sum + entry.usage.bytes, 0),
|
|
188
|
+
sizeEstimated: group.some((entry) => entry.usage.estimated),
|
|
184
189
|
newestMtimeMs: Math.max(...group.map((e) => e.usage.newestMtimeMs ?? 0)),
|
|
185
190
|
tier: rule.tier ?? 'safe',
|
|
186
191
|
reason: rule.minAge
|
|
@@ -269,13 +274,21 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
269
274
|
found.push({ rule, base, paths });
|
|
270
275
|
}
|
|
271
276
|
}
|
|
272
|
-
const usages = await env.usageMany(found.flatMap((entry) => entry.paths));
|
|
273
277
|
await resolvePaths(
|
|
274
278
|
context,
|
|
275
279
|
found.flatMap((entry) => entry.paths)
|
|
276
280
|
);
|
|
277
281
|
const items = scanItems(context);
|
|
282
|
+
// Deliver cheap native measurements before rules needing recursive activity
|
|
283
|
+
// timestamps, so an emergency goal can avoid the expensive work entirely.
|
|
284
|
+
found.sort(
|
|
285
|
+
(a, b) => Number(requiresMtime(a.rule)) - Number(requiresMtime(b.rule))
|
|
286
|
+
);
|
|
278
287
|
for (const { rule, base, paths } of found) {
|
|
288
|
+
const usages = await env.usageMany(paths, {
|
|
289
|
+
mtime: requiresMtime(rule),
|
|
290
|
+
cache: true,
|
|
291
|
+
});
|
|
279
292
|
const accept = ageFilter(context, rule);
|
|
280
293
|
const measured = paths
|
|
281
294
|
.map((target) => ({ path: target, usage: usages.get(target) }))
|
|
@@ -284,7 +297,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
284
297
|
? [measured]
|
|
285
298
|
: measured.map((entry) => [entry]);
|
|
286
299
|
for (const group of groups.filter((g) => g.length > 0)) {
|
|
287
|
-
|
|
300
|
+
const item = await buildItem(context, rule, base, group);
|
|
301
|
+
items.push(item);
|
|
302
|
+
if ((await context.ready?.([item])) === false) {
|
|
303
|
+
return items;
|
|
304
|
+
}
|
|
288
305
|
}
|
|
289
306
|
}
|
|
290
307
|
return items;
|