disk-space-saviour 0.13.1 → 0.15.2
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 +34 -0
- package/README.md +89 -28
- package/bin/dss.js +12 -1
- package/package.json +5 -5
- package/src/audit.js +6 -0
- package/src/clean.js +166 -32
- package/src/cli.js +12 -2
- package/src/docker/cli.js +33 -5
- package/src/docker/containers.js +63 -7
- package/src/docker/scan.js +170 -46
- package/src/docker/snapshots.js +65 -0
- package/src/docker/writable.js +288 -0
- package/src/emergency.js +12 -3
- package/src/env/local.js +162 -78
- package/src/env/policy.js +155 -0
- package/src/env/proc.js +53 -0
- package/src/env/shell.js +190 -12
- package/src/exec.js +4 -1
- package/src/git-preserved.js +120 -0
- package/src/git.js +34 -6
- package/src/index.d.ts +14 -2
- package/src/liveness.js +160 -13
- package/src/options.js +5 -0
- package/src/paths.js +34 -2
- package/src/report.js +67 -7
- package/src/rules/apps.js +178 -0
- package/src/rules/ecosystems.js +4 -3
- package/src/rules/other.js +29 -5
- package/src/rules/versions.js +23 -0
- package/src/scan.js +86 -12
- package/src/scanners/agents.js +1 -1
- package/src/scanners/common.js +2 -1
- package/src/scanners/global.js +59 -7
- package/src/scanners/projects.js +4 -8
- package/src/scanners/system.js +4 -4
- package/src/scanners/versions.js +16 -4
package/src/env/shell.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { sharedWritableLayer } from '../docker/writable.js';
|
|
11
12
|
|
|
12
|
-
import { shellQuote } from '../exec.js';
|
|
13
|
+
import { containerExecutor, shellQuote, trace } from '../exec.js';
|
|
13
14
|
import { DEFAULT_SKIP_NAMES, parseDf, processAliases } from './local.js';
|
|
14
15
|
|
|
15
16
|
const STAT_FORMAT = '%F|%s|%b|%B|%Y|%n';
|
|
@@ -32,8 +33,8 @@ true`;
|
|
|
32
33
|
const USAGE_MANY_SCRIPT = `for t in "$@"; do
|
|
33
34
|
[ -e "$t" ] || [ -L "$t" ] || continue
|
|
34
35
|
printf '${RECORD}%s\\n' "$t"
|
|
35
|
-
du -
|
|
36
|
-
find "$t" ! -type d -exec stat -c %Y {} + 2>/dev/null | awk 'BEGIN{m=0;n=0}{n++; if ($1>m) m=$1} END{print n, m}'
|
|
36
|
+
du -skx -- "$t" 2>/dev/null | cut -f1
|
|
37
|
+
find "$t" -xdev ! -type d -exec stat -c %Y {} + 2>/dev/null | awk 'BEGIN{m=0;n=0}{n++; if ($1>m) m=$1} END{print n, m}'
|
|
37
38
|
stat -c %Y -- "$t"
|
|
38
39
|
done
|
|
39
40
|
true`;
|
|
@@ -44,18 +45,29 @@ for p in /proc/[0-9]*; do
|
|
|
44
45
|
e=$(readlink "$p/exe" 2>/dev/null)
|
|
45
46
|
a=$(tr '\\000' '\\n' < "$p/cmdline" 2>/dev/null | head -n 1)
|
|
46
47
|
w=$(readlink "$p/cwd" 2>/dev/null)
|
|
48
|
+
q=$(tr '\\000' ' ' < "$p/cmdline" 2>/dev/null)
|
|
47
49
|
t=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1,20)
|
|
48
|
-
printf '%s|%s|%s|%s|%s|%s\\n' "\${p#/proc/}" "$t" "$c" "$e" "$a" "$w"
|
|
50
|
+
printf '%s|%s|%s|%s|%s|%s|%s\\n' "\${p#/proc/}" "$t" "$c" "$e" "$a" "$w" "$q"
|
|
49
51
|
done`;
|
|
50
52
|
|
|
51
|
-
const OPEN_PATHS_SCRIPT = `failed=0
|
|
53
|
+
export const OPEN_PATHS_SCRIPT = `failed=0
|
|
52
54
|
for p in /proc/[0-9]*; do
|
|
53
55
|
[ -d "$p" ] || continue
|
|
54
|
-
|
|
56
|
+
state=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1)
|
|
57
|
+
[ "$state" = Z ] && continue
|
|
58
|
+
unreadable=0
|
|
59
|
+
ls "$p/fd" >/dev/null 2>&1 || unreadable=1
|
|
55
60
|
for l in "$p/cwd" "$p/exe" "$p"/fd/*; do
|
|
56
61
|
[ -L "$l" ] || continue
|
|
57
|
-
readlink "$l" 2>/dev/null ||
|
|
62
|
+
readlink "$l" 2>/dev/null || { [ -L "$l" ] && unreadable=1; }
|
|
58
63
|
done
|
|
64
|
+
if [ "$unreadable" = 1 ] && [ -d "$p" ]; then
|
|
65
|
+
printf 'unreadable pid %s (%s)\\n' "\${p#/proc/}" "$(cat "$p/comm" 2>/dev/null)" >&2
|
|
66
|
+
uid=$(awk '/^Uid:/{print $2}' "$p/status" 2>/dev/null)
|
|
67
|
+
command=$(tr '\\000' ' ' < "$p/cmdline" 2>/dev/null)
|
|
68
|
+
printf 'DSS_UNREADABLE|%s|%s|%s|%s\\n' "\${p#/proc/}" "$uid" "$(cat "$p/comm" 2>/dev/null)" "$command" >&2
|
|
69
|
+
failed=1
|
|
70
|
+
fi
|
|
59
71
|
done
|
|
60
72
|
exit "$failed"`;
|
|
61
73
|
|
|
@@ -156,6 +168,10 @@ export class ShellEnv {
|
|
|
156
168
|
*/
|
|
157
169
|
constructor(executor, options = {}) {
|
|
158
170
|
this.executor = executor;
|
|
171
|
+
this.writableLayer =
|
|
172
|
+
executor.parent && executor.containerId
|
|
173
|
+
? sharedWritableLayer(executor.parent, executor.containerId)
|
|
174
|
+
: null;
|
|
159
175
|
this.id = options.id ?? executor.label;
|
|
160
176
|
this.kind = options.kind ?? 'container';
|
|
161
177
|
this.label = options.label ?? executor.label;
|
|
@@ -289,7 +305,74 @@ export class ShellEnv {
|
|
|
289
305
|
* @returns {Promise<Map<string, object>>}
|
|
290
306
|
*/
|
|
291
307
|
async usageMany(targets) {
|
|
308
|
+
const usages = await this.rawUsageMany(targets);
|
|
309
|
+
return this.writableLayer
|
|
310
|
+
? this.writableLayer.measure(this, usages)
|
|
311
|
+
: usages;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async statMany(targets) {
|
|
315
|
+
const stats = new Map();
|
|
316
|
+
for (const batch of batches(targets)) {
|
|
317
|
+
const result = await this.executor.run([
|
|
318
|
+
'stat',
|
|
319
|
+
'-c',
|
|
320
|
+
`${STAT_FORMAT}|%d:%i:%u`,
|
|
321
|
+
'--',
|
|
322
|
+
...batch,
|
|
323
|
+
]);
|
|
324
|
+
for (const line of result.stdout.split('\n').filter(Boolean)) {
|
|
325
|
+
const end = line.lastIndexOf('|');
|
|
326
|
+
const stat = parseStatLine(line.slice(0, end));
|
|
327
|
+
if (stat) {
|
|
328
|
+
const identity = line.slice(end + 1).split(':');
|
|
329
|
+
stats.set(stat.name, {
|
|
330
|
+
...stat,
|
|
331
|
+
inode: identity.slice(0, 2).join(':'),
|
|
332
|
+
uid: identity.length > 2 ? Number(identity[2]) : undefined,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return stats;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async rawUsageMany(targets) {
|
|
292
341
|
const usages = new Map();
|
|
342
|
+
const policy = this.scanPolicy;
|
|
343
|
+
if (policy) {
|
|
344
|
+
targets = targets.filter((target) => !policy.removalReason(target));
|
|
345
|
+
for (const target of targets) {
|
|
346
|
+
const expression = this.#boundaryPatterns().flatMap(
|
|
347
|
+
(pattern, index) => [
|
|
348
|
+
...(index ? ['-o'] : []),
|
|
349
|
+
/[/\\]/.test(pattern) ? '-path' : '-name',
|
|
350
|
+
pattern.replaceAll('\\', '/'),
|
|
351
|
+
]
|
|
352
|
+
);
|
|
353
|
+
const found = await this.executor.run([
|
|
354
|
+
'find',
|
|
355
|
+
target,
|
|
356
|
+
'-xdev',
|
|
357
|
+
'-mindepth',
|
|
358
|
+
'1',
|
|
359
|
+
'(',
|
|
360
|
+
...expression,
|
|
361
|
+
')',
|
|
362
|
+
'-print',
|
|
363
|
+
'-quit',
|
|
364
|
+
]);
|
|
365
|
+
for (const line of found.stdout.split('\n').filter(Boolean)) {
|
|
366
|
+
policy.excludedPaths.add(line);
|
|
367
|
+
}
|
|
368
|
+
if (found.code !== 0) {
|
|
369
|
+
policy.excludedPaths.add(target);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// du cannot prune same-device mounts or excluded children. Skip their
|
|
373
|
+
// ancestors before either recursive measurement command can enter them.
|
|
374
|
+
targets = targets.filter((target) => !policy.removalReason(target));
|
|
375
|
+
}
|
|
293
376
|
for (const batch of batches(targets)) {
|
|
294
377
|
const result = await this.sh(USAGE_MANY_SCRIPT, batch);
|
|
295
378
|
for (const [target, body] of splitRecords(result.stdout)) {
|
|
@@ -344,6 +427,9 @@ export class ShellEnv {
|
|
|
344
427
|
* descending into matched directories) and prunes `skipNames`.
|
|
345
428
|
*/
|
|
346
429
|
async #find(roots, type, globs, maxDepth, skipNames) {
|
|
430
|
+
roots = roots.filter(
|
|
431
|
+
(root) => !this.scanPolicy || this.scanPolicy.allows(root)
|
|
432
|
+
);
|
|
347
433
|
if (roots.length === 0 || globs.length === 0) {
|
|
348
434
|
return [];
|
|
349
435
|
}
|
|
@@ -355,10 +441,12 @@ export class ShellEnv {
|
|
|
355
441
|
const argv = [
|
|
356
442
|
'find',
|
|
357
443
|
...roots,
|
|
444
|
+
'-xdev',
|
|
358
445
|
'-mindepth',
|
|
359
446
|
'1',
|
|
360
447
|
'-maxdepth',
|
|
361
448
|
String(maxDepth),
|
|
449
|
+
...this.#prunePaths(),
|
|
362
450
|
...(type === 'd'
|
|
363
451
|
? ['-type', 'd', '(', ...findNameExpression(globs), ')', '-print']
|
|
364
452
|
: []),
|
|
@@ -369,7 +457,47 @@ export class ShellEnv {
|
|
|
369
457
|
: ['-name', '']),
|
|
370
458
|
];
|
|
371
459
|
const result = await this.executor.run(argv);
|
|
372
|
-
return result.stdout
|
|
460
|
+
return result.stdout
|
|
461
|
+
.split('\n')
|
|
462
|
+
.filter(
|
|
463
|
+
(target) =>
|
|
464
|
+
target && (!this.scanPolicy || this.scanPolicy.allows(target))
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#boundaryPatterns() {
|
|
469
|
+
const policy = this.scanPolicy;
|
|
470
|
+
if (!policy) {
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
return [
|
|
474
|
+
...policy.exclude,
|
|
475
|
+
...policy.pruned,
|
|
476
|
+
'/var/lib/docker',
|
|
477
|
+
'/var/lib/containerd',
|
|
478
|
+
'/run/containerd',
|
|
479
|
+
'/var/lib/containers',
|
|
480
|
+
'/tmp/containerd-mount*',
|
|
481
|
+
'/private/tmp/containerd-mount*',
|
|
482
|
+
].map((pattern) =>
|
|
483
|
+
// find's * also matches slashes. Match optional globstar segments
|
|
484
|
+
// conservatively so its traversal cannot miss a zero-depth exclusion.
|
|
485
|
+
pattern
|
|
486
|
+
.replaceAll('\\', '/')
|
|
487
|
+
.replace(/\/\*\*$/, '')
|
|
488
|
+
.replaceAll('**/', '*')
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#prunePaths() {
|
|
493
|
+
return this.#boundaryPatterns().flatMap((pattern) => [
|
|
494
|
+
'(',
|
|
495
|
+
/[/\\]/.test(pattern) ? '-path' : '-name',
|
|
496
|
+
pattern,
|
|
497
|
+
')',
|
|
498
|
+
'-prune',
|
|
499
|
+
'-o',
|
|
500
|
+
]);
|
|
373
501
|
}
|
|
374
502
|
|
|
375
503
|
async remove(targets) {
|
|
@@ -384,14 +512,18 @@ export class ShellEnv {
|
|
|
384
512
|
}
|
|
385
513
|
|
|
386
514
|
async processes() {
|
|
387
|
-
const result = await this.
|
|
515
|
+
const result = await (this.probeExecutor ?? this.executor).run([
|
|
516
|
+
'sh',
|
|
517
|
+
'-c',
|
|
518
|
+
PROCESS_SCRIPT,
|
|
519
|
+
]);
|
|
388
520
|
if (result.code !== 0) {
|
|
389
521
|
return null;
|
|
390
522
|
}
|
|
391
523
|
const [selfPid, ...lines] = result.stdout.split('\n');
|
|
392
524
|
return lines
|
|
393
525
|
.map((line) =>
|
|
394
|
-
/^(\d+)\|([^|]*)\|([^|]*)\|([^|]*)\|([^|]*)\|(.*)$/.exec(line)
|
|
526
|
+
/^(\d+)\|([^|]*)\|([^|]*)\|([^|]*)\|([^|]*)\|([^|]*)\|(.*)$/.exec(line)
|
|
395
527
|
)
|
|
396
528
|
.filter((m) => m && !m[2].startsWith('Z '))
|
|
397
529
|
.map((m) => ({
|
|
@@ -400,14 +532,60 @@ export class ShellEnv {
|
|
|
400
532
|
name: m[3],
|
|
401
533
|
aliases: processAliases(m[4], m[5]),
|
|
402
534
|
cwd: m[6] || null,
|
|
535
|
+
command: m[7],
|
|
403
536
|
}))
|
|
404
537
|
.filter((proc) => String(proc.pid) !== selfPid.trim());
|
|
405
538
|
}
|
|
406
539
|
|
|
407
540
|
async openPaths() {
|
|
408
|
-
|
|
541
|
+
let result = await (this.probeExecutor ?? this.executor).run([
|
|
542
|
+
'sh',
|
|
543
|
+
'-c',
|
|
544
|
+
OPEN_PATHS_SCRIPT,
|
|
545
|
+
]);
|
|
546
|
+
this.probeDiagnostic = null;
|
|
547
|
+
if (
|
|
548
|
+
result.code !== 0 &&
|
|
549
|
+
this.executor.parent &&
|
|
550
|
+
this.executor.containerId
|
|
551
|
+
) {
|
|
552
|
+
const diagnostic = result.stderr.trim();
|
|
553
|
+
trace(
|
|
554
|
+
'retrying process inspection as privileged root',
|
|
555
|
+
this.id,
|
|
556
|
+
diagnostic
|
|
557
|
+
);
|
|
558
|
+
const inspector = containerExecutor(
|
|
559
|
+
this.executor.parent,
|
|
560
|
+
this.executor.containerId,
|
|
561
|
+
{ user: '0', privileged: true }
|
|
562
|
+
);
|
|
563
|
+
result = await inspector.run(['sh', '-c', OPEN_PATHS_SCRIPT]);
|
|
564
|
+
if (result.code !== 0) {
|
|
565
|
+
this.probeDiagnostic = `${diagnostic}; privileged inspection failed: ${result.stderr.trim()}`;
|
|
566
|
+
} else {
|
|
567
|
+
this.probeExecutor = inspector;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this.unreadableProcesses = result.stderr
|
|
571
|
+
.split('\n')
|
|
572
|
+
.filter((line) => line.startsWith('DSS_UNREADABLE|'))
|
|
573
|
+
.map((line) => {
|
|
574
|
+
const [, pid, uid, name, ...command] = line.split('|');
|
|
575
|
+
return {
|
|
576
|
+
pid: Number(pid),
|
|
577
|
+
uid: uid ? Number(uid) : null,
|
|
578
|
+
name,
|
|
579
|
+
command: command.join('|'),
|
|
580
|
+
};
|
|
581
|
+
});
|
|
409
582
|
if (result.code !== 0) {
|
|
410
|
-
|
|
583
|
+
this.probeDiagnostic ??= result.stderr.trim();
|
|
584
|
+
if (!this.unreadableProcesses.length) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
this.probeHint =
|
|
588
|
+
'some processes could not be inspected; run as root for full verification';
|
|
411
589
|
}
|
|
412
590
|
return new Set(
|
|
413
591
|
result.stdout
|
package/src/exec.js
CHANGED
|
@@ -59,6 +59,7 @@ export function runProcess(argv, options = {}) {
|
|
|
59
59
|
const stdout = [];
|
|
60
60
|
const stderr = [];
|
|
61
61
|
let captured = 0;
|
|
62
|
+
const captureLimit = options.maxCaptureBytes ?? MAX_CAPTURE_BYTES;
|
|
62
63
|
let settled = false;
|
|
63
64
|
const startedAt = Date.now();
|
|
64
65
|
const finish = (result) => {
|
|
@@ -80,7 +81,7 @@ export function runProcess(argv, options = {}) {
|
|
|
80
81
|
}, timeoutMs);
|
|
81
82
|
const collect = (target) => (chunk) => {
|
|
82
83
|
captured += chunk.length;
|
|
83
|
-
if (captured <=
|
|
84
|
+
if (captured <= captureLimit) {
|
|
84
85
|
target.push(chunk);
|
|
85
86
|
}
|
|
86
87
|
};
|
|
@@ -94,6 +95,7 @@ export function runProcess(argv, options = {}) {
|
|
|
94
95
|
code: code ?? 1,
|
|
95
96
|
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
96
97
|
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
98
|
+
truncated: captured > captureLimit,
|
|
97
99
|
});
|
|
98
100
|
});
|
|
99
101
|
child.stdin.on('error', () => {});
|
|
@@ -161,6 +163,7 @@ export function containerExecutor(parent, containerId, options = {}) {
|
|
|
161
163
|
docker,
|
|
162
164
|
'exec',
|
|
163
165
|
...(interactive ? ['-i'] : []),
|
|
166
|
+
...(options.privileged ? ['--privileged'] : []),
|
|
164
167
|
...(options.user ? ['--user', options.user] : []),
|
|
165
168
|
containerId,
|
|
166
169
|
...argv,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Verify unstaged work against cached remote refs without running filters. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createReadStream, promises as fsp } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
async function blob(root, file, algorithm) {
|
|
7
|
+
const target = path.join(root, file);
|
|
8
|
+
const stat = await fsp.lstat(target).catch((error) => {
|
|
9
|
+
if (['ENOENT', 'ENOTDIR'].includes(error.code)) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
throw error;
|
|
13
|
+
});
|
|
14
|
+
if (!stat) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) {
|
|
18
|
+
throw new Error('non-file change');
|
|
19
|
+
}
|
|
20
|
+
const link = stat.isSymbolicLink()
|
|
21
|
+
? Buffer.from(await fsp.readlink(target))
|
|
22
|
+
: null;
|
|
23
|
+
const hash = createHash(algorithm);
|
|
24
|
+
hash.update(`blob ${link ? link.length : stat.size}\0`);
|
|
25
|
+
if (link) {
|
|
26
|
+
hash.update(link);
|
|
27
|
+
} else {
|
|
28
|
+
for await (const chunk of createReadStream(target)) {
|
|
29
|
+
hash.update(chunk);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
oid: hash.digest('hex'),
|
|
34
|
+
mode: link ? '120000' : stat.mode & 0o111 ? '100755' : '100644',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** All changes must be preserved by one ref; staged changes remain protected. */
|
|
39
|
+
export async function preservedWork(root, git) {
|
|
40
|
+
const status = await git([
|
|
41
|
+
'status',
|
|
42
|
+
'--porcelain=v1',
|
|
43
|
+
'-z',
|
|
44
|
+
'--untracked-files=all',
|
|
45
|
+
]);
|
|
46
|
+
if (status.code !== 0) {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
const records = status.stdout.split('\0').filter(Boolean);
|
|
50
|
+
const untracked = records.filter((line) => line.startsWith('?? ')).length;
|
|
51
|
+
const result = { untracked, dirty: records.length };
|
|
52
|
+
if (
|
|
53
|
+
!records.length ||
|
|
54
|
+
records.length > 2000 ||
|
|
55
|
+
records.some((line) => ![' ', '?'].includes(line[0]))
|
|
56
|
+
) {
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
const refs = await git([
|
|
60
|
+
'for-each-ref',
|
|
61
|
+
'--format=%(refname)',
|
|
62
|
+
'refs/remotes',
|
|
63
|
+
]);
|
|
64
|
+
const format = await git(['rev-parse', '--show-object-format']);
|
|
65
|
+
if (refs.code !== 0 || format.code !== 0) {
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
const algorithm = format.stdout.trim();
|
|
69
|
+
if (!['sha1', 'sha256'].includes(algorithm)) {
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const files = new Map();
|
|
74
|
+
for (const record of records) {
|
|
75
|
+
const file = record.slice(3);
|
|
76
|
+
files.set(file, await blob(root, file, algorithm));
|
|
77
|
+
}
|
|
78
|
+
for (const ref of refs.stdout
|
|
79
|
+
.trim()
|
|
80
|
+
.split('\n')
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.slice(0, 100)) {
|
|
83
|
+
const tree = await git([
|
|
84
|
+
'ls-tree',
|
|
85
|
+
'-r',
|
|
86
|
+
'-z',
|
|
87
|
+
ref,
|
|
88
|
+
'--',
|
|
89
|
+
...[...files.keys()].map((file) => `:(literal)${file}`),
|
|
90
|
+
]);
|
|
91
|
+
if (tree.code !== 0) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const saved = new Map(
|
|
95
|
+
tree.stdout
|
|
96
|
+
.split('\0')
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.map((line) => {
|
|
99
|
+
const tab = line.indexOf('\t');
|
|
100
|
+
const [mode, , oid] = line.slice(0, tab).split(' ');
|
|
101
|
+
return [line.slice(tab + 1), { mode, oid }];
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
if (
|
|
105
|
+
[...files].every(([file, current]) =>
|
|
106
|
+
current
|
|
107
|
+
? saved.get(file)?.oid === current.oid &&
|
|
108
|
+
saved.get(file)?.mode === current.mode
|
|
109
|
+
: !saved.has(file)
|
|
110
|
+
)
|
|
111
|
+
) {
|
|
112
|
+
result.preservedOn = ref.replace(/^refs\/remotes\//, '');
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Unreadable files and unsupported changes keep the original blocker.
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
package/src/git.js
CHANGED
|
@@ -15,6 +15,7 @@ import os from 'node:os';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
|
|
17
17
|
import { trace } from './exec.js';
|
|
18
|
+
import { preservedWork } from './git-preserved.js';
|
|
18
19
|
|
|
19
20
|
const GIT_FLAGS = [
|
|
20
21
|
'--no-optional-locks',
|
|
@@ -93,7 +94,7 @@ export function stateBlockers(label, state) {
|
|
|
93
94
|
return [`${label}: ${state.error}`];
|
|
94
95
|
}
|
|
95
96
|
const problems = [];
|
|
96
|
-
if (state.dirty > 0) {
|
|
97
|
+
if (state.dirty > 0 && !state.preservedOn) {
|
|
97
98
|
problems.push(plural(state.dirty, 'uncommitted change'));
|
|
98
99
|
}
|
|
99
100
|
if (state.unpushed > 0) {
|
|
@@ -111,8 +112,9 @@ export class GitInspector {
|
|
|
111
112
|
/**
|
|
112
113
|
* @param {object} env environment adapter
|
|
113
114
|
*/
|
|
114
|
-
constructor(env) {
|
|
115
|
+
constructor(env, options = {}) {
|
|
115
116
|
this.env = env;
|
|
117
|
+
this.options = options;
|
|
116
118
|
this.states = new Map();
|
|
117
119
|
this.roots = new Map();
|
|
118
120
|
this.available = null;
|
|
@@ -224,6 +226,9 @@ export class GitInspector {
|
|
|
224
226
|
return state;
|
|
225
227
|
}
|
|
226
228
|
state.dirty = countLines(status.stdout);
|
|
229
|
+
if (this.options.recognizePreserved && state.dirty > 0) {
|
|
230
|
+
Object.assign(state, await preservedWork(root, git));
|
|
231
|
+
}
|
|
227
232
|
const unpushed = await git([
|
|
228
233
|
'log',
|
|
229
234
|
'HEAD',
|
|
@@ -259,7 +264,7 @@ export class GitInspector {
|
|
|
259
264
|
}
|
|
260
265
|
|
|
261
266
|
/** Protect an entire temporary work directory, including nested clones. */
|
|
262
|
-
async treeBlockers(target) {
|
|
267
|
+
async treeBlockers(target, { allowNoRepo = false } = {}) {
|
|
263
268
|
const dirs = await this.env.findDirs([target], {
|
|
264
269
|
globs: ['.git'],
|
|
265
270
|
maxDepth: 32,
|
|
@@ -278,6 +283,9 @@ export class GitInspector {
|
|
|
278
283
|
...files.map((file) => this.env.path.dirname(file)),
|
|
279
284
|
]);
|
|
280
285
|
if (roots.size === 0) {
|
|
286
|
+
if (allowNoRepo) {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
281
289
|
return [`${target}: no Git repository found; cannot verify work`];
|
|
282
290
|
}
|
|
283
291
|
if (roots.size > 50) {
|
|
@@ -303,9 +311,27 @@ export class GitInspector {
|
|
|
303
311
|
const result =
|
|
304
312
|
this.env.kind === 'host'
|
|
305
313
|
? await safeGitDirectory(root, (safeDir) =>
|
|
306
|
-
this.#git(
|
|
314
|
+
this.#git(
|
|
315
|
+
root,
|
|
316
|
+
[
|
|
317
|
+
'ls-files',
|
|
318
|
+
'--cached',
|
|
319
|
+
'--others',
|
|
320
|
+
'--exclude-standard',
|
|
321
|
+
'--',
|
|
322
|
+
relative,
|
|
323
|
+
],
|
|
324
|
+
safeDir
|
|
325
|
+
)
|
|
307
326
|
)
|
|
308
|
-
: await this.#git(root, [
|
|
327
|
+
: await this.#git(root, [
|
|
328
|
+
'ls-files',
|
|
329
|
+
'--cached',
|
|
330
|
+
'--others',
|
|
331
|
+
'--exclude-standard',
|
|
332
|
+
'--',
|
|
333
|
+
relative,
|
|
334
|
+
]);
|
|
309
335
|
return result.code === 0 ? countLines(result.stdout) : -1;
|
|
310
336
|
} catch {
|
|
311
337
|
return -1;
|
|
@@ -331,7 +357,9 @@ export class GitInspector {
|
|
|
331
357
|
return [`cannot verify Git tracking of ${target}`];
|
|
332
358
|
}
|
|
333
359
|
if (tracked > 0) {
|
|
334
|
-
return [
|
|
360
|
+
return [
|
|
361
|
+
`${plural(tracked, 'file')} inside ${target} tracked by Git or not ignored`,
|
|
362
|
+
];
|
|
335
363
|
}
|
|
336
364
|
return requireClean ? this.repoBlockers(root) : [];
|
|
337
365
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface ScanOptions {
|
|
|
15
15
|
staleAge?: Duration;
|
|
16
16
|
/** Alias of `staleAge`. */
|
|
17
17
|
olderThan?: Duration;
|
|
18
|
-
/** Inactivity before
|
|
18
|
+
/** Inactivity before lockfile-backed node_modules become `safe`, other dependency dirs `moderate` (`30d`). */
|
|
19
19
|
inactive?: Duration;
|
|
20
20
|
/** Items smaller than this are dropped (`1M`). */
|
|
21
21
|
minSize?: Size;
|
|
@@ -25,7 +25,7 @@ export interface ScanOptions {
|
|
|
25
25
|
docker?: boolean | null;
|
|
26
26
|
/** Docker nesting depth (1 = containers of the host daemon). */
|
|
27
27
|
dockerDepth?: number;
|
|
28
|
-
/** Container ids or
|
|
28
|
+
/** Container ids, names or unique prefixes to limit Docker work to. */
|
|
29
29
|
containers?: string[] | string;
|
|
30
30
|
scanners?: Array<'projects' | 'global' | 'versions' | 'agents' | 'system'>;
|
|
31
31
|
/** Only these ecosystems, rules or kinds. */
|
|
@@ -34,6 +34,8 @@ export interface ScanOptions {
|
|
|
34
34
|
exclude?: string[] | string;
|
|
35
35
|
includeVolumes?: boolean;
|
|
36
36
|
removeStoppedContainers?: boolean;
|
|
37
|
+
/** Opt-in age after which failed containers may pass the investigation hold. */
|
|
38
|
+
investigationMaxAge?: Duration | null;
|
|
37
39
|
/**
|
|
38
40
|
* Stopped containers (name, id or id prefix of 12+ characters) the
|
|
39
41
|
* operator approves one by one, including ones kept for investigation.
|
|
@@ -63,6 +65,7 @@ export interface CleanOptions extends ScanOptions {
|
|
|
63
65
|
confirm?: (item: Item) => boolean | Promise<boolean>;
|
|
64
66
|
/** `false` skips writing the audit log file. */
|
|
65
67
|
audit?: boolean;
|
|
68
|
+
signal?: AbortSignal;
|
|
66
69
|
onEntry?: (entry: AuditEntry, item: Item) => void;
|
|
67
70
|
}
|
|
68
71
|
|
|
@@ -97,6 +100,12 @@ export interface Item {
|
|
|
97
100
|
path: string | null;
|
|
98
101
|
paths: string[];
|
|
99
102
|
bytes: number;
|
|
103
|
+
/** Merged filesystem bytes, including image data, when measured in Docker. */
|
|
104
|
+
totalBytes?: number;
|
|
105
|
+
/** Immutable image bytes excluded from reclaimable totals. */
|
|
106
|
+
imageBytes?: number;
|
|
107
|
+
/** The object was discovered but its reclaimable size could not be measured. */
|
|
108
|
+
sizeUnknown?: boolean;
|
|
100
109
|
newestMtimeMs: number;
|
|
101
110
|
tier: Tier;
|
|
102
111
|
reason: string;
|
|
@@ -134,6 +143,7 @@ export interface EnvironmentDescriptor {
|
|
|
134
143
|
kind: 'host' | 'container';
|
|
135
144
|
depth: number;
|
|
136
145
|
chain: Array<{ containerId: string; name: string }>;
|
|
146
|
+
hint?: string | null;
|
|
137
147
|
disk?: Disk | null;
|
|
138
148
|
}
|
|
139
149
|
|
|
@@ -200,6 +210,8 @@ export interface AuditLog {
|
|
|
200
210
|
dryRun: boolean;
|
|
201
211
|
startedAt: string;
|
|
202
212
|
finishedAt: string | null;
|
|
213
|
+
aborted?: boolean;
|
|
214
|
+
error?: string;
|
|
203
215
|
entries: AuditEntry[];
|
|
204
216
|
freedBytes: number;
|
|
205
217
|
plannedBytes: number;
|