@git.zone/cli 2.23.0 → 2.24.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.
Files changed (28) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/mod_services/classes.dockercontainer.d.ts +81 -0
  3. package/dist_ts/mod_services/classes.dockercontainer.js +205 -10
  4. package/dist_ts/mod_services/classes.globalregistry.d.ts +10 -0
  5. package/dist_ts/mod_services/classes.globalregistry.js +23 -1
  6. package/dist_ts/mod_services/classes.serviceconfiguration.d.ts +39 -1
  7. package/dist_ts/mod_services/classes.serviceconfiguration.js +86 -20
  8. package/dist_ts/mod_services/classes.servicedatamarker.d.ts +93 -0
  9. package/dist_ts/mod_services/classes.servicedatamarker.js +166 -0
  10. package/dist_ts/mod_services/classes.servicemanager.d.ts +82 -5
  11. package/dist_ts/mod_services/classes.servicemanager.js +258 -64
  12. package/dist_ts/mod_services/classes.servicepruner.d.ts +102 -0
  13. package/dist_ts/mod_services/classes.servicepruner.js +410 -0
  14. package/dist_ts/mod_services/helpers.d.ts +15 -0
  15. package/dist_ts/mod_services/helpers.js +57 -1
  16. package/dist_ts/mod_services/index.js +307 -53
  17. package/package.json +3 -2
  18. package/readme.hints.md +88 -0
  19. package/readme.md +71 -5
  20. package/ts/00_commitinfo_data.ts +1 -1
  21. package/ts/mod_services/classes.dockercontainer.ts +244 -13
  22. package/ts/mod_services/classes.globalregistry.ts +27 -0
  23. package/ts/mod_services/classes.serviceconfiguration.ts +101 -22
  24. package/ts/mod_services/classes.servicedatamarker.ts +228 -0
  25. package/ts/mod_services/classes.servicemanager.ts +365 -72
  26. package/ts/mod_services/classes.servicepruner.ts +532 -0
  27. package/ts/mod_services/helpers.ts +60 -0
  28. package/ts/mod_services/index.ts +437 -58
