disk-space-saviour 0.16.0 → 0.18.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 +18 -0
- package/README.md +67 -14
- package/bin/dss.js +5 -1
- package/package.json +1 -1
- package/src/clean.js +45 -13
- package/src/cli.js +80 -14
- package/src/docker/scan.js +1 -0
- package/src/emergency.js +41 -6
- package/src/env/local.js +270 -65
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/shell.js +10 -1
- package/src/fast-clean.js +39 -0
- package/src/git.js +101 -39
- package/src/health.js +3 -3
- package/src/index.d.ts +28 -1
- package/src/liveness.js +131 -9
- package/src/options.js +3 -1
- package/src/report.js +69 -2
- package/src/rules/ecosystems.js +5 -4
- package/src/rules/other.js +4 -1
- package/src/rules/versions.js +23 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +157 -22
- package/src/scanners/global.js +46 -11
- package/src/scanners/projects.js +94 -69
- package/src/scanners/versions.js +109 -7
- package/src/swap.js +69 -0
- package/src/timings.js +54 -0
|
@@ -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,15 @@ 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';
|
|
35
|
+
import { PhaseTimer, timed } from './timings.js';
|
|
27
36
|
|
|
28
37
|
export const SCANNERS = {
|
|
29
38
|
projects: scanProjects,
|
|
@@ -76,20 +85,47 @@ function sealReport(report) {
|
|
|
76
85
|
return report;
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
function unchangedDiscoveryScope(report, input) {
|
|
89
|
+
const original = resolveOptions(report.options);
|
|
90
|
+
const overrides = Object.fromEntries(
|
|
91
|
+
Object.entries(input).filter(
|
|
92
|
+
([name, value]) =>
|
|
93
|
+
RESCAN_OPTIONS.has(name) && value !== undefined && value !== null
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
return (
|
|
97
|
+
scopeKey(original) ===
|
|
98
|
+
scopeKey(resolveOptions({ ...original, ...overrides }))
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
/** Rebuild scanner-derived actions when a report came from JSON or changed. */
|
|
80
103
|
export async function revalidateReport(saved, input = {}) {
|
|
81
104
|
if (trustedReports.get(saved) === safetySnapshot(saved)) {
|
|
82
105
|
return saved;
|
|
83
106
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
saved = saved.report?.schema === REPORT_SCHEMA ? saved.report : saved;
|
|
108
|
+
if (
|
|
109
|
+
unchangedDiscoveryScope(saved, input) &&
|
|
110
|
+
(await authenticatedReport(
|
|
111
|
+
saved,
|
|
112
|
+
input.cacheDir,
|
|
113
|
+
input.now?.() ?? Date.now()
|
|
114
|
+
))
|
|
115
|
+
) {
|
|
116
|
+
return sealReport(saved);
|
|
117
|
+
}
|
|
118
|
+
const scope = Object.fromEntries(
|
|
119
|
+
[
|
|
120
|
+
'roots',
|
|
121
|
+
'scanners',
|
|
122
|
+
'containers',
|
|
123
|
+
'host',
|
|
124
|
+
'docker',
|
|
125
|
+
'dockerDepth',
|
|
126
|
+
'maxDepth',
|
|
127
|
+
].map((name) => [name, saved.options?.[name]])
|
|
128
|
+
);
|
|
93
129
|
for (const [key, value] of Object.entries(input)) {
|
|
94
130
|
if (RESCAN_OPTIONS.has(key) && value !== undefined && value !== null) {
|
|
95
131
|
scope[key] = value;
|
|
@@ -172,6 +208,7 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
172
208
|
options.scanBudgetMs
|
|
173
209
|
);
|
|
174
210
|
const found = new Map();
|
|
211
|
+
const completed = new Set();
|
|
175
212
|
let scanner = 'context';
|
|
176
213
|
const progress = () =>
|
|
177
214
|
options.onScanProgress?.({
|
|
@@ -179,6 +216,11 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
179
216
|
scanner,
|
|
180
217
|
elapsedMs: Date.now() - started,
|
|
181
218
|
items: found.size,
|
|
219
|
+
candidates: found.size,
|
|
220
|
+
filesSized: env.scanMetrics?.filesSized ?? 0,
|
|
221
|
+
directoriesSized: env.scanMetrics?.directoriesSized ?? 0,
|
|
222
|
+
indexHits: env.scanMetrics?.indexHits ?? 0,
|
|
223
|
+
sizeCacheHits: env.scanMetrics?.sizeCacheHits ?? 0,
|
|
182
224
|
status: env.scanStatus,
|
|
183
225
|
});
|
|
184
226
|
env.scanStatus = 'scanning';
|
|
@@ -189,10 +231,17 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
189
231
|
controller.signal,
|
|
190
232
|
() => {
|
|
191
233
|
progress();
|
|
192
|
-
return scanEnvironmentScoped(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
234
|
+
return scanEnvironmentScoped(
|
|
235
|
+
env,
|
|
236
|
+
options,
|
|
237
|
+
extra,
|
|
238
|
+
found,
|
|
239
|
+
completed,
|
|
240
|
+
(name) => {
|
|
241
|
+
scanner = name;
|
|
242
|
+
progress();
|
|
243
|
+
}
|
|
244
|
+
);
|
|
196
245
|
},
|
|
197
246
|
deadline
|
|
198
247
|
);
|
|
@@ -205,7 +254,9 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
205
254
|
(extra.errors ?? []).push({ env: env.id, scanner, message: error.message });
|
|
206
255
|
const items = [...found.values()];
|
|
207
256
|
for (const item of items) {
|
|
208
|
-
|
|
257
|
+
if (options.signal?.aborted || !completed.has(item.id)) {
|
|
258
|
+
block(item, `scan incomplete: ${error.message}`);
|
|
259
|
+
}
|
|
209
260
|
}
|
|
210
261
|
return items;
|
|
211
262
|
} finally {
|
|
@@ -216,13 +267,63 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
216
267
|
}
|
|
217
268
|
}
|
|
218
269
|
|
|
219
|
-
|
|
270
|
+
/** Phase timings of an environment, started on its first scan. */
|
|
271
|
+
function environmentTimings(env) {
|
|
272
|
+
env.timings ??= new PhaseTimer();
|
|
273
|
+
return env.timings;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function scanEnvironmentScoped(
|
|
277
|
+
env,
|
|
278
|
+
options,
|
|
279
|
+
extra,
|
|
280
|
+
found,
|
|
281
|
+
completed,
|
|
282
|
+
stage
|
|
283
|
+
) {
|
|
220
284
|
const errors = extra.errors ?? [];
|
|
221
|
-
const context = await
|
|
285
|
+
const context = await environmentTimings(env).measure('context', () =>
|
|
286
|
+
environmentContext(env, options, extra)
|
|
287
|
+
);
|
|
288
|
+
context.timings = env.timings;
|
|
222
289
|
context.emit = (item) => found.set(item.id, item);
|
|
290
|
+
let stopped = false;
|
|
291
|
+
const ready = async (items) => {
|
|
292
|
+
await env.timings.measure('storage', () => attributeStorage(env, items));
|
|
293
|
+
for (const item of items) {
|
|
294
|
+
for (const target of item.paths) {
|
|
295
|
+
block(item, env.scanPolicy.removalReason(target));
|
|
296
|
+
}
|
|
297
|
+
completed.add(item.id);
|
|
298
|
+
}
|
|
299
|
+
if (
|
|
300
|
+
env.kind === 'host' &&
|
|
301
|
+
context.scanner === 'global' &&
|
|
302
|
+
options.onScanPhase
|
|
303
|
+
) {
|
|
304
|
+
const result = await options.onScanPhase(
|
|
305
|
+
sealReport({
|
|
306
|
+
schema: REPORT_SCHEMA,
|
|
307
|
+
tool: 'disk-space-saviour',
|
|
308
|
+
createdAt: new Date(options.now()).toISOString(),
|
|
309
|
+
host: { env: env.id, label: env.label, platform: env.platform },
|
|
310
|
+
options: reportOptions(options),
|
|
311
|
+
environments: [envDescriptor(env, 0, [])],
|
|
312
|
+
items: filterItems(items, options, [], env.path),
|
|
313
|
+
totals: tierTotals(items),
|
|
314
|
+
errors: [],
|
|
315
|
+
docker: null,
|
|
316
|
+
})
|
|
317
|
+
);
|
|
318
|
+
stopped ||= result === false;
|
|
319
|
+
}
|
|
320
|
+
return !stopped;
|
|
321
|
+
};
|
|
322
|
+
context.ready = ready;
|
|
223
323
|
let incomplete = false;
|
|
224
324
|
for (const name of options.scanners) {
|
|
225
325
|
checkSignal(env.signal);
|
|
326
|
+
context.scanner = name;
|
|
226
327
|
stage(name);
|
|
227
328
|
const scanner = SCANNERS[name];
|
|
228
329
|
if (!scanner) {
|
|
@@ -233,25 +334,31 @@ async function scanEnvironmentScoped(env, options, extra, found, stage) {
|
|
|
233
334
|
const started = Date.now();
|
|
234
335
|
const previous = new Set(found.keys());
|
|
235
336
|
try {
|
|
236
|
-
const entries = await scanner(
|
|
337
|
+
const entries = await env.timings.measure(`scanner:${name}`, () =>
|
|
338
|
+
scanner(context)
|
|
339
|
+
);
|
|
237
340
|
for (const item of entries) {
|
|
238
341
|
context.emit(item);
|
|
239
342
|
}
|
|
343
|
+
await ready(entries.filter((item) => !completed.has(item.id)));
|
|
240
344
|
} catch (error) {
|
|
241
345
|
checkSignal(env.signal);
|
|
242
346
|
incomplete = true;
|
|
243
347
|
for (const [id, item] of found) {
|
|
244
|
-
if (!previous.has(id)) {
|
|
348
|
+
if (![previous, completed].some((set) => set.has(id))) {
|
|
245
349
|
block(item, `scan incomplete: ${error.message}`);
|
|
246
350
|
}
|
|
247
351
|
}
|
|
248
352
|
errors.push({ env: env.id, scanner: name, message: error.message });
|
|
249
353
|
}
|
|
250
354
|
trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
|
|
355
|
+
if (stopped) {
|
|
356
|
+
env.scanStatus = 'goal-reached';
|
|
357
|
+
return [...found.values()];
|
|
358
|
+
}
|
|
251
359
|
}
|
|
252
360
|
stage('storage');
|
|
253
361
|
const items = [...found.values()];
|
|
254
|
-
await attributeStorage(env, items);
|
|
255
362
|
for (const item of items) {
|
|
256
363
|
for (const target of item.paths) {
|
|
257
364
|
block(item, env.scanPolicy.removalReason(target));
|
|
@@ -385,13 +492,18 @@ function envDescriptor(env, depth, chain) {
|
|
|
385
492
|
hint: env.probeHint ?? null,
|
|
386
493
|
scanStatus: env.scanStatus ?? null,
|
|
387
494
|
layerSource: env.writableLayer?.source ?? null,
|
|
495
|
+
timings: env.timings?.toJSON() ?? [],
|
|
388
496
|
};
|
|
389
497
|
}
|
|
390
498
|
|
|
391
499
|
async function withDisk(env, descriptor) {
|
|
392
500
|
const target = env.platform === 'win32' ? env.currentHome : '/';
|
|
393
501
|
const disk = await env.diskUsage(target).catch(() => null);
|
|
394
|
-
return {
|
|
502
|
+
return {
|
|
503
|
+
...descriptor,
|
|
504
|
+
disk: disk ? { path: target, ...disk } : null,
|
|
505
|
+
swap: await readSwap(env),
|
|
506
|
+
};
|
|
395
507
|
}
|
|
396
508
|
|
|
397
509
|
async function dockerSection(env, options, errors) {
|
|
@@ -479,9 +591,19 @@ async function scanScoped(input) {
|
|
|
479
591
|
const env = input.env ?? new LocalEnv();
|
|
480
592
|
const startedAt = options.now();
|
|
481
593
|
const errors = [];
|
|
594
|
+
env.scanMetrics = {
|
|
595
|
+
filesSized: 0,
|
|
596
|
+
directoriesSized: 0,
|
|
597
|
+
indexHits: 0,
|
|
598
|
+
sizeCacheHits: 0,
|
|
599
|
+
};
|
|
600
|
+
env.scanIndex = new ScanIndex(options.cacheDir);
|
|
601
|
+
env.platformIndex = options.platformIndex;
|
|
602
|
+
await env.scanIndex.load();
|
|
482
603
|
resetWritableLayers(env.executor);
|
|
483
604
|
env.writableLayer = null;
|
|
484
605
|
env.scanPolicy = null;
|
|
606
|
+
env.timings = new PhaseTimer();
|
|
485
607
|
await configureScanPolicy(env, options);
|
|
486
608
|
await configureContainerHost(
|
|
487
609
|
env,
|
|
@@ -492,7 +614,12 @@ async function scanScoped(input) {
|
|
|
492
614
|
options.host === false
|
|
493
615
|
? []
|
|
494
616
|
: await scanEnvironment(env, options, { roots: options.roots, errors });
|
|
495
|
-
const docker =
|
|
617
|
+
const docker =
|
|
618
|
+
env.scanStatus === 'goal-reached'
|
|
619
|
+
? null
|
|
620
|
+
: await timed(env.timings, 'docker', () =>
|
|
621
|
+
dockerSection(env, options, errors)
|
|
622
|
+
);
|
|
496
623
|
blockMountedHostItems(hostItems, docker?.bindMounts ?? [], env.path);
|
|
497
624
|
const items = filterItems(
|
|
498
625
|
[...hostItems, ...(docker?.items ?? [])],
|
|
@@ -504,7 +631,10 @@ async function scanScoped(input) {
|
|
|
504
631
|
for (const inner of docker?.environments ?? []) {
|
|
505
632
|
environments.push(inner);
|
|
506
633
|
}
|
|
507
|
-
environments[0] = await
|
|
634
|
+
environments[0] = await timed(env.timings, 'disk', () =>
|
|
635
|
+
withDisk(env, environments[0])
|
|
636
|
+
);
|
|
637
|
+
environments[0].timings = env.timings.toJSON();
|
|
508
638
|
const report = buildReport({
|
|
509
639
|
options,
|
|
510
640
|
env,
|
|
@@ -515,6 +645,10 @@ async function scanScoped(input) {
|
|
|
515
645
|
startedAt,
|
|
516
646
|
});
|
|
517
647
|
reportEnvironments.set(report, env);
|
|
648
|
+
await env.scanIndex.save();
|
|
649
|
+
await authenticateReport(report, options.cacheDir);
|
|
650
|
+
sealReport(report);
|
|
651
|
+
await saveScan(report, options.cacheDir);
|
|
518
652
|
return report;
|
|
519
653
|
}
|
|
520
654
|
|
|
@@ -524,6 +658,7 @@ function reportOptions(options) {
|
|
|
524
658
|
delete serializable.env;
|
|
525
659
|
delete serializable.signal;
|
|
526
660
|
delete serializable.onScanProgress;
|
|
661
|
+
delete serializable.onScanPhase;
|
|
527
662
|
return serializable;
|
|
528
663
|
}
|
|
529
664
|
|
package/src/scanners/global.js
CHANGED
|
@@ -14,6 +14,7 @@ import { expandGlobPaths, expandRulePath, isWithin } from '../paths.js';
|
|
|
14
14
|
import { olderThan, scanTimeBusy, resolvePaths } from './common.js';
|
|
15
15
|
import { configuredPattern } from '../env/cache-roots.js';
|
|
16
16
|
import { TOOLCHAIN_CACHE_RULES } from '../rules/toolchains.js';
|
|
17
|
+
import { timed } from '../timings.js';
|
|
17
18
|
|
|
18
19
|
export const GLOBAL_RULES = [
|
|
19
20
|
...CACHE_RULES,
|
|
@@ -135,6 +136,10 @@ function ageFilter(context, rule) {
|
|
|
135
136
|
return () => true;
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
function requiresMtime(rule) {
|
|
140
|
+
return rule.mtime ?? (rule.scope === 'tmp' || Boolean(rule.minAge));
|
|
141
|
+
}
|
|
142
|
+
|
|
138
143
|
/** Keep the latest installed revision of each browser and platform family. */
|
|
139
144
|
function olderVersions(env, rule, paths, stats) {
|
|
140
145
|
if (!rule.versionPattern) {
|
|
@@ -181,6 +186,7 @@ async function buildItem(context, rule, base, group) {
|
|
|
181
186
|
path: paths[0],
|
|
182
187
|
paths,
|
|
183
188
|
bytes: group.reduce((sum, entry) => sum + entry.usage.bytes, 0),
|
|
189
|
+
sizeEstimated: group.some((entry) => entry.usage.estimated),
|
|
184
190
|
newestMtimeMs: Math.max(...group.map((e) => e.usage.newestMtimeMs ?? 0)),
|
|
185
191
|
tier: rule.tier ?? 'safe',
|
|
186
192
|
reason: rule.minAge
|
|
@@ -237,9 +243,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
237
243
|
patterns: rulePatterns(env, rule, base),
|
|
238
244
|
}))
|
|
239
245
|
);
|
|
240
|
-
const matches = await
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
const matches = await timed(context.timings, 'global:expand', () =>
|
|
247
|
+
expandGlobPaths(
|
|
248
|
+
env,
|
|
249
|
+
planned.flatMap((entry) => entry.patterns)
|
|
250
|
+
)
|
|
243
251
|
);
|
|
244
252
|
const typedPaths = planned
|
|
245
253
|
.filter(
|
|
@@ -248,7 +256,9 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
248
256
|
.flatMap(({ patterns }) =>
|
|
249
257
|
patterns.flatMap((pattern) => matches.get(pattern) ?? [])
|
|
250
258
|
);
|
|
251
|
-
const stats = await
|
|
259
|
+
const stats = await timed(context.timings, 'global:stat', () =>
|
|
260
|
+
env.statMany([...new Set(typedPaths)])
|
|
261
|
+
);
|
|
252
262
|
const found = [];
|
|
253
263
|
const seen = new Set();
|
|
254
264
|
for (const { rule, base, patterns } of planned) {
|
|
@@ -258,7 +268,11 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
258
268
|
|
|
259
269
|
const accepted = [];
|
|
260
270
|
for (const target of candidates) {
|
|
261
|
-
if (
|
|
271
|
+
if (
|
|
272
|
+
!(await timed(context.timings, 'global:accept', () =>
|
|
273
|
+
acceptedPath(env, rule, target, stats)
|
|
274
|
+
))
|
|
275
|
+
) {
|
|
262
276
|
continue;
|
|
263
277
|
}
|
|
264
278
|
accepted.push(target);
|
|
@@ -269,22 +283,43 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
269
283
|
found.push({ rule, base, paths });
|
|
270
284
|
}
|
|
271
285
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
286
|
+
await timed(context.timings, 'global:resolve', () =>
|
|
287
|
+
resolvePaths(
|
|
288
|
+
context,
|
|
289
|
+
found.flatMap((entry) => entry.paths)
|
|
290
|
+
)
|
|
276
291
|
);
|
|
277
292
|
const items = scanItems(context);
|
|
293
|
+
// Deliver cheap native measurements before rules needing recursive activity
|
|
294
|
+
// timestamps, so an emergency goal can avoid the expensive work entirely.
|
|
295
|
+
found.sort(
|
|
296
|
+
(a, b) => Number(requiresMtime(a.rule)) - Number(requiresMtime(b.rule))
|
|
297
|
+
);
|
|
298
|
+
// The report drops items below `minSize`; skipping them here saves their
|
|
299
|
+
// liveness checks (Telegram media alone can hold 100k small files). In a
|
|
300
|
+
// container the writable layer re-measures sizes later, so all are kept.
|
|
301
|
+
const large = (entry) =>
|
|
302
|
+
Boolean(env.writableLayer) ||
|
|
303
|
+
entry.usage.bytes >= (options.minSizeBytes ?? 0);
|
|
278
304
|
for (const { rule, base, paths } of found) {
|
|
305
|
+
const usages = await timed(context.timings, 'global:measure', () =>
|
|
306
|
+
env.usageMany(paths, { mtime: requiresMtime(rule), cache: true })
|
|
307
|
+
);
|
|
279
308
|
const accept = ageFilter(context, rule);
|
|
280
309
|
const measured = paths
|
|
281
310
|
.map((target) => ({ path: target, usage: usages.get(target) }))
|
|
282
311
|
.filter((entry) => entry.usage && accept(entry.usage));
|
|
283
312
|
const groups = rule.aggregate
|
|
284
313
|
? [measured]
|
|
285
|
-
: measured.map((entry) => [entry]);
|
|
314
|
+
: measured.filter(large).map((entry) => [entry]);
|
|
286
315
|
for (const group of groups.filter((g) => g.length > 0)) {
|
|
287
|
-
|
|
316
|
+
const item = await timed(context.timings, 'global:item', () =>
|
|
317
|
+
buildItem(context, rule, base, group)
|
|
318
|
+
);
|
|
319
|
+
items.push(item);
|
|
320
|
+
if ((await context.ready?.([item])) === false) {
|
|
321
|
+
return items;
|
|
322
|
+
}
|
|
288
323
|
}
|
|
289
324
|
}
|
|
290
325
|
return items;
|