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
|
@@ -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
|
@@ -7,9 +7,12 @@ import {
|
|
|
7
7
|
configureContainerHost,
|
|
8
8
|
resetWritableLayers,
|
|
9
9
|
} from './docker/writable.js';
|
|
10
|
+
import { setInterval, clearInterval } from 'node:timers';
|
|
10
11
|
import { scanDocker } from './docker/scan.js';
|
|
11
12
|
import { LocalEnv } from './env/local.js';
|
|
12
13
|
import { configureScanPolicy } from './env/policy.js';
|
|
14
|
+
import { configureCacheRoots } from './env/cache-roots.js';
|
|
15
|
+
import { checkSignal, environmentScope } from './env/scope.js';
|
|
13
16
|
import { block, tierTotals } from './items.js';
|
|
14
17
|
import { hostExecutor, trace } from './exec.js';
|
|
15
18
|
import { GitInspector } from './git.js';
|
|
@@ -21,6 +24,14 @@ import { scanGlobal } from './scanners/global.js';
|
|
|
21
24
|
import { scanProjects } from './scanners/projects.js';
|
|
22
25
|
import { scanSystem } from './scanners/system.js';
|
|
23
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';
|
|
24
35
|
|
|
25
36
|
export const SCANNERS = {
|
|
26
37
|
projects: scanProjects,
|
|
@@ -59,6 +70,9 @@ const RESCAN_OPTIONS = new Set([
|
|
|
59
70
|
'journalKeep',
|
|
60
71
|
'env',
|
|
61
72
|
'now',
|
|
73
|
+
'signal',
|
|
74
|
+
'scanBudget',
|
|
75
|
+
'onScanProgress',
|
|
62
76
|
]);
|
|
63
77
|
|
|
64
78
|
function safetySnapshot(report) {
|
|
@@ -70,20 +84,47 @@ function sealReport(report) {
|
|
|
70
84
|
return report;
|
|
71
85
|
}
|
|
72
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
|
+
|
|
73
101
|
/** Rebuild scanner-derived actions when a report came from JSON or changed. */
|
|
74
102
|
export async function revalidateReport(saved, input = {}) {
|
|
75
103
|
if (trustedReports.get(saved) === safetySnapshot(saved)) {
|
|
76
104
|
return saved;
|
|
77
105
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
);
|
|
87
128
|
for (const [key, value] of Object.entries(input)) {
|
|
88
129
|
if (RESCAN_OPTIONS.has(key) && value !== undefined && value !== null) {
|
|
89
130
|
scope[key] = value;
|
|
@@ -133,6 +174,7 @@ export async function environmentContext(env, options, overrides = {}) {
|
|
|
133
174
|
});
|
|
134
175
|
await liveness.refresh(true);
|
|
135
176
|
const homes = await env.homeDirs();
|
|
177
|
+
await configureCacheRoots(env, homes);
|
|
136
178
|
const tmpDirs = await env.tmpDirs();
|
|
137
179
|
const roots = overrides.roots ?? (await defaultRoots(env, homes, tmpDirs));
|
|
138
180
|
return {
|
|
@@ -152,16 +194,127 @@ export async function environmentContext(env, options, overrides = {}) {
|
|
|
152
194
|
* @returns {Promise<object[]>} items
|
|
153
195
|
*/
|
|
154
196
|
export async function scanEnvironment(env, options, extra = {}) {
|
|
155
|
-
const
|
|
156
|
-
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const abort = () => controller.abort(options.signal.reason);
|
|
199
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
200
|
+
if (options.signal?.aborted) {
|
|
201
|
+
abort();
|
|
202
|
+
}
|
|
203
|
+
const started = Date.now(),
|
|
204
|
+
deadline = started + options.scanBudgetMs;
|
|
205
|
+
const timer = setTimeout(
|
|
206
|
+
() => controller.abort(new Error('scan time budget exceeded')),
|
|
207
|
+
options.scanBudgetMs
|
|
208
|
+
);
|
|
209
|
+
const found = new Map();
|
|
210
|
+
const completed = new Set();
|
|
211
|
+
let scanner = 'context';
|
|
212
|
+
const progress = () =>
|
|
213
|
+
options.onScanProgress?.({
|
|
214
|
+
env: env.id,
|
|
215
|
+
scanner,
|
|
216
|
+
elapsedMs: Date.now() - started,
|
|
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,
|
|
223
|
+
status: env.scanStatus,
|
|
224
|
+
});
|
|
225
|
+
env.scanStatus = 'scanning';
|
|
226
|
+
const interval = setInterval(progress, 5000);
|
|
157
227
|
try {
|
|
158
|
-
|
|
228
|
+
return await environmentScope(
|
|
229
|
+
env,
|
|
230
|
+
controller.signal,
|
|
231
|
+
() => {
|
|
232
|
+
progress();
|
|
233
|
+
return scanEnvironmentScoped(
|
|
234
|
+
env,
|
|
235
|
+
options,
|
|
236
|
+
extra,
|
|
237
|
+
found,
|
|
238
|
+
completed,
|
|
239
|
+
(name) => {
|
|
240
|
+
scanner = name;
|
|
241
|
+
progress();
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
deadline
|
|
246
|
+
);
|
|
159
247
|
} catch (error) {
|
|
160
|
-
|
|
161
|
-
|
|
248
|
+
env.scanStatus = options.signal?.aborted
|
|
249
|
+
? 'aborted'
|
|
250
|
+
: controller.signal.aborted
|
|
251
|
+
? 'budget-exceeded'
|
|
252
|
+
: 'error';
|
|
253
|
+
(extra.errors ?? []).push({ env: env.id, scanner, message: error.message });
|
|
254
|
+
const items = [...found.values()];
|
|
255
|
+
for (const item of items) {
|
|
256
|
+
if (options.signal?.aborted || !completed.has(item.id)) {
|
|
257
|
+
block(item, `scan incomplete: ${error.message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return items;
|
|
261
|
+
} finally {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
clearInterval(interval);
|
|
264
|
+
options.signal?.removeEventListener('abort', abort);
|
|
265
|
+
progress();
|
|
162
266
|
}
|
|
163
|
-
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function scanEnvironmentScoped(
|
|
270
|
+
env,
|
|
271
|
+
options,
|
|
272
|
+
extra,
|
|
273
|
+
found,
|
|
274
|
+
completed,
|
|
275
|
+
stage
|
|
276
|
+
) {
|
|
277
|
+
const errors = extra.errors ?? [];
|
|
278
|
+
const context = await environmentContext(env, options, extra);
|
|
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;
|
|
313
|
+
let incomplete = false;
|
|
164
314
|
for (const name of options.scanners) {
|
|
315
|
+
checkSignal(env.signal);
|
|
316
|
+
context.scanner = name;
|
|
317
|
+
stage(name);
|
|
165
318
|
const scanner = SCANNERS[name];
|
|
166
319
|
if (!scanner) {
|
|
167
320
|
throw new Error(
|
|
@@ -169,14 +322,31 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
169
322
|
);
|
|
170
323
|
}
|
|
171
324
|
const started = Date.now();
|
|
325
|
+
const previous = new Set(found.keys());
|
|
172
326
|
try {
|
|
173
|
-
|
|
327
|
+
const entries = await scanner(context);
|
|
328
|
+
for (const item of entries) {
|
|
329
|
+
context.emit(item);
|
|
330
|
+
}
|
|
331
|
+
await ready(entries.filter((item) => !completed.has(item.id)));
|
|
174
332
|
} catch (error) {
|
|
333
|
+
checkSignal(env.signal);
|
|
334
|
+
incomplete = true;
|
|
335
|
+
for (const [id, item] of found) {
|
|
336
|
+
if (![previous, completed].some((set) => set.has(id))) {
|
|
337
|
+
block(item, `scan incomplete: ${error.message}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
175
340
|
errors.push({ env: env.id, scanner: name, message: error.message });
|
|
176
341
|
}
|
|
177
342
|
trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
|
|
343
|
+
if (stopped) {
|
|
344
|
+
env.scanStatus = 'goal-reached';
|
|
345
|
+
return [...found.values()];
|
|
346
|
+
}
|
|
178
347
|
}
|
|
179
|
-
|
|
348
|
+
stage('storage');
|
|
349
|
+
const items = [...found.values()];
|
|
180
350
|
for (const item of items) {
|
|
181
351
|
for (const target of item.paths) {
|
|
182
352
|
block(item, env.scanPolicy.removalReason(target));
|
|
@@ -185,6 +355,7 @@ export async function scanEnvironment(env, options, extra = {}) {
|
|
|
185
355
|
for (const message of env.accessErrors?.values() ?? []) {
|
|
186
356
|
errors.push({ env: env.id, scanner: 'filesystem', message });
|
|
187
357
|
}
|
|
358
|
+
env.scanStatus = incomplete ? 'error' : 'complete';
|
|
188
359
|
return items;
|
|
189
360
|
}
|
|
190
361
|
|
|
@@ -307,13 +478,19 @@ function envDescriptor(env, depth, chain) {
|
|
|
307
478
|
depth,
|
|
308
479
|
chain,
|
|
309
480
|
hint: env.probeHint ?? null,
|
|
481
|
+
scanStatus: env.scanStatus ?? null,
|
|
482
|
+
layerSource: env.writableLayer?.source ?? null,
|
|
310
483
|
};
|
|
311
484
|
}
|
|
312
485
|
|
|
313
486
|
async function withDisk(env, descriptor) {
|
|
314
487
|
const target = env.platform === 'win32' ? env.currentHome : '/';
|
|
315
488
|
const disk = await env.diskUsage(target).catch(() => null);
|
|
316
|
-
return {
|
|
489
|
+
return {
|
|
490
|
+
...descriptor,
|
|
491
|
+
disk: disk ? { path: target, ...disk } : null,
|
|
492
|
+
swap: await readSwap(env),
|
|
493
|
+
};
|
|
317
494
|
}
|
|
318
495
|
|
|
319
496
|
async function dockerSection(env, options, errors) {
|
|
@@ -373,10 +550,43 @@ function blockMountedHostItems(items, mounts, pathApi) {
|
|
|
373
550
|
}
|
|
374
551
|
|
|
375
552
|
export async function scan(input = {}) {
|
|
553
|
+
const env = input.env ?? new LocalEnv();
|
|
554
|
+
try {
|
|
555
|
+
return await environmentScope(env, input.signal, () =>
|
|
556
|
+
scanScoped({ ...input, env })
|
|
557
|
+
);
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (!input.signal?.aborted) {
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
env.scanStatus = 'aborted';
|
|
563
|
+
const options = resolveOptions(input);
|
|
564
|
+
return buildReport({
|
|
565
|
+
options,
|
|
566
|
+
env,
|
|
567
|
+
items: [],
|
|
568
|
+
environments: [envDescriptor(env, 0, [])],
|
|
569
|
+
docker: null,
|
|
570
|
+
errors: [{ env: env.id, scanner: 'context', message: error.message }],
|
|
571
|
+
startedAt: options.now(),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function scanScoped(input) {
|
|
376
577
|
const options = resolveOptions(input);
|
|
377
578
|
const env = input.env ?? new LocalEnv();
|
|
378
579
|
const startedAt = options.now();
|
|
379
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();
|
|
380
590
|
resetWritableLayers(env.executor);
|
|
381
591
|
env.writableLayer = null;
|
|
382
592
|
env.scanPolicy = null;
|
|
@@ -390,7 +600,10 @@ export async function scan(input = {}) {
|
|
|
390
600
|
options.host === false
|
|
391
601
|
? []
|
|
392
602
|
: await scanEnvironment(env, options, { roots: options.roots, errors });
|
|
393
|
-
const docker =
|
|
603
|
+
const docker =
|
|
604
|
+
env.scanStatus === 'goal-reached'
|
|
605
|
+
? null
|
|
606
|
+
: await dockerSection(env, options, errors);
|
|
394
607
|
blockMountedHostItems(hostItems, docker?.bindMounts ?? [], env.path);
|
|
395
608
|
const items = filterItems(
|
|
396
609
|
[...hostItems, ...(docker?.items ?? [])],
|
|
@@ -413,6 +626,10 @@ export async function scan(input = {}) {
|
|
|
413
626
|
startedAt,
|
|
414
627
|
});
|
|
415
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);
|
|
416
633
|
return report;
|
|
417
634
|
}
|
|
418
635
|
|
|
@@ -420,6 +637,9 @@ function reportOptions(options) {
|
|
|
420
637
|
const serializable = { ...options };
|
|
421
638
|
delete serializable.now;
|
|
422
639
|
delete serializable.env;
|
|
640
|
+
delete serializable.signal;
|
|
641
|
+
delete serializable.onScanProgress;
|
|
642
|
+
delete serializable.onScanPhase;
|
|
423
643
|
return serializable;
|
|
424
644
|
}
|
|
425
645
|
|
|
@@ -440,6 +660,7 @@ export function buildReport({
|
|
|
440
660
|
tool: 'disk-space-saviour',
|
|
441
661
|
createdAt: new Date(options.now()).toISOString(),
|
|
442
662
|
durationMs: options.now() - startedAt,
|
|
663
|
+
aborted: Boolean(options.signal?.aborted),
|
|
443
664
|
host: { env: env.id, label: env.label, platform: env.platform },
|
|
444
665
|
options: reportOptions(options),
|
|
445
666
|
environments,
|
package/src/scanners/agents.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { scanItems } from '../env/scope.js';
|
|
1
2
|
/**
|
|
2
3
|
* Snapshot stores of AI coding agents built on the opencode storage layout
|
|
3
4
|
* (`@link-assistant/agent`, opencode).
|
|
@@ -112,7 +113,7 @@ async function appItems(context, app, root) {
|
|
|
112
113
|
mtimeMs: usages.get(state.entry.path)?.newestMtimeMs ?? 0,
|
|
113
114
|
}))
|
|
114
115
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0].state;
|
|
115
|
-
const items =
|
|
116
|
+
const items = scanItems(context);
|
|
116
117
|
for (const state of states) {
|
|
117
118
|
const usage = usages.get(state.entry.path);
|
|
118
119
|
const verdict =
|
|
@@ -148,7 +149,7 @@ async function appItems(context, app, root) {
|
|
|
148
149
|
export async function scanAgents(context, { apps = AGENT_APPS } = {}) {
|
|
149
150
|
const { env, options } = context;
|
|
150
151
|
const homes = options.homes ?? (await env.homeDirs());
|
|
151
|
-
const items =
|
|
152
|
+
const items = scanItems(context);
|
|
152
153
|
const seen = new Set();
|
|
153
154
|
for (const home of homes) {
|
|
154
155
|
for (const dataDir of dataDirs(env, home)) {
|
package/src/scanners/common.js
CHANGED
|
@@ -26,10 +26,15 @@ export async function listMany(env, dirs) {
|
|
|
26
26
|
* @returns {string|null}
|
|
27
27
|
*/
|
|
28
28
|
export async function scanTimeBusy(context, item) {
|
|
29
|
-
await context
|
|
29
|
+
await resolvePaths(context, item.paths);
|
|
30
30
|
return context.liveness ? context.liveness.busyReason(item) : null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Prime one batch before constructing items, so later per-item probes are cached. */
|
|
34
|
+
export async function resolvePaths(context, paths) {
|
|
35
|
+
await context.liveness?.resolve(paths);
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/**
|
|
34
39
|
* Whether `now - timestamp` exceeds `windowMs` (unknown timestamps count as
|
|
35
40
|
* recent, so they are never treated as abandoned).
|