@@ -0,0 +1,532 @@
1
+ import * as plugins from './mod.plugins.js';
2
+ import * as helpers from './helpers.js';
3
+ import { DockerContainer, type IContainerInspectInfo } from './classes.dockercontainer.js';
4
+ import { GlobalRegistry, type IRegisteredProject } from './classes.globalregistry.js';
5
+ import {
6
+ getServiceDataDirectory,
7
+ isSafeServiceDataPath,
8
+ isServiceOwnedByLabels,
9
+ readServiceDataMarker,
10
+ serviceImages,
11
+ serviceNames,
12
+ serviceToolLabel,
13
+ type TServiceName,
14
+ } from './classes.servicedatamarker.js';
15
+ import { logger } from '../gitzone.logging.js';
16
+
17
+ /** Default inactivity threshold before a project is even considered stale. */
18
+ export const defaultStaleDays = 30;
19
+
20
+ /**
21
+ * How a project was classified.
22
+ *
23
+ * - `live` — recent activity; never a reclamation candidate.
24
+ * - `stale` — project still on disk but inactive past the threshold.
25
+ * - `orphaned` — project directory no longer exists.
26
+ * - `unknown` — insufficient evidence to classify; never reclaimed.
27
+ */
28
+ export type TServiceProjectState = 'live' | 'stale' | 'orphaned' | 'unknown';
29
+
30
+ /** Why a candidate was left alone. */
31
+ export interface IServiceSkipReason {
32
+ target: string;
33
+ reason: string;
34
+ }
35
+
36
+ export interface IServiceDataDirReport {
37
+ service: TServiceName;
38
+ path: string;
39
+ exists: boolean;
40
+ sizeBytes: number;
41
+ reclaimable: boolean;
42
+ reason: string;
43
+ }
44
+
45
+ export interface IServiceContainerReport {
46
+ id: string;
47
+ name: string;
48
+ state: string;
49
+ running: boolean;
50
+ reclaimable: boolean;
51
+ reason: string;
52
+ }
53
+
54
+ export interface IServiceProjectReport {
55
+ projectPath: string;
56
+ projectName: string;
57
+ state: TServiceProjectState;
58
+ stateReason: string;
59
+ lastActive: number | null;
60
+ inactiveDays: number | null;
61
+ projectDirExists: boolean;
62
+ registered: boolean;
63
+ markerPresent: boolean;
64
+ containers: IServiceContainerReport[];
65
+ dataDirectories: IServiceDataDirReport[];
66
+ totalDataBytes: number;
67
+ reclaimableDataBytes: number;
68
+ }
69
+
70
+ export interface IServicePrunePlan {
71
+ staleDays: number;
72
+ dockerAvailable: boolean;
73
+ projects: IServiceProjectReport[];
74
+ registryEntriesToRemove: string[];
75
+ containersToRemove: Array<{ id: string; name: string; projectPath: string }>;
76
+ dataDirsToRemove: Array<{ projectPath: string; service: TServiceName; path: string; sizeBytes: number }>;
77
+ skipped: IServiceSkipReason[];
78
+ totals: {
79
+ projects: number;
80
+ totalDataBytes: number;
81
+ reclaimableDataBytes: number;
82
+ /** Bytes that would be reclaimable if the holding containers were stopped first. */
83
+ blockedByRunningBytes: number;
84
+ };
85
+ }
86
+
87
+ export interface IServicePrunerOptions {
88
+ staleDays?: number;
89
+ }
90
+
91
+ interface IProjectCandidate {
92
+ projectPath: string;
93
+ projectName: string;
94
+ registryEntry?: IRegisteredProject;
95
+ containers: IContainerInspectInfo[];
96
+ }
97
+
98
+ const dayInMs = 24 * 60 * 60 * 1000;
99
+
100
+ /**
101
+ * Plans and applies reclamation of resources created by `gitzone services`.
102
+ *
103
+ * Design mirrors `@git.zone/tsdocker`'s `TsDockerPruner`: build an explicit
104
+ * plan, print it, and only mutate on an explicit apply that re-verifies every
105
+ * single item. Nothing is ever matched by image or by bare name pattern, and
106
+ * every ambiguity resolves to "skip".
107
+ */
108
+ export class ServicePruner {
109
+ private docker: DockerContainer;
110
+ private globalRegistry: GlobalRegistry;
111
+ private staleDays: number;
112
+
113
+ constructor(optionsArg: IServicePrunerOptions = {}) {
114
+ this.docker = new DockerContainer();
115
+ this.globalRegistry = GlobalRegistry.getInstance();
116
+ this.staleDays = optionsArg.staleDays ?? defaultStaleDays;
117
+ }
118
+
119
+ /**
120
+ * Build a reclamation plan. Read-only: touches nothing.
121
+ */
122
+ public async createPlan(): Promise<IServicePrunePlan> {
123
+ const skipped: IServiceSkipReason[] = [];
124
+ const registryProjects = await this.globalRegistry.getAllProjects();
125
+
126
+ // Collect tool-owned containers. A Docker failure must not be mistaken for
127
+ // "no containers exist", so we record availability and fail closed below.
128
+ let dockerAvailable = true;
129
+ let labeledContainers: IContainerInspectInfo[] = [];
130
+ let runningMountSources: string[] = [];
131
+ try {
132
+ const ids = await this.docker.listIds([`label=git.zone.tool=${serviceToolLabel}`]);
133
+ labeledContainers = await this.docker.inspectMany(ids);
134
+ runningMountSources = await this.docker.listRunningMountSources();
135
+ } catch (error) {
136
+ dockerAvailable = false;
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ skipped.push({
139
+ target: 'docker',
140
+ reason: `docker unavailable (${message}); container and data reclamation disabled`,
141
+ });
142
+ }
143
+
144
+ // Containers recorded in the registry but not labeled (created by older
145
+ // versions). Their names are only trusted when exactly one registry entry
146
+ // claims them.
147
+ const nameClaims = new Map<string, string[]>();
148
+ for (const [projectPath, project] of Object.entries(registryProjects)) {
149
+ for (const containerName of Object.values(project.containers)) {
150
+ if (!containerName) {
151
+ continue;
152
+ }
153
+ const claims = nameClaims.get(containerName) || [];
154
+ claims.push(projectPath);
155
+ nameClaims.set(containerName, claims);
156
+ }
157
+ }
158
+
159
+ let legacyContainers: IContainerInspectInfo[] = [];
160
+ if (dockerAvailable && nameClaims.size > 0) {
161
+ try {
162
+ // One `docker ps -a` for all claimed names rather than one per name:
163
+ // each shell-out is a spawned child process, and this runs on every
164
+ // prune. Identity is established by the exact-name match below, so the
165
+ // listing only needs to be a superset.
166
+ const labeledIds = new Set(labeledContainers.map((container) => container.id));
167
+ const allIds = await this.docker.listIds([]);
168
+ const candidateIds = allIds.filter((id) => !labeledIds.has(id));
169
+ const inspected = await this.docker.inspectMany(candidateIds);
170
+ legacyContainers = inspected.filter((container) => nameClaims.has(container.name));
171
+ } catch (error) {
172
+ dockerAvailable = false;
173
+ const message = error instanceof Error ? error.message : String(error);
174
+ skipped.push({
175
+ target: 'docker',
176
+ reason: `docker unavailable (${message}); container and data reclamation disabled`,
177
+ });
178
+ }
179
+ }
180
+
181
+ // Group every known container and registry entry under a project path.
182
+ const candidates = new Map<string, IProjectCandidate>();
183
+ const ensureCandidate = (projectPathArg: string, projectNameArg: string): IProjectCandidate => {
184
+ const projectPath = plugins.path.resolve(projectPathArg);
185
+ const existing = candidates.get(projectPath);
186
+ if (existing) {
187
+ return existing;
188
+ }
189
+ const candidate: IProjectCandidate = {
190
+ projectPath,
191
+ projectName: projectNameArg,
192
+ containers: [],
193
+ };
194
+ candidates.set(projectPath, candidate);
195
+ return candidate;
196
+ };
197
+
198
+ for (const [projectPath, project] of Object.entries(registryProjects)) {
199
+ const candidate = ensureCandidate(projectPath, project.projectName);
200
+ candidate.registryEntry = project;
201
+ }
202
+
203
+ for (const container of labeledContainers) {
204
+ const projectPath = container.labels['git.zone.project-path'];
205
+ if (!projectPath) {
206
+ skipped.push({
207
+ target: container.name,
208
+ reason: 'labeled as tool-owned but carries no git.zone.project-path',
209
+ });
210
+ continue;
211
+ }
212
+ const candidate = ensureCandidate(projectPath, plugins.path.basename(projectPath));
213
+ candidate.containers.push(container);
214
+ }
215
+
216
+ // Projects whose container ownership cannot be resolved. Ambiguity must
217
+ // *tighten* the decision, never loosen it: if we cannot attribute a
218
+ // container, we also cannot claim that a project has no container running,
219
+ // so such projects are forced to `unknown` and nothing is reclaimed.
220
+ const ambiguousProjectPaths = new Set<string>();
221
+
222
+ for (const container of legacyContainers) {
223
+ const claims = nameClaims.get(container.name) || [];
224
+ if (claims.length !== 1) {
225
+ skipped.push({
226
+ target: container.name,
227
+ reason: `unlabeled container claimed by ${claims.length} registered projects; ownership ambiguous`,
228
+ });
229
+ for (const claim of claims) {
230
+ ambiguousProjectPaths.add(plugins.path.resolve(claim));
231
+ ensureCandidate(claim, plugins.path.basename(claim));
232
+ }
233
+ continue;
234
+ }
235
+ const projectPath = claims[0];
236
+ const candidate = ensureCandidate(projectPath, plugins.path.basename(projectPath));
237
+ candidate.containers.push(container);
238
+ }
239
+
240
+ // Classify and decide.
241
+ const projects: IServiceProjectReport[] = [];
242
+ const registryEntriesToRemove: string[] = [];
243
+ const containersToRemove: Array<{ id: string; name: string; projectPath: string }> = [];
244
+ const dataDirsToRemove: Array<{
245
+ projectPath: string;
246
+ service: TServiceName;
247
+ path: string;
248
+ sizeBytes: number;
249
+ }> = [];
250
+ let blockedByRunningBytes = 0;
251
+
252
+ for (const candidate of [...candidates.values()].sort((first, second) =>
253
+ first.projectPath.localeCompare(second.projectPath),
254
+ )) {
255
+ const projectDirExists = await plugins.smartfs.directory(candidate.projectPath).exists();
256
+ const marker = await readServiceDataMarker(candidate.projectPath);
257
+ const lastActive = candidate.registryEntry?.lastActive ?? null;
258
+ const inactiveDays =
259
+ lastActive === null ? null : Math.floor((Date.now() - lastActive) / dayInMs);
260
+
261
+ let state: TServiceProjectState;
262
+ let stateReason: string;
263
+ if (ambiguousProjectPaths.has(candidate.projectPath)) {
264
+ state = 'unknown';
265
+ stateReason =
266
+ 'a container name for this project is claimed by more than one project; ownership ambiguous';
267
+ } else if (!projectDirExists) {
268
+ state = 'orphaned';
269
+ stateReason = 'project directory no longer exists';
270
+ } else if (lastActive === null) {
271
+ state = 'unknown';
272
+ stateReason = 'no lastActive timestamp recorded; cannot prove inactivity';
273
+ } else if ((inactiveDays as number) > this.staleDays) {
274
+ state = 'stale';
275
+ stateReason = `inactive for ${inactiveDays} days (threshold ${this.staleDays})`;
276
+ } else {
277
+ state = 'live';
278
+ stateReason = `active ${inactiveDays} day(s) ago`;
279
+ }
280
+
281
+ const anyContainerRunning = candidate.containers.some((container) => container.running);
282
+
283
+ // Containers
284
+ const containerReports: IServiceContainerReport[] = [];
285
+ for (const container of candidate.containers) {
286
+ let reclaimable = false;
287
+ let reason: string;
288
+ if (!dockerAvailable) {
289
+ reason = 'docker unavailable';
290
+ } else if (state === 'live') {
291
+ reason = 'project is live';
292
+ } else if (state === 'unknown') {
293
+ reason = 'project state unknown';
294
+ } else if (container.running) {
295
+ reason = 'container is running; stop it first';
296
+ } else {
297
+ reclaimable = true;
298
+ reason = `project ${state}, container stopped`;
299
+ }
300
+ containerReports.push({
301
+ id: container.id,
302
+ name: container.name,
303
+ state: container.state,
304
+ running: container.running,
305
+ reclaimable,
306
+ reason,
307
+ });
308
+ if (reclaimable) {
309
+ containersToRemove.push({
310
+ id: container.id,
311
+ name: container.name,
312
+ projectPath: candidate.projectPath,
313
+ });
314
+ }
315
+ }
316
+
317
+ // Data directories
318
+ const ownershipProven = !!marker || !!candidate.registryEntry;
319
+ const dataReports: IServiceDataDirReport[] = [];
320
+ let totalDataBytes = 0;
321
+ let reclaimableDataBytes = 0;
322
+
323
+ for (const service of serviceNames) {
324
+ const dataPath = getServiceDataDirectory(candidate.projectPath, service);
325
+ const exists = await plugins.smartfs.directory(dataPath).exists();
326
+ const sizeBytes = exists ? await helpers.getDirectorySize(dataPath) : 0;
327
+ totalDataBytes += sizeBytes;
328
+
329
+ let reclaimable = false;
330
+ let reason: string;
331
+ const mountConflict = runningMountSources.find((source) =>
332
+ helpers.pathsOverlap(dataPath, source),
333
+ );
334
+
335
+ if (!exists) {
336
+ reason = 'no data directory';
337
+ } else if (!dockerAvailable) {
338
+ reason = 'docker unavailable; cannot prove no container uses this data';
339
+ } else if (!isSafeServiceDataPath(candidate.projectPath, dataPath)) {
340
+ reason = 'path outside the allowed service data shape';
341
+ } else if (!ownershipProven) {
342
+ reason = 'no marker and no registry entry; ownership unproven';
343
+ } else if (state === 'live') {
344
+ reason = 'project is live';
345
+ } else if (state === 'unknown') {
346
+ reason = 'project state unknown';
347
+ } else if (state === 'orphaned') {
348
+ // Data lives inside the project directory, which is gone.
349
+ reason = 'project directory absent';
350
+ } else if (mountConflict) {
351
+ reason = `mounted by a running container (${mountConflict})`;
352
+ blockedByRunningBytes += sizeBytes;
353
+ } else if (anyContainerRunning) {
354
+ reason = 'a container for this project is still running';
355
+ blockedByRunningBytes += sizeBytes;
356
+ } else {
357
+ reclaimable = true;
358
+ reason = `project stale, no container running, ownership proven by ${marker ? 'marker' : 'registry'}`;
359
+ }
360
+
361
+ dataReports.push({ service, path: dataPath, exists, sizeBytes, reclaimable, reason });
362
+ if (reclaimable) {
363
+ reclaimableDataBytes += sizeBytes;
364
+ dataDirsToRemove.push({
365
+ projectPath: candidate.projectPath,
366
+ service,
367
+ path: dataPath,
368
+ sizeBytes,
369
+ });
370
+ }
371
+ }
372
+
373
+ // Registry entry: safe to drop only once no container remains. For
374
+ // containers created before labels existed, the registry claim is the only
375
+ // thing that can ever identify them again — dropping it while one still
376
+ // exists would make it invisible to prune, `stop -g` and `cleanup -g`,
377
+ // leaving it running forever under `restart: unless-stopped`.
378
+ if (state === 'orphaned' && candidate.registryEntry) {
379
+ if (candidate.containers.length === 0) {
380
+ registryEntriesToRemove.push(candidate.projectPath);
381
+ } else {
382
+ skipped.push({
383
+ target: candidate.projectPath,
384
+ reason: `project directory is gone but ${candidate.containers.length} container(s) remain; keeping the registry entry so they stay identifiable`,
385
+ });
386
+ }
387
+ }
388
+
389
+ projects.push({
390
+ projectPath: candidate.projectPath,
391
+ projectName: candidate.registryEntry?.projectName || candidate.projectName,
392
+ state,
393
+ stateReason,
394
+ lastActive,
395
+ inactiveDays,
396
+ projectDirExists,
397
+ registered: !!candidate.registryEntry,
398
+ markerPresent: !!marker,
399
+ containers: containerReports,
400
+ dataDirectories: dataReports,
401
+ totalDataBytes,
402
+ reclaimableDataBytes,
403
+ });
404
+ }
405
+
406
+ return {
407
+ staleDays: this.staleDays,
408
+ dockerAvailable,
409
+ projects,
410
+ registryEntriesToRemove,
411
+ containersToRemove,
412
+ dataDirsToRemove,
413
+ skipped,
414
+ totals: {
415
+ projects: projects.length,
416
+ totalDataBytes: projects.reduce((sum, project) => sum + project.totalDataBytes, 0),
417
+ reclaimableDataBytes: projects.reduce(
418
+ (sum, project) => sum + project.reclaimableDataBytes,
419
+ 0,
420
+ ),
421
+ blockedByRunningBytes,
422
+ },
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Apply a plan, re-verifying every item immediately before it is removed.
428
+ *
429
+ * Any drift between plan and current reality aborts with an error rather than
430
+ * removing something that may since have become live.
431
+ */
432
+ public async applyPlan(planArg: IServicePrunePlan): Promise<void> {
433
+ if (!planArg.dockerAvailable && (planArg.containersToRemove.length > 0 || planArg.dataDirsToRemove.length > 0)) {
434
+ throw new Error('Refusing to apply container or data removal without a reachable Docker daemon');
435
+ }
436
+
437
+ // Containers first, so their mounts stop protecting the data we then remove.
438
+ if (planArg.containersToRemove.length > 0) {
439
+ const current = await this.docker.inspectMany(
440
+ planArg.containersToRemove.map((container) => container.id),
441
+ );
442
+ for (const planned of planArg.containersToRemove) {
443
+ const container = current.find((candidate) => candidate.id === planned.id);
444
+ if (!container) {
445
+ throw new Error(`Refusing to remove vanished container: ${planned.name}`);
446
+ }
447
+ if (container.running) {
448
+ throw new Error(`Refusing to remove running container: ${container.name}`);
449
+ }
450
+ const labelOwned = isServiceOwnedByLabels(container.labels);
451
+ const registryOwned = await this.isClaimedByExactlyOneProject(container.name, planned.projectPath);
452
+ if (!labelOwned && !registryOwned) {
453
+ throw new Error(`Refusing to remove container with unproven ownership: ${container.name}`);
454
+ }
455
+ if (!(await this.docker.remove(container.name, false))) {
456
+ throw new Error(`Failed to remove container: ${container.name}`);
457
+ }
458
+ logger.log('ok', `Removed container ${container.name}`);
459
+ }
460
+ }
461
+
462
+ // Data directories. Cheap, local invariants are re-checked first so an
463
+ // invalid plan is rejected before any Docker call and before anything is
464
+ // deleted; only then is the live mount table consulted.
465
+ if (planArg.dataDirsToRemove.length > 0) {
466
+ const currentProjects = await this.globalRegistry.getAllProjects();
467
+ for (const planned of planArg.dataDirsToRemove) {
468
+ if (!isSafeServiceDataPath(planned.projectPath, planned.path)) {
469
+ throw new Error(`Refusing unsafe service data path: ${planned.path}`);
470
+ }
471
+ const marker = await readServiceDataMarker(planned.projectPath);
472
+ const registered = await this.globalRegistry.isRegistered(planned.projectPath);
473
+ if (!marker && !registered) {
474
+ throw new Error(`Refusing data path with unproven ownership: ${planned.path}`);
475
+ }
476
+ // Re-verify staleness: the project may have been started between
477
+ // planning and applying, which would make this live data.
478
+ const currentEntry = Object.entries(currentProjects).find(
479
+ ([projectPath]) => plugins.path.resolve(projectPath) === planned.projectPath,
480
+ )?.[1];
481
+ if (currentEntry) {
482
+ const inactiveDays = Math.floor((Date.now() - currentEntry.lastActive) / dayInMs);
483
+ if (inactiveDays <= this.staleDays) {
484
+ throw new Error(
485
+ `Refusing data path for a project that became active: ${planned.path} (${inactiveDays}d)`,
486
+ );
487
+ }
488
+ }
489
+ }
490
+
491
+ const runningMountSources = await this.docker.listRunningMountSources();
492
+ for (const planned of planArg.dataDirsToRemove) {
493
+ const conflict = runningMountSources.find((source) =>
494
+ helpers.pathsOverlap(planned.path, source),
495
+ );
496
+ if (conflict) {
497
+ throw new Error(`Refusing mounted data path: ${planned.path} is used by ${conflict}`);
498
+ }
499
+ if (!(await plugins.smartfs.directory(planned.path).exists())) {
500
+ continue;
501
+ }
502
+ // Escalates to a privileged one-off container when the contents belong to
503
+ // the container user; throws rather than deleting only part of the tree.
504
+ await this.docker.removeDataDirectory(planned.path, serviceImages[planned.service]);
505
+ logger.log('ok', `Removed data directory ${planned.path}`);
506
+ }
507
+ }
508
+
509
+ // Registry metadata last, so a failure above leaves the claim intact.
510
+ for (const projectPath of planArg.registryEntriesToRemove) {
511
+ if (await plugins.smartfs.directory(projectPath).exists()) {
512
+ throw new Error(`Refusing to unregister an existing project: ${projectPath}`);
513
+ }
514
+ await this.globalRegistry.unregisterProject(projectPath);
515
+ logger.log('ok', `Unregistered ${projectPath}`);
516
+ }
517
+ }
518
+
519
+ private async isClaimedByExactlyOneProject(
520
+ containerNameArg: string,
521
+ projectPathArg: string,
522
+ ): Promise<boolean> {
523
+ const projects = await this.globalRegistry.getAllProjects();
524
+ const claims: string[] = [];
525
+ for (const [projectPath, project] of Object.entries(projects)) {
526
+ if (Object.values(project.containers).includes(containerNameArg)) {
527
+ claims.push(plugins.path.resolve(projectPath));
528
+ }
529
+ }
530
+ return claims.length === 1 && claims[0] === plugins.path.resolve(projectPathArg);
531
+ }
532
+ }
@@ -90,6 +90,66 @@ export const formatBytes = (bytes: number): string => {
90
90
  return `${size.toFixed(2)} ${units[unitIndex]}`;
91
91
  };
92
92
 
93
+ /**
94
+ * True when either path contains the other, or they are the same path.
95
+ *
96
+ * Used to decide whether a deletion candidate collides with a directory a
97
+ * running container has mounted. Containment in *either* direction counts: a
98
+ * parent mount protects its children, and a child mount protects its parent.
99
+ */
100
+ export const pathsOverlap = (firstPathArg: string, secondPathArg: string): boolean => {
101
+ const firstPath = plugins.path.resolve(firstPathArg);
102
+ const secondPath = plugins.path.resolve(secondPathArg);
103
+ const contains = (containerPath: string, candidatePath: string): boolean => {
104
+ const relative = plugins.path.relative(containerPath, candidatePath);
105
+ if (relative === '') {
106
+ return true;
107
+ }
108
+ return !!relative && !relative.startsWith('..') && !plugins.path.isAbsolute(relative);
109
+ };
110
+ return contains(firstPath, secondPath) || contains(secondPath, firstPath);
111
+ };
112
+
113
+ /**
114
+ * Total size in bytes of a directory tree.
115
+ *
116
+ * Symlinks are counted by their own size and never followed, so a symlink
117
+ * inside a data directory can neither inflate the report nor walk out of it.
118
+ */
119
+ export const getDirectorySize = async (
120
+ directoryPathArg: string,
121
+ maxDepthArg: number = 64,
122
+ ): Promise<number> => {
123
+ let total = 0;
124
+ const walk = async (currentPath: string, depth: number): Promise<void> => {
125
+ // Bind mounts inside a data directory are user-controlled and can form a
126
+ // cycle; a depth cap keeps this from recursing until it exhausts memory.
127
+ if (depth > maxDepthArg) {
128
+ return;
129
+ }
130
+ const entries = await plugins.fs
131
+ .readdir(currentPath, { withFileTypes: true })
132
+ .catch(() => []);
133
+ for (const entry of entries) {
134
+ const entryPath = plugins.path.join(currentPath, entry.name);
135
+ // isDirectory() here is lstat-based, so symlinks are counted by their own
136
+ // size and never followed.
137
+ if (entry.isDirectory()) {
138
+ await walk(entryPath, depth + 1);
139
+ continue;
140
+ }
141
+ try {
142
+ const stats = await plugins.fs.lstat(entryPath);
143
+ total += stats.size;
144
+ } catch {
145
+ // entry disappeared between readdir and lstat; ignore
146
+ }
147
+ }
148
+ };
149
+ await walk(plugins.path.resolve(directoryPathArg), 0);
150
+ return total;
151
+ };
152
+
93
153
  /**
94
154
  * Get the local network IP address
95
155
  */