@cfour-hi/dsh-plugin-devicex 1.5.0 → 1.7.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/client.js +539 -285
- package/host/actions-darwin.js +230 -21
- package/host/actions-win32.js +38 -4
- package/host/collector-win32.js +3 -0
- package/host/footprint-core.js +399 -0
- package/host/footprint-darwin.js +663 -0
- package/host/footprint-win32.js +411 -0
- package/host/footprint.js +284 -0
- package/host/transport.js +27 -5
- package/host/uninstall.js +244 -0
- package/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X - uninstall footprint scanner (Windows).
|
|
3
|
+
*
|
|
4
|
+
* Read-only, and deliberately conservative for 1.6: it reports the leftover
|
|
5
|
+
* locations it can identify with high confidence and refuses to guess beyond
|
|
6
|
+
* that. In particular it does **not** move anything to the Recycle Bin - the
|
|
7
|
+
* cleanup leg of the pipeline is not implemented for Windows yet, because the
|
|
8
|
+
* shell-based move (`Shell.Application` / `SHFileOperation`) could not be
|
|
9
|
+
* validated on a real Windows host and this plugin never ships an unverified
|
|
10
|
+
* destructive path.
|
|
11
|
+
*
|
|
12
|
+
* Discovery sources, strongest first:
|
|
13
|
+
*
|
|
14
|
+
* - `%LOCALAPPDATA%\Packages\<PackageFamilyName>` - MSIX/Store apps own an
|
|
15
|
+
* exact container, so no naming heuristic is involved;
|
|
16
|
+
* - the MSI/uninstall-key `InstallLocation` and the reverse-DNS registry trees
|
|
17
|
+
* under `Software\<Vendor>` that match the display name;
|
|
18
|
+
* - `%APPDATA%`, `%LOCALAPPDATA%` and `%PROGRAMDATA%` product directories
|
|
19
|
+
* whose name matches the display name (tier B, never preselected).
|
|
20
|
+
*
|
|
21
|
+
* Windows has no bundle identifier, so name matching is weaker here than on
|
|
22
|
+
* macOS and the `conflict` tier does more of the work.
|
|
23
|
+
*
|
|
24
|
+
* ASCII-only source: `scripts/verify-cleanup.mjs` asserts this file contains no
|
|
25
|
+
* non-ASCII byte, because PowerShell input decodes with the console code page.
|
|
26
|
+
*
|
|
27
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/footprint-win32
|
|
28
|
+
*/
|
|
29
|
+
import * as fsp from 'node:fs/promises';
|
|
30
|
+
import * as os from 'node:os';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
|
|
33
|
+
import { classify, compactSlug, dedupeApps, selectedBytes, slugify, toAppContext, totalBytes } from './footprint-core.js';
|
|
34
|
+
|
|
35
|
+
/** Host platform for this implementation. */
|
|
36
|
+
export const PLATFORM = 'win32';
|
|
37
|
+
|
|
38
|
+
/** Maximum candidates returned in one report. */
|
|
39
|
+
const MAX_ITEMS = 200;
|
|
40
|
+
/** Maximum directory entries visited per candidate size measurement. */
|
|
41
|
+
const MAX_ENTRIES = 40_000;
|
|
42
|
+
/** Wall-clock budget per candidate size walk (ms). */
|
|
43
|
+
const TIME_BUDGET_MS = 3_000;
|
|
44
|
+
/** Maximum diagnostic strings kept. */
|
|
45
|
+
const MAX_ERRORS = 20;
|
|
46
|
+
/** Maximum installed apps read for conflict detection. */
|
|
47
|
+
const MAX_APPS = 600;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Environment variables this module reads, resolved at call time so tests can
|
|
51
|
+
* inject a profile without touching the real one.
|
|
52
|
+
* @returns {Record<string, string>}
|
|
53
|
+
*/
|
|
54
|
+
function profileEnv() {
|
|
55
|
+
const env = process.env;
|
|
56
|
+
return {
|
|
57
|
+
appData: env.APPDATA ?? '',
|
|
58
|
+
localAppData: env.LOCALAPPDATA ?? '',
|
|
59
|
+
programData: env.ProgramData ?? env.PROGRAMDATA ?? '',
|
|
60
|
+
userProfile: env.USERPROFILE ?? os.homedir(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Directories swept for product-level leftovers, with their admin flag.
|
|
66
|
+
* @param {Record<string, string>} env
|
|
67
|
+
* @returns {Array<{root: string, requiresAdmin: boolean}>}
|
|
68
|
+
*/
|
|
69
|
+
export function sweepRootsFor(env) {
|
|
70
|
+
/** @type {Array<{root: string, requiresAdmin: boolean}>} */
|
|
71
|
+
const roots = [];
|
|
72
|
+
if (env.appData) roots.push({ root: path.join(env.appData), requiresAdmin: false });
|
|
73
|
+
if (env.localAppData) roots.push({ root: path.join(env.localAppData), requiresAdmin: false });
|
|
74
|
+
if (env.programData) roots.push({ root: path.join(env.programData), requiresAdmin: true });
|
|
75
|
+
return roots;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every root this scanner may ever report a candidate from.
|
|
80
|
+
*
|
|
81
|
+
* The dispatcher uses this list, not the report, to bound deletion.
|
|
82
|
+
*/
|
|
83
|
+
export const SAFE_ROOTS = Object.freeze(
|
|
84
|
+
(() => {
|
|
85
|
+
const env = profileEnv();
|
|
86
|
+
/** @type {string[]} */
|
|
87
|
+
const roots = [];
|
|
88
|
+
if (env.appData) roots.push(env.appData);
|
|
89
|
+
if (env.localAppData) roots.push(env.localAppData);
|
|
90
|
+
if (env.programData) roots.push(env.programData);
|
|
91
|
+
if (env.userProfile) roots.push(env.userProfile);
|
|
92
|
+
return roots;
|
|
93
|
+
})(),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Shorten an error message to one JSON-friendly line.
|
|
98
|
+
* @param {unknown} value
|
|
99
|
+
* @param {number} [max]
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function shortErr(value, max = 200) {
|
|
103
|
+
const text = String(value ?? 'unknown error')
|
|
104
|
+
.replace(/\s+/g, ' ')
|
|
105
|
+
.trim();
|
|
106
|
+
return text.length > max ? `${text.slice(0, max - 1) + '...'}` : text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `%VAR%` placeholder for display.
|
|
111
|
+
* @param {string} absolute
|
|
112
|
+
* @param {Record<string, string>} env
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function displayPath(absolute, env) {
|
|
116
|
+
const pairs = [
|
|
117
|
+
['%APPDATA%', env.appData],
|
|
118
|
+
['%LOCALAPPDATA%', env.localAppData],
|
|
119
|
+
['%PROGRAMDATA%', env.programData],
|
|
120
|
+
['%USERPROFILE%', env.userProfile],
|
|
121
|
+
];
|
|
122
|
+
for (const [token, base] of pairs) {
|
|
123
|
+
if (typeof base === 'string' && base !== '' && absolute.toLowerCase().startsWith(base.toLowerCase())) {
|
|
124
|
+
return token + absolute.slice(base.length);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return absolute;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Bounded recursive size walk using `lstat` only; never follows reparse points.
|
|
132
|
+
* @param {string} root
|
|
133
|
+
* @param {{maxEntries?: number, timeBudgetMs?: number}} [options]
|
|
134
|
+
* @returns {Promise<{bytes: number, files: number, truncated: string|null}>}
|
|
135
|
+
*/
|
|
136
|
+
export async function measurePath(root, options = {}) {
|
|
137
|
+
const maxEntries = options.maxEntries ?? MAX_ENTRIES;
|
|
138
|
+
const timeBudgetMs = options.timeBudgetMs ?? TIME_BUDGET_MS;
|
|
139
|
+
const deadline = Date.now() + timeBudgetMs;
|
|
140
|
+
let bytes = 0;
|
|
141
|
+
let files = 0;
|
|
142
|
+
let entries = 0;
|
|
143
|
+
/** @type {string|null} */
|
|
144
|
+
let truncated = null;
|
|
145
|
+
/** @type {string[]} */
|
|
146
|
+
let level = [root];
|
|
147
|
+
|
|
148
|
+
outer: while (level.length > 0) {
|
|
149
|
+
/** @type {string[]} */
|
|
150
|
+
const next = [];
|
|
151
|
+
for (const current of level) {
|
|
152
|
+
if (Date.now() > deadline) {
|
|
153
|
+
truncated = `timed out after ${timeBudgetMs} ms`;
|
|
154
|
+
break outer;
|
|
155
|
+
}
|
|
156
|
+
let stat;
|
|
157
|
+
try {
|
|
158
|
+
stat = await fsp.lstat(current);
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (stat.isSymbolicLink()) continue;
|
|
163
|
+
if (stat.isFile()) {
|
|
164
|
+
bytes += stat.size;
|
|
165
|
+
files += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!stat.isDirectory()) continue;
|
|
169
|
+
entries += 1;
|
|
170
|
+
if (entries > maxEntries) {
|
|
171
|
+
truncated = `stopped after ${maxEntries} entries`;
|
|
172
|
+
break outer;
|
|
173
|
+
}
|
|
174
|
+
let children;
|
|
175
|
+
try {
|
|
176
|
+
children = await fsp.readdir(current);
|
|
177
|
+
} catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
for (const child of children) next.push(path.join(current, child));
|
|
181
|
+
}
|
|
182
|
+
level = next;
|
|
183
|
+
}
|
|
184
|
+
return { bytes, files, truncated };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Stable, scan-local item id derived from the path (FNV-1a).
|
|
189
|
+
* @param {string} absolute
|
|
190
|
+
* @returns {string}
|
|
191
|
+
*/
|
|
192
|
+
export function itemId(absolute) {
|
|
193
|
+
let hash = 0x811c9dc5;
|
|
194
|
+
for (let index = 0; index < absolute.length; index += 1) {
|
|
195
|
+
hash ^= absolute.charCodeAt(index);
|
|
196
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
197
|
+
}
|
|
198
|
+
return `fp_${hash.toString(16).padStart(8, '0')}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Scan one application's leftover footprint.
|
|
203
|
+
*
|
|
204
|
+
* @param {{appPath?: string, installedApps?: Array<object>, name?: string,
|
|
205
|
+
* bundleId?: string|null, packageFamilyName?: string|null}} [options]
|
|
206
|
+
* @returns {Promise<object>} a `FootprintReport` without `scanId`
|
|
207
|
+
*/
|
|
208
|
+
export async function scanFootprint(options = {}) {
|
|
209
|
+
const startedAt = Date.now();
|
|
210
|
+
/** @type {string[]} */
|
|
211
|
+
const errors = [];
|
|
212
|
+
const env = profileEnv();
|
|
213
|
+
const appPath = typeof options.appPath === 'string' ? options.appPath : '';
|
|
214
|
+
|
|
215
|
+
const base = {
|
|
216
|
+
appPath,
|
|
217
|
+
bundleId: typeof options.bundleId === 'string' && options.bundleId !== '' ? options.bundleId : null,
|
|
218
|
+
sandboxed: false,
|
|
219
|
+
targetedRoots: [],
|
|
220
|
+
generatedAt: startedAt,
|
|
221
|
+
durationMs: 0,
|
|
222
|
+
items: [],
|
|
223
|
+
totalBytes: 0,
|
|
224
|
+
selectedBytes: 0,
|
|
225
|
+
errors,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const appName =
|
|
229
|
+
typeof options.name === 'string' && options.name !== ''
|
|
230
|
+
? options.name
|
|
231
|
+
: appPath !== ''
|
|
232
|
+
? path.basename(appPath)
|
|
233
|
+
: '';
|
|
234
|
+
if (appName === '') {
|
|
235
|
+
errors.push('missing application name');
|
|
236
|
+
return { ...base, durationMs: Date.now() - startedAt };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const subject = toAppContext({ name: appName, bundleId: base.bundleId });
|
|
240
|
+
const installedApps = Array.isArray(options.installedApps)
|
|
241
|
+
? options.installedApps.map((app) => toAppContext(app))
|
|
242
|
+
: [subject];
|
|
243
|
+
const roster = dedupeApps(installedApps);
|
|
244
|
+
|
|
245
|
+
/** @type {Array<{name: string, absolute: string, requiresAdmin: boolean, source: string, holdsSubject?: boolean}>} */
|
|
246
|
+
const candidates = [];
|
|
247
|
+
|
|
248
|
+
// 1. MSIX containers: an exact, unambiguous match.
|
|
249
|
+
//
|
|
250
|
+
// The PFN is `PackageName_PublisherId`, so it is derived from the app name
|
|
251
|
+
// rather than required from the caller: a collector that only knows the
|
|
252
|
+
// display name still gets the strongest signal Windows offers.
|
|
253
|
+
if (env.localAppData !== '') {
|
|
254
|
+
const packagesDir = path.join(env.localAppData, 'Packages');
|
|
255
|
+
const subjectSlug = compactSlug(appName);
|
|
256
|
+
const hinted = typeof options.packageFamilyName === 'string' ? options.packageFamilyName : '';
|
|
257
|
+
let entries = [];
|
|
258
|
+
try {
|
|
259
|
+
entries = await fsp.readdir(packagesDir, { withFileTypes: true });
|
|
260
|
+
} catch {
|
|
261
|
+
// A machine with no Store apps has no Packages directory at all.
|
|
262
|
+
}
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
if (!entry.isDirectory()) continue;
|
|
265
|
+
const isHinted = hinted !== '' && entry.name.toLowerCase() === hinted.toLowerCase();
|
|
266
|
+
// `Acme.Widget_8wekyb3d8bbwe` -> `acmewidget` (separators removed, because
|
|
267
|
+
// a package name has no separator convention we can rely on).
|
|
268
|
+
const packageSlug = compactSlug(entry.name.split('_')[0]);
|
|
269
|
+
if (!isHinted && (subjectSlug === '' || packageSlug !== subjectSlug)) continue;
|
|
270
|
+
candidates.push({
|
|
271
|
+
name: entry.name,
|
|
272
|
+
absolute: path.join(packagesDir, entry.name),
|
|
273
|
+
requiresAdmin: false,
|
|
274
|
+
source: 'msix',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 2. Product directories under the three profile roots, matched by name.
|
|
280
|
+
for (const { root, requiresAdmin } of sweepRootsFor(env)) {
|
|
281
|
+
let entries;
|
|
282
|
+
try {
|
|
283
|
+
entries = await fsp.readdir(root, { withFileTypes: true });
|
|
284
|
+
} catch {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (!entry.isDirectory()) continue;
|
|
289
|
+
candidates.push({
|
|
290
|
+
name: entry.name,
|
|
291
|
+
absolute: path.join(root, entry.name),
|
|
292
|
+
requiresAdmin,
|
|
293
|
+
source: 'registry',
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** @type {Array<object>} */
|
|
299
|
+
const items = [];
|
|
300
|
+
|
|
301
|
+
// The install directory (when the registry told us where it is) is the
|
|
302
|
+
// Windows analogue of "the bundle itself": always first, always selected.
|
|
303
|
+
if (appPath !== '') {
|
|
304
|
+
const measure = await measurePath(appPath);
|
|
305
|
+
items.push({
|
|
306
|
+
id: itemId(appPath),
|
|
307
|
+
kind: 'app',
|
|
308
|
+
path: appPath,
|
|
309
|
+
displayPath: displayPath(appPath, env),
|
|
310
|
+
bytes: measure.bytes,
|
|
311
|
+
files: measure.files,
|
|
312
|
+
confidence: 'high',
|
|
313
|
+
source: 'registry',
|
|
314
|
+
reason: 'The application install directory.',
|
|
315
|
+
requiresAdmin: false,
|
|
316
|
+
exists: true,
|
|
317
|
+
...(measure.truncated ? { warning: measure.truncated } : {}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** @type {Set<string>} */
|
|
322
|
+
const seen = new Set(items.map((item) => item.path));
|
|
323
|
+
|
|
324
|
+
const classified = [];
|
|
325
|
+
for (const candidate of candidates) {
|
|
326
|
+
if (seen.has(candidate.absolute)) continue;
|
|
327
|
+
const verdict =
|
|
328
|
+
candidate.source === 'msix'
|
|
329
|
+
? {
|
|
330
|
+
confidence: /** @type {const} */ ('high'),
|
|
331
|
+
source: /** @type {const} */ ('msix'),
|
|
332
|
+
reason: 'MSIX package container for this application.',
|
|
333
|
+
alsoMatchedBy: [],
|
|
334
|
+
}
|
|
335
|
+
: classify(candidate, subject, roster, {});
|
|
336
|
+
if (verdict === null) continue;
|
|
337
|
+
seen.add(candidate.absolute);
|
|
338
|
+
classified.push({ ...candidate, ...verdict });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const rank = { high: 0, medium: 1, conflict: 2, shared: 3 };
|
|
342
|
+
classified.sort((left, right) => {
|
|
343
|
+
const diff = (rank[left.confidence] ?? 9) - (rank[right.confidence] ?? 9);
|
|
344
|
+
return diff !== 0 ? diff : left.absolute.localeCompare(right.absolute);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
for (const candidate of classified.slice(0, MAX_ITEMS)) {
|
|
348
|
+
const measure = await measurePath(candidate.absolute);
|
|
349
|
+
items.push({
|
|
350
|
+
id: itemId(candidate.absolute),
|
|
351
|
+
kind: candidate.requiresAdmin ? 'system' : 'data',
|
|
352
|
+
path: candidate.absolute,
|
|
353
|
+
displayPath: displayPath(candidate.absolute, env),
|
|
354
|
+
bytes: measure.bytes,
|
|
355
|
+
files: measure.files,
|
|
356
|
+
confidence: candidate.confidence,
|
|
357
|
+
source: candidate.source,
|
|
358
|
+
reason: candidate.reason,
|
|
359
|
+
...(candidate.alsoMatchedBy.length > 0 ? { alsoMatchedBy: candidate.alsoMatchedBy } : {}),
|
|
360
|
+
requiresAdmin: candidate.requiresAdmin,
|
|
361
|
+
exists: true,
|
|
362
|
+
...(measure.truncated ? { warning: measure.truncated } : {}),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (classified.length > MAX_ITEMS) {
|
|
366
|
+
errors.push(`report truncated to ${MAX_ITEMS} of ${classified.length} candidates`);
|
|
367
|
+
}
|
|
368
|
+
if (errors.length > MAX_ERRORS) errors.length = MAX_ERRORS;
|
|
369
|
+
|
|
370
|
+
const appBytes = items.length > 0 && items[0].kind === 'app' ? items[0].bytes : 0;
|
|
371
|
+
return {
|
|
372
|
+
...base,
|
|
373
|
+
targetedRoots: sweepRootsFor(env).map((entry) => displayPath(entry.root, env)),
|
|
374
|
+
durationMs: Date.now() - startedAt,
|
|
375
|
+
items,
|
|
376
|
+
totalBytes: totalBytes(items),
|
|
377
|
+
selectedBytes: Math.max(0, selectedBytes(items) - appBytes),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Leftover cleanup is not implemented for Windows yet: moving to the Recycle
|
|
383
|
+
* Bin needs the shell, which could not be validated on a real Windows host.
|
|
384
|
+
* Returning a refusal is the point - the plugin never ships an unverified
|
|
385
|
+
* destructive path, and the caller must say so plainly.
|
|
386
|
+
*
|
|
387
|
+
* @returns {{ok: false, message: string, code: string}}
|
|
388
|
+
*/
|
|
389
|
+
export function cleanupUnsupported() {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
message:
|
|
393
|
+
'Leftover cleanup is not available on Windows yet: this build can list leftovers but will not move them to the Recycle Bin. The application itself can still be uninstalled.',
|
|
394
|
+
code: 'UNSUPPORTED_PLATFORM',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Test seam. */
|
|
399
|
+
export const __test = {
|
|
400
|
+
PLATFORM,
|
|
401
|
+
SAFE_ROOTS,
|
|
402
|
+
MAX_ITEMS,
|
|
403
|
+
MAX_ENTRIES,
|
|
404
|
+
TIME_BUDGET_MS,
|
|
405
|
+
measurePath,
|
|
406
|
+
sweepRootsFor,
|
|
407
|
+
displayPath,
|
|
408
|
+
profileEnv,
|
|
409
|
+
itemId,
|
|
410
|
+
slugify,
|
|
411
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device X — uninstall footprint dispatcher and scan-token store.
|
|
3
|
+
*
|
|
4
|
+
* The public surface is `scanFootprint({path, fresh})`. It returns a report
|
|
5
|
+
* whose item paths stay on the server: the client receives an opaque `scanId`
|
|
6
|
+
* plus per-item opaque ids, and `app.uninstall` accepts only
|
|
7
|
+
* `(scanId, itemIds[])` for cleanup. That keeps the shipped safety property —
|
|
8
|
+
* a deletion request can never name a path — while still letting the user pick
|
|
9
|
+
* individual leftover directories, which are only known at runtime.
|
|
10
|
+
*
|
|
11
|
+
* A token is single-use, expires after {@link TOKEN_TTL_MS}, and remembers the
|
|
12
|
+
* exact roots the scan was allowed to produce so a tampered id cannot reach
|
|
13
|
+
* anything else.
|
|
14
|
+
*
|
|
15
|
+
* @module @cfour-hi/dsh-plugin-devicex/host/footprint
|
|
16
|
+
*/
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
import * as darwin from './footprint-darwin.js';
|
|
21
|
+
import * as win32 from './footprint-win32.js';
|
|
22
|
+
|
|
23
|
+
/** Host platform. */
|
|
24
|
+
export const PLATFORM = process.platform;
|
|
25
|
+
|
|
26
|
+
/** Scan token lifetime (ms). */
|
|
27
|
+
export const TOKEN_TTL_MS = 10 * 60_000;
|
|
28
|
+
/** Maximum concurrently retained tokens. */
|
|
29
|
+
export const MAX_TOKENS = 16;
|
|
30
|
+
/** Cached report reuse window (ms); a re-open of the same dialog must be cheap. */
|
|
31
|
+
export const REPORT_TTL_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
const impl = process.platform === 'win32' ? win32 : darwin;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} ScanRecord
|
|
37
|
+
* @property {string} scanId
|
|
38
|
+
* @property {number} at
|
|
39
|
+
* @property {boolean} consumed
|
|
40
|
+
* @property {string} appPath
|
|
41
|
+
* @property {Map<string, {path: string, requiresAdmin: boolean, kind: string, bytes: number}>} items
|
|
42
|
+
* @property {string[]} roots the exact root prefixes this scan could produce
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** @type {Map<string, ScanRecord>} */
|
|
46
|
+
const tokens = new Map();
|
|
47
|
+
|
|
48
|
+
/** @type {Map<string, {at: number, report: object}>} */
|
|
49
|
+
const reportCache = new Map();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register a finished report as a single-use cleanup token.
|
|
53
|
+
*
|
|
54
|
+
* The roots are derived from what the platform implementation declares, not
|
|
55
|
+
* from the report, so a bug in the scanner cannot widen the deletion boundary.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} report raw platform report
|
|
58
|
+
* @param {readonly string[]} roots absolute root prefixes the scan was allowed to use
|
|
59
|
+
* @returns {string} the opaque scan id
|
|
60
|
+
*/
|
|
61
|
+
function registerToken(report, roots) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
for (const [id, record] of tokens) {
|
|
64
|
+
if (record.consumed || now - record.at > TOKEN_TTL_MS) tokens.delete(id);
|
|
65
|
+
}
|
|
66
|
+
while (tokens.size >= MAX_TOKENS) {
|
|
67
|
+
const oldest = tokens.keys().next();
|
|
68
|
+
if (oldest.done) break;
|
|
69
|
+
tokens.delete(oldest.value);
|
|
70
|
+
}
|
|
71
|
+
const scanId = randomUUID();
|
|
72
|
+
/** @type {Map<string, {path: string, requiresAdmin: boolean, kind: string, bytes: number}>} */
|
|
73
|
+
const items = new Map();
|
|
74
|
+
for (const item of Array.isArray(report?.items) ? report.items : []) {
|
|
75
|
+
if (typeof item?.id !== 'string' || typeof item?.path !== 'string') continue;
|
|
76
|
+
const bytes = Number(item.bytes);
|
|
77
|
+
items.set(item.id, {
|
|
78
|
+
path: item.path,
|
|
79
|
+
requiresAdmin: item.requiresAdmin === true,
|
|
80
|
+
kind: typeof item.kind === 'string' ? item.kind : 'data',
|
|
81
|
+
// Kept so the pipeline can report how much the Trash now holds. The
|
|
82
|
+
// number is a measurement from scan time, not a guarantee: the directory
|
|
83
|
+
// may have changed between the scan and the move.
|
|
84
|
+
bytes: Number.isFinite(bytes) && bytes > 0 ? bytes : 0,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
tokens.set(scanId, {
|
|
88
|
+
scanId,
|
|
89
|
+
at: now,
|
|
90
|
+
consumed: false,
|
|
91
|
+
appPath: typeof report?.appPath === 'string' ? report.appPath : '',
|
|
92
|
+
items,
|
|
93
|
+
roots: Array.isArray(roots) ? roots.slice() : [],
|
|
94
|
+
});
|
|
95
|
+
return scanId;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Drop expired entries from the report cache.
|
|
100
|
+
* @param {number} now
|
|
101
|
+
*/
|
|
102
|
+
function pruneCache(now) {
|
|
103
|
+
for (const [key, entry] of reportCache) {
|
|
104
|
+
if (now - entry.at > REPORT_TTL_MS) reportCache.delete(key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Scan one application's leftover footprint (read-only).
|
|
110
|
+
*
|
|
111
|
+
* @param {{path?: string, fresh?: boolean}} [options]
|
|
112
|
+
* @returns {Promise<object>} `FootprintReport` including its `scanId`
|
|
113
|
+
*/
|
|
114
|
+
export async function scanFootprint(options = {}) {
|
|
115
|
+
const appPath = typeof options.path === 'string' ? options.path.trim() : '';
|
|
116
|
+
const fresh = options.fresh === true;
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
pruneCache(now);
|
|
119
|
+
|
|
120
|
+
if (appPath === '') {
|
|
121
|
+
return {
|
|
122
|
+
scanId: '',
|
|
123
|
+
appPath: '',
|
|
124
|
+
bundleId: null,
|
|
125
|
+
sandboxed: false,
|
|
126
|
+
targetedRoots: [],
|
|
127
|
+
generatedAt: now,
|
|
128
|
+
durationMs: 0,
|
|
129
|
+
items: [],
|
|
130
|
+
totalBytes: 0,
|
|
131
|
+
selectedBytes: 0,
|
|
132
|
+
errors: ['missing application path'],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const key = path.resolve(appPath);
|
|
137
|
+
const cached = reportCache.get(key);
|
|
138
|
+
if (!fresh && cached && now - cached.at <= REPORT_TTL_MS) {
|
|
139
|
+
const scanId = registerToken(cached.report, impl.__test?.SAFE_ROOTS ?? []);
|
|
140
|
+
return { ...cached.report, scanId };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const report = await impl.scanFootprint({ appPath: key });
|
|
144
|
+
reportCache.set(key, { at: now, report });
|
|
145
|
+
const scanId = registerToken(report, impl.__test?.SAFE_ROOTS ?? []);
|
|
146
|
+
return { ...report, scanId };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @typedef {object} ResolvedItem
|
|
151
|
+
* @property {string} id
|
|
152
|
+
* @property {string} path
|
|
153
|
+
* @property {boolean} requiresAdmin
|
|
154
|
+
* @property {string} kind
|
|
155
|
+
* @property {number} bytes measured at scan time
|
|
156
|
+
*/
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve requested item ids through a scan token.
|
|
160
|
+
*
|
|
161
|
+
* Every rejection reason is explicit so the caller can explain it: an unknown
|
|
162
|
+
* or expired token, an id that belongs to a different scan, a path that left
|
|
163
|
+
* the recorded roots, a symbolic link, or a root directory itself.
|
|
164
|
+
*
|
|
165
|
+
* @param {unknown} scanId
|
|
166
|
+
* @param {unknown} itemIds
|
|
167
|
+
* @returns {{ok: true, items: ResolvedItem[], appPath: string} | {ok: false, message: string, code: string}}
|
|
168
|
+
*/
|
|
169
|
+
export function resolveScanItems(scanId, itemIds) {
|
|
170
|
+
if (typeof scanId !== 'string' || scanId === '') {
|
|
171
|
+
return { ok: false, message: 'A scan id is required to clean up leftovers.', code: 'BAD_PARAMS' };
|
|
172
|
+
}
|
|
173
|
+
const record = tokens.get(scanId);
|
|
174
|
+
if (record === undefined) {
|
|
175
|
+
return { ok: false, message: 'This scan has expired. Re-open the uninstall dialog.', code: 'SCAN_NOT_FOUND' };
|
|
176
|
+
}
|
|
177
|
+
if (record.consumed) {
|
|
178
|
+
return { ok: false, message: 'This scan was already used. Re-open the uninstall dialog.', code: 'SCAN_CONSUMED' };
|
|
179
|
+
}
|
|
180
|
+
if (Date.now() - record.at > TOKEN_TTL_MS) {
|
|
181
|
+
tokens.delete(scanId);
|
|
182
|
+
return { ok: false, message: 'This scan has expired. Re-open the uninstall dialog.', code: 'SCAN_EXPIRED' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @type {unknown[]} */
|
|
186
|
+
let requested;
|
|
187
|
+
if (Array.isArray(itemIds)) requested = itemIds;
|
|
188
|
+
else if (typeof itemIds === 'string') requested = itemIds.split(',');
|
|
189
|
+
else return { ok: false, message: 'itemIds must be an array of scan item ids.', code: 'BAD_PARAMS' };
|
|
190
|
+
|
|
191
|
+
/** @type {ResolvedItem[]} */
|
|
192
|
+
const resolved = [];
|
|
193
|
+
/** @type {Set<string>} */
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
for (const raw of requested) {
|
|
196
|
+
if (typeof raw !== 'string') {
|
|
197
|
+
return { ok: false, message: 'Scan item ids must be strings.', code: 'BAD_PARAMS' };
|
|
198
|
+
}
|
|
199
|
+
const id = raw.trim();
|
|
200
|
+
if (id === '') continue;
|
|
201
|
+
if (seen.has(id)) {
|
|
202
|
+
return { ok: false, message: `Duplicate scan item id: ${id}`, code: 'BAD_PARAMS' };
|
|
203
|
+
}
|
|
204
|
+
seen.add(id);
|
|
205
|
+
const item = record.items.get(id);
|
|
206
|
+
if (item === undefined) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
message: `Item ${id} does not belong to this scan.`,
|
|
210
|
+
code: 'ITEM_NOT_IN_SCAN',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (item.requiresAdmin) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
message: `${item.path} needs administrator rights and cannot be removed by this plugin.`,
|
|
217
|
+
code: 'REQUIRES_ADMIN',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const absolute = path.resolve(item.path);
|
|
221
|
+
if (!isInsideRoots(absolute, record.roots)) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
message: `Refusing to touch a path outside the scanned roots: ${absolute}`,
|
|
225
|
+
code: 'PATH_NOT_ALLOWED',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (absolute === path.dirname(absolute)) {
|
|
229
|
+
return { ok: false, message: `Refusing to remove a filesystem root: ${absolute}`, code: 'PATH_NOT_ALLOWED' };
|
|
230
|
+
}
|
|
231
|
+
resolved.push({ id, path: absolute, requiresAdmin: false, kind: item.kind, bytes: item.bytes });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (resolved.length === 0) {
|
|
235
|
+
return { ok: false, message: 'No scan items were selected.', code: 'BAD_PARAMS' };
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, items: resolved, appPath: record.appPath };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* True when `candidate` is one of the allowed roots or lives beneath one.
|
|
242
|
+
* @param {string} candidate absolute, resolved
|
|
243
|
+
* @param {string[]} roots absolute roots
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
export function isInsideRoots(candidate, roots) {
|
|
247
|
+
if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return false;
|
|
248
|
+
for (const root of Array.isArray(roots) ? roots : []) {
|
|
249
|
+
if (typeof root !== 'string' || root === '') continue;
|
|
250
|
+
if (candidate === root) return true;
|
|
251
|
+
if (candidate.startsWith(`${root}${path.sep}`)) return true;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Mark a token used. Called only after the cleanup actually ran, so a failed
|
|
258
|
+
* pipeline can be retried with the same dialog still open.
|
|
259
|
+
* @param {string} scanId
|
|
260
|
+
*/
|
|
261
|
+
export function consumeToken(scanId) {
|
|
262
|
+
const record = tokens.get(scanId);
|
|
263
|
+
if (record !== undefined) record.consumed = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Test seam: clear all stores. */
|
|
267
|
+
export function __resetForTests() {
|
|
268
|
+
tokens.clear();
|
|
269
|
+
reportCache.clear();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Test seam. */
|
|
273
|
+
export const __test = {
|
|
274
|
+
PLATFORM,
|
|
275
|
+
TOKEN_TTL_MS,
|
|
276
|
+
MAX_TOKENS,
|
|
277
|
+
REPORT_TTL_MS,
|
|
278
|
+
tokens,
|
|
279
|
+
reportCache,
|
|
280
|
+
darwin,
|
|
281
|
+
win32,
|
|
282
|
+
isInsideRoots,
|
|
283
|
+
registerToken,
|
|
284
|
+
};
|