slybrowser 0.1.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/README.md +146 -0
- package/dist/automation.d.ts +15 -0
- package/dist/automation.d.ts.map +1 -0
- package/dist/automation.js +69 -0
- package/dist/automation.js.map +1 -0
- package/dist/browser.d.ts +31 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +203 -0
- package/dist/browser.js.map +1 -0
- package/dist/canonical.d.ts +4 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +42 -0
- package/dist/canonical.js.map +1 -0
- package/dist/cdp.d.ts +21 -0
- package/dist/cdp.d.ts.map +1 -0
- package/dist/cdp.js +139 -0
- package/dist/cdp.js.map +1 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +241 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +113 -0
- package/dist/errors.js.map +1 -0
- package/dist/humanize.d.ts +23 -0
- package/dist/humanize.d.ts.map +1 -0
- package/dist/humanize.js +247 -0
- package/dist/humanize.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/installer.d.ts +43 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +669 -0
- package/dist/installer.js.map +1 -0
- package/dist/launcher.d.ts +38 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +325 -0
- package/dist/launcher.js.map +1 -0
- package/dist/license.d.ts +76 -0
- package/dist/license.d.ts.map +1 -0
- package/dist/license.js +329 -0
- package/dist/license.js.map +1 -0
- package/dist/licensed.d.ts +82 -0
- package/dist/licensed.d.ts.map +1 -0
- package/dist/licensed.js +513 -0
- package/dist/licensed.js.map +1 -0
- package/dist/manifest.d.ts +66 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +292 -0
- package/dist/manifest.js.map +1 -0
- package/dist/service.d.ts +206 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +1271 -0
- package/dist/service.js.map +1 -0
- package/dist/webdriver.d.ts +208 -0
- package/dist/webdriver.d.ts.map +1 -0
- package/dist/webdriver.js +592 -0
- package/dist/webdriver.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import { Readable, Transform } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import * as yauzl from "yauzl";
|
|
11
|
+
import { ArtifactError } from "./errors.js";
|
|
12
|
+
import { verifyArtifact } from "./manifest.js";
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const MAXIMUM_7Z_ENTRIES = 100_000;
|
|
15
|
+
const INSPECTION_TIMEOUT_MS = 30_000;
|
|
16
|
+
const EXTRACTION_TIMEOUT_MS = 5 * 60_000;
|
|
17
|
+
function isRuntimeGrant(grant) {
|
|
18
|
+
return "downloadTicket" in grant;
|
|
19
|
+
}
|
|
20
|
+
function defaultCacheRoot() {
|
|
21
|
+
if (process.platform === "win32") {
|
|
22
|
+
return resolve(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "SlyBrowser", "cache");
|
|
23
|
+
}
|
|
24
|
+
if (process.platform === "darwin")
|
|
25
|
+
return resolve(homedir(), "Library", "Caches", "SlyBrowser");
|
|
26
|
+
return resolve(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "slybrowser");
|
|
27
|
+
}
|
|
28
|
+
function currentPlatform() {
|
|
29
|
+
if (process.platform === "win32")
|
|
30
|
+
return "windows";
|
|
31
|
+
if (process.platform === "darwin")
|
|
32
|
+
return "macos";
|
|
33
|
+
if (process.platform === "linux")
|
|
34
|
+
return "linux";
|
|
35
|
+
throw new ArtifactError("Unsupported platform", "platform_unsupported");
|
|
36
|
+
}
|
|
37
|
+
function currentArch() {
|
|
38
|
+
if (process.arch === "x64")
|
|
39
|
+
return "x64";
|
|
40
|
+
if (process.arch === "arm64")
|
|
41
|
+
return "arm64";
|
|
42
|
+
throw new ArtifactError("Unsupported architecture", "platform_unsupported");
|
|
43
|
+
}
|
|
44
|
+
function compareVersion(left, right) {
|
|
45
|
+
const a = left.split(".").map(Number);
|
|
46
|
+
const b = right.split(".").map(Number);
|
|
47
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
48
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
49
|
+
if (difference !== 0)
|
|
50
|
+
return Math.sign(difference);
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
function matchesKernelMajor(version, kernelMajor) {
|
|
55
|
+
return kernelMajor === "latest" || Number(version.split(".")[0]) === kernelMajor;
|
|
56
|
+
}
|
|
57
|
+
function maximumExpandedBytes(archiveSize) {
|
|
58
|
+
return Math.min(Math.max(archiveSize * 20, 2 * 1024 * 1024 * 1024), 16 * 1024 * 1024 * 1024);
|
|
59
|
+
}
|
|
60
|
+
function enforceDownloadSizeLimit(maximumBytes) {
|
|
61
|
+
let downloaded = 0;
|
|
62
|
+
return new Transform({
|
|
63
|
+
transform(chunk, _encoding, callback) {
|
|
64
|
+
downloaded += chunk.length;
|
|
65
|
+
if (downloaded > maximumBytes) {
|
|
66
|
+
callback(new ArtifactError("Browser artifact download exceeds its signed size", "artifact_size_mismatch"));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
callback(null, chunk);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function assertContentLength(response, expectedSize) {
|
|
74
|
+
const value = response.headers.get("content-length");
|
|
75
|
+
if (value === null)
|
|
76
|
+
return;
|
|
77
|
+
if (!/^\d+$/.test(value) || Number(value) > expectedSize) {
|
|
78
|
+
throw new ArtifactError("Browser artifact download exceeds its signed size", "artifact_size_mismatch");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function safeExtractZip(archive, destination, limit) {
|
|
82
|
+
const root = resolve(destination);
|
|
83
|
+
let expanded = 0;
|
|
84
|
+
await mkdir(root, { recursive: true });
|
|
85
|
+
const zipFile = await openZipFile(archive);
|
|
86
|
+
await new Promise((accept, reject) => {
|
|
87
|
+
let settled = false;
|
|
88
|
+
const finish = (error) => {
|
|
89
|
+
if (settled)
|
|
90
|
+
return;
|
|
91
|
+
settled = true;
|
|
92
|
+
zipFile.close();
|
|
93
|
+
if (error)
|
|
94
|
+
reject(error);
|
|
95
|
+
else
|
|
96
|
+
accept();
|
|
97
|
+
};
|
|
98
|
+
zipFile.once("error", finish);
|
|
99
|
+
zipFile.once("end", () => finish());
|
|
100
|
+
zipFile.on("entry", (entry) => {
|
|
101
|
+
void (async () => {
|
|
102
|
+
const normalized = safeZipEntryName(entry);
|
|
103
|
+
expanded += entry.uncompressedSize;
|
|
104
|
+
if (expanded > limit) {
|
|
105
|
+
throw new ArtifactError("Browser archive expands beyond its allowed size", "artifact_expanded_too_large");
|
|
106
|
+
}
|
|
107
|
+
const output = resolve(root, normalized);
|
|
108
|
+
if (!isSubpath(root, output)) {
|
|
109
|
+
throw new ArtifactError("Browser archive contains an unsafe path", "artifact_layout_invalid");
|
|
110
|
+
}
|
|
111
|
+
if (normalized.endsWith("/")) {
|
|
112
|
+
await mkdir(output, { recursive: true, mode: 0o700 });
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
await mkdir(dirname(output), { recursive: true });
|
|
116
|
+
const stream = await openZipEntryStream(zipFile, entry);
|
|
117
|
+
await pipeline(stream, createWriteStream(output, { mode: safeZipEntryMode(entry) }));
|
|
118
|
+
}
|
|
119
|
+
zipFile.readEntry();
|
|
120
|
+
})().catch(finish);
|
|
121
|
+
});
|
|
122
|
+
zipFile.readEntry();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function safeExtract7z(archive, destination, limit) {
|
|
126
|
+
let executable;
|
|
127
|
+
let listing;
|
|
128
|
+
const configured = process.env.SLYBROWSER_7Z_PATH;
|
|
129
|
+
for (const candidate of [configured, ...(process.platform === "win32" ? ["7z.exe", "7z"] : ["7zz", "7z"])].filter((value) => Boolean(value))) {
|
|
130
|
+
try {
|
|
131
|
+
const inspected = await execFileAsync(candidate, ["l", "-slt", archive], {
|
|
132
|
+
windowsHide: true,
|
|
133
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
134
|
+
timeout: INSPECTION_TIMEOUT_MS,
|
|
135
|
+
killSignal: "SIGKILL",
|
|
136
|
+
});
|
|
137
|
+
executable = candidate;
|
|
138
|
+
listing = inspected.stdout;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error.code !== "ENOENT") {
|
|
143
|
+
throw new ArtifactError("Unable to inspect browser archive", "artifact_extract_failed");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!executable)
|
|
148
|
+
throw new ArtifactError("7z or 7zz is required to extract browser archives", "artifact_extractor_missing");
|
|
149
|
+
inspect7zListingForExtraction(listing ?? "", limit);
|
|
150
|
+
try {
|
|
151
|
+
await execFileAsync(executable, ["x", archive, `-o${destination}`, "-y", "-bd", "-bb0"], {
|
|
152
|
+
windowsHide: true,
|
|
153
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
154
|
+
timeout: EXTRACTION_TIMEOUT_MS,
|
|
155
|
+
killSignal: "SIGKILL",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.killed) {
|
|
160
|
+
throw new ArtifactError("Browser archive extraction timed out", "artifact_extract_timeout");
|
|
161
|
+
}
|
|
162
|
+
throw new ArtifactError("Unable to extract browser artifact", "artifact_extract_failed");
|
|
163
|
+
}
|
|
164
|
+
let expanded = 0;
|
|
165
|
+
for (const relativeName of await readdir(destination, { recursive: true })) {
|
|
166
|
+
const output = resolve(destination, relativeName);
|
|
167
|
+
const info = await lstat(output);
|
|
168
|
+
if (info.isSymbolicLink())
|
|
169
|
+
throw new ArtifactError("Browser archive contains a symbolic link", "artifact_layout_invalid");
|
|
170
|
+
if (info.isFile()) {
|
|
171
|
+
expanded += info.size;
|
|
172
|
+
if (expanded > limit)
|
|
173
|
+
throw new ArtifactError("Browser archive expands beyond its allowed size", "artifact_expanded_too_large");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export function inspect7zListingForExtraction(listing, limit) {
|
|
178
|
+
const separator = /^----------\s*$/m.exec(listing);
|
|
179
|
+
if (!separator)
|
|
180
|
+
throw new ArtifactError("Unable to inspect browser archive", "artifact_extract_failed");
|
|
181
|
+
const records = listing.slice(separator.index + separator[0].length).split(/\r?\n\s*\r?\n/);
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
let expanded = 0;
|
|
184
|
+
let entries = 0;
|
|
185
|
+
for (const record of records) {
|
|
186
|
+
const fields = new Map();
|
|
187
|
+
for (const line of record.split(/\r?\n/)) {
|
|
188
|
+
const marker = line.indexOf(" = ");
|
|
189
|
+
if (marker > 0)
|
|
190
|
+
fields.set(line.slice(0, marker), line.slice(marker + 3));
|
|
191
|
+
}
|
|
192
|
+
const rawName = fields.get("Path");
|
|
193
|
+
if (!rawName)
|
|
194
|
+
continue;
|
|
195
|
+
entries += 1;
|
|
196
|
+
if (entries > MAXIMUM_7Z_ENTRIES) {
|
|
197
|
+
throw new ArtifactError("Browser archive contains too many entries", "artifact_layout_invalid");
|
|
198
|
+
}
|
|
199
|
+
const normalized = rawName.replaceAll("\\", "/");
|
|
200
|
+
const parts = normalized.split("/");
|
|
201
|
+
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized) || parts.includes("..") || normalized.includes("\0")) {
|
|
202
|
+
throw new ArtifactError("Browser archive contains an unsafe path", "artifact_layout_invalid");
|
|
203
|
+
}
|
|
204
|
+
const identity = process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
205
|
+
if (seen.has(identity))
|
|
206
|
+
throw new ArtifactError("Browser archive contains duplicate paths", "artifact_layout_invalid");
|
|
207
|
+
seen.add(identity);
|
|
208
|
+
const attributes = fields.get("Attributes") ?? "";
|
|
209
|
+
if (fields.has("Symbolic Link") || /^l/i.test(attributes) || attributes.includes(" reparse ")) {
|
|
210
|
+
throw new ArtifactError("Browser archive contains a symbolic link", "artifact_layout_invalid");
|
|
211
|
+
}
|
|
212
|
+
const sizeText = fields.get("Size") ?? "0";
|
|
213
|
+
if (!/^\d+$/.test(sizeText))
|
|
214
|
+
throw new ArtifactError("Browser archive has an invalid entry size", "artifact_extract_failed");
|
|
215
|
+
const size = Number(sizeText);
|
|
216
|
+
if (!Number.isSafeInteger(size))
|
|
217
|
+
throw new ArtifactError("Browser archive has an invalid entry size", "artifact_extract_failed");
|
|
218
|
+
expanded += size;
|
|
219
|
+
if (expanded > limit)
|
|
220
|
+
throw new ArtifactError("Browser archive expands beyond its allowed size", "artifact_expanded_too_large");
|
|
221
|
+
}
|
|
222
|
+
if (entries === 0)
|
|
223
|
+
throw new ArtifactError("Browser archive contains no entries", "artifact_layout_invalid");
|
|
224
|
+
}
|
|
225
|
+
async function safeExtractArchive(archive, destination, format, limit) {
|
|
226
|
+
if (format === "7z")
|
|
227
|
+
return safeExtract7z(archive, destination, limit);
|
|
228
|
+
return safeExtractZip(archive, destination, limit);
|
|
229
|
+
}
|
|
230
|
+
function openZipFile(archive) {
|
|
231
|
+
return new Promise((accept, reject) => {
|
|
232
|
+
yauzl.open(archive, { lazyEntries: true, autoClose: false, validateEntrySizes: true }, (error, zipFile) => {
|
|
233
|
+
if (error) {
|
|
234
|
+
reject(new ArtifactError("Unable to open browser archive", "artifact_extract_failed"));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (!zipFile) {
|
|
238
|
+
reject(new ArtifactError("Unable to open browser archive", "artifact_extract_failed"));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
accept(zipFile);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function openZipEntryStream(zipFile, entry) {
|
|
246
|
+
return new Promise((accept, reject) => {
|
|
247
|
+
zipFile.openReadStream(entry, (error, stream) => {
|
|
248
|
+
if (error) {
|
|
249
|
+
reject(new ArtifactError("Unable to extract browser artifact", "artifact_extract_failed"));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!stream) {
|
|
253
|
+
reject(new ArtifactError("Unable to extract browser artifact", "artifact_extract_failed"));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
accept(stream);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function safeZipEntryName(entry) {
|
|
261
|
+
const normalized = entry.fileName.replaceAll("\\", "/");
|
|
262
|
+
if (normalized === "" ||
|
|
263
|
+
normalized.startsWith("/") ||
|
|
264
|
+
/^[A-Za-z]:/.test(normalized) ||
|
|
265
|
+
normalized.split("/").includes("..")) {
|
|
266
|
+
throw new ArtifactError("Browser archive contains an unsafe path", "artifact_layout_invalid");
|
|
267
|
+
}
|
|
268
|
+
const mode = (entry.externalFileAttributes >> 16) & 0xffff;
|
|
269
|
+
if ((mode & 0xf000) === 0xa000) {
|
|
270
|
+
throw new ArtifactError("Browser archive contains a symbolic link", "artifact_layout_invalid");
|
|
271
|
+
}
|
|
272
|
+
return normalized;
|
|
273
|
+
}
|
|
274
|
+
function safeZipEntryMode(entry) {
|
|
275
|
+
const mode = (entry.externalFileAttributes >> 16) & 0xffff;
|
|
276
|
+
return (mode & 0o111) !== 0 ? 0o700 : 0o600;
|
|
277
|
+
}
|
|
278
|
+
function isSubpath(root, value) {
|
|
279
|
+
const child = resolve(value);
|
|
280
|
+
const relation = relative(root, child);
|
|
281
|
+
return relation === "" || (relation !== "" && !relation.startsWith("..") && !isAbsolute(relation));
|
|
282
|
+
}
|
|
283
|
+
async function exists(path) {
|
|
284
|
+
try {
|
|
285
|
+
return (await stat(path)).isFile();
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function processIsAlive(pid) {
|
|
292
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
293
|
+
return false;
|
|
294
|
+
try {
|
|
295
|
+
process.kill(pid, 0);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
return error.code === "EPERM";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async function pathExists(path) {
|
|
303
|
+
try {
|
|
304
|
+
await stat(path);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function quarantinePath(path) {
|
|
312
|
+
if (!await pathExists(path))
|
|
313
|
+
return;
|
|
314
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
315
|
+
const target = `${path}.bad-${process.pid}-${Date.now()}-${attempt}`;
|
|
316
|
+
try {
|
|
317
|
+
await rename(path, target);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
const code = error.code;
|
|
322
|
+
if (code === "ENOENT")
|
|
323
|
+
return;
|
|
324
|
+
if (code === "EEXIST")
|
|
325
|
+
continue;
|
|
326
|
+
throw new ArtifactError("Unable to quarantine invalid browser cache", "artifact_cache_failed");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
throw new ArtifactError("Unable to quarantine invalid browser cache", "artifact_cache_failed");
|
|
330
|
+
}
|
|
331
|
+
async function sha256(path) {
|
|
332
|
+
const digest = createHash("sha256");
|
|
333
|
+
await pipeline(createReadStream(path), digest);
|
|
334
|
+
return digest.digest("hex");
|
|
335
|
+
}
|
|
336
|
+
async function verifyRuntime(root, grant) {
|
|
337
|
+
const browser = resolve(root, grant.artifact.browserExecutable);
|
|
338
|
+
const driver = resolve(root, grant.artifact.driverExecutable);
|
|
339
|
+
if (!browser.startsWith(`${resolve(root)}\\`) && !browser.startsWith(`${resolve(root)}/`) ||
|
|
340
|
+
!driver.startsWith(`${resolve(root)}\\`) && !driver.startsWith(`${resolve(root)}/`) ||
|
|
341
|
+
dirname(browser) !== dirname(driver) || !await exists(browser) || !await exists(driver)) {
|
|
342
|
+
throw new ArtifactError("Installed browser and project WebDriver layout is invalid", "artifact_layout_invalid");
|
|
343
|
+
}
|
|
344
|
+
if (await sha256(browser) !== grant.artifact.browserSha256 || await sha256(driver) !== grant.artifact.driverSha256) {
|
|
345
|
+
throw new ArtifactError("Installed browser or project WebDriver hash does not match the signed manifest", "artifact_runtime_hash_mismatch");
|
|
346
|
+
}
|
|
347
|
+
return { browser, driver };
|
|
348
|
+
}
|
|
349
|
+
async function acquireLock(path, timeoutMs) {
|
|
350
|
+
const deadline = Date.now() + timeoutMs;
|
|
351
|
+
while (true) {
|
|
352
|
+
try {
|
|
353
|
+
const handle = await open(path, "wx", 0o600);
|
|
354
|
+
await handle.writeFile(String(process.pid));
|
|
355
|
+
await handle.close();
|
|
356
|
+
return async () => { await rm(path, { force: true }); };
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (error.code !== "EEXIST")
|
|
360
|
+
throw error;
|
|
361
|
+
if (Date.now() >= deadline)
|
|
362
|
+
throw new ArtifactError("Timed out waiting for the browser installation lock", "install_lock_timeout");
|
|
363
|
+
await new Promise((accept) => setTimeout(accept, 100));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function readInstallation(path, grant) {
|
|
368
|
+
try {
|
|
369
|
+
const document = JSON.parse(await readFile(join(path, ".sly-install.json"), "utf8"));
|
|
370
|
+
if (document.version !== grant.browserVersion || document.artifactSha256 !== grant.artifact.sha256)
|
|
371
|
+
return null;
|
|
372
|
+
const expectedBrowser = resolve(path, grant.artifact.browserExecutable);
|
|
373
|
+
const expectedDriver = resolve(path, grant.artifact.driverExecutable);
|
|
374
|
+
if (resolve(document.root) !== resolve(path) || resolve(document.browserExecutable) !== expectedBrowser ||
|
|
375
|
+
resolve(document.driverExecutable) !== expectedDriver)
|
|
376
|
+
return null;
|
|
377
|
+
await verifyRuntime(path, grant);
|
|
378
|
+
return document;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async function readLooseInstallation(path, options) {
|
|
385
|
+
try {
|
|
386
|
+
const document = JSON.parse(await readFile(join(path, ".sly-install.json"), "utf8"));
|
|
387
|
+
if (document.platform !== options.platform || document.arch !== options.arch)
|
|
388
|
+
return null;
|
|
389
|
+
if (!/^\d+(\.\d+){0,7}$/.test(document.version) || !matchesKernelMajor(document.version, options.kernelMajor))
|
|
390
|
+
return null;
|
|
391
|
+
if (typeof document.artifactSha256 !== "string" || !/^[a-f0-9]{64}$/.test(document.artifactSha256))
|
|
392
|
+
return null;
|
|
393
|
+
const root = resolve(document.root);
|
|
394
|
+
if (root !== resolve(path))
|
|
395
|
+
return null;
|
|
396
|
+
const browser = resolve(document.browserExecutable);
|
|
397
|
+
const driver = resolve(document.driverExecutable);
|
|
398
|
+
if (!browser.startsWith(`${root}\\`) && !browser.startsWith(`${root}/`) ||
|
|
399
|
+
!driver.startsWith(`${root}\\`) && !driver.startsWith(`${root}/`) ||
|
|
400
|
+
dirname(browser) !== dirname(driver) || !await exists(browser) || !await exists(driver)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
return { ...document, root, browserExecutable: browser, driverExecutable: driver };
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function writeCurrentPointers(cacheRoot, installation) {
|
|
410
|
+
const current = join(cacheRoot, "stable", "current");
|
|
411
|
+
await mkdir(current, { recursive: true });
|
|
412
|
+
const payload = `${JSON.stringify({
|
|
413
|
+
schemaVersion: 1,
|
|
414
|
+
version: installation.version,
|
|
415
|
+
platform: installation.platform,
|
|
416
|
+
arch: installation.arch,
|
|
417
|
+
artifactSha256: installation.artifactSha256,
|
|
418
|
+
root: installation.root,
|
|
419
|
+
updatedAt: new Date().toISOString(),
|
|
420
|
+
}, null, 2)}\n`;
|
|
421
|
+
const major = installation.version.split(".")[0];
|
|
422
|
+
await writeFile(join(current, `${installation.platform}-${installation.arch}-latest.json`), payload, { encoding: "utf8", mode: 0o600 });
|
|
423
|
+
await writeFile(join(current, `${installation.platform}-${installation.arch}-${major}.json`), payload, { encoding: "utf8", mode: 0o600 });
|
|
424
|
+
}
|
|
425
|
+
export async function acquireBrowserInstallationReference(installation) {
|
|
426
|
+
const root = resolve(installation.root);
|
|
427
|
+
const refs = join(root, ".sly-refs");
|
|
428
|
+
await mkdir(refs, { recursive: true });
|
|
429
|
+
const referenceFile = join(refs, `${process.pid}-${Date.now()}-${randomUUID()}.json`);
|
|
430
|
+
const payload = `${JSON.stringify({
|
|
431
|
+
schemaVersion: 1,
|
|
432
|
+
processId: process.pid,
|
|
433
|
+
acquiredAt: new Date().toISOString(),
|
|
434
|
+
version: installation.version,
|
|
435
|
+
platform: installation.platform,
|
|
436
|
+
arch: installation.arch,
|
|
437
|
+
artifactSha256: installation.artifactSha256,
|
|
438
|
+
root,
|
|
439
|
+
browserExecutable: resolve(installation.browserExecutable),
|
|
440
|
+
driverExecutable: resolve(installation.driverExecutable),
|
|
441
|
+
}, null, 2)}\n`;
|
|
442
|
+
await writeFile(referenceFile, payload, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
443
|
+
let released = false;
|
|
444
|
+
return {
|
|
445
|
+
installation,
|
|
446
|
+
referenceFile,
|
|
447
|
+
async release() {
|
|
448
|
+
if (released)
|
|
449
|
+
return;
|
|
450
|
+
released = true;
|
|
451
|
+
await rm(referenceFile, { force: true });
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
export async function activeBrowserInstallationReferences(installation) {
|
|
456
|
+
const refs = join(resolve(installation.root), ".sly-refs");
|
|
457
|
+
const entries = await readdir(refs, { withFileTypes: true }).catch(() => []);
|
|
458
|
+
const active = [];
|
|
459
|
+
for (const entry of entries) {
|
|
460
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
461
|
+
continue;
|
|
462
|
+
const referenceFile = join(refs, entry.name);
|
|
463
|
+
try {
|
|
464
|
+
const document = JSON.parse(await readFile(referenceFile, "utf8"));
|
|
465
|
+
if (resolve(String(document.root ?? "")) !== resolve(installation.root) ||
|
|
466
|
+
document.artifactSha256 !== installation.artifactSha256) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const pid = Number(document.processId);
|
|
470
|
+
if (processIsAlive(pid)) {
|
|
471
|
+
active.push(referenceFile);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
await rm(referenceFile, { force: true });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
await rm(referenceFile, { force: true });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return active;
|
|
482
|
+
}
|
|
483
|
+
export async function isBrowserInstallationInUse(installation) {
|
|
484
|
+
return (await activeBrowserInstallationReferences(installation)).length > 0;
|
|
485
|
+
}
|
|
486
|
+
async function currentInstallationRoots(cacheRoot) {
|
|
487
|
+
const roots = new Set();
|
|
488
|
+
const current = join(cacheRoot, "stable", "current");
|
|
489
|
+
const pointers = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
490
|
+
for (const pointer of pointers) {
|
|
491
|
+
if (!pointer.isFile() || !pointer.name.endsWith(".json"))
|
|
492
|
+
continue;
|
|
493
|
+
try {
|
|
494
|
+
const document = JSON.parse(await readFile(join(current, pointer.name), "utf8"));
|
|
495
|
+
if (typeof document.root === "string")
|
|
496
|
+
roots.add(resolve(document.root));
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// Ignore corrupt current pointers; installation metadata is rechecked before deletion.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return roots;
|
|
503
|
+
}
|
|
504
|
+
export async function pruneBrowserInstallations(options = {}) {
|
|
505
|
+
const cacheRoot = resolve(options.cacheRoot ?? defaultCacheRoot());
|
|
506
|
+
const stable = join(cacheRoot, "stable");
|
|
507
|
+
const lookup = {
|
|
508
|
+
platform: options.platform ?? currentPlatform(),
|
|
509
|
+
arch: options.arch ?? currentArch(),
|
|
510
|
+
kernelMajor: options.kernelMajor ?? "latest",
|
|
511
|
+
};
|
|
512
|
+
if (lookup.kernelMajor !== "latest" && (!Number.isSafeInteger(lookup.kernelMajor) || lookup.kernelMajor < 1)) {
|
|
513
|
+
throw new ArtifactError("kernelMajor must be a positive integer or latest", "version_policy_invalid");
|
|
514
|
+
}
|
|
515
|
+
const currentRoots = await currentInstallationRoots(cacheRoot);
|
|
516
|
+
const result = { removed: [], skippedInUse: [], kept: [] };
|
|
517
|
+
const versions = await readdir(stable, { withFileTypes: true }).catch(() => []);
|
|
518
|
+
for (const version of versions) {
|
|
519
|
+
if (!version.isDirectory() || version.name === "current" || !/^\d+(\.\d+){0,7}$/.test(version.name) ||
|
|
520
|
+
!matchesKernelMajor(version.name, lookup.kernelMajor))
|
|
521
|
+
continue;
|
|
522
|
+
const versionRoot = join(stable, version.name);
|
|
523
|
+
const identities = await readdir(versionRoot, { withFileTypes: true }).catch(() => []);
|
|
524
|
+
for (const identity of identities) {
|
|
525
|
+
if (!identity.isDirectory())
|
|
526
|
+
continue;
|
|
527
|
+
const root = resolve(versionRoot, identity.name);
|
|
528
|
+
const installation = await readLooseInstallation(root, lookup);
|
|
529
|
+
if (!installation)
|
|
530
|
+
continue;
|
|
531
|
+
if (currentRoots.has(resolve(installation.root))) {
|
|
532
|
+
result.kept.push(installation.root);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (await isBrowserInstallationInUse(installation)) {
|
|
536
|
+
result.skippedInUse.push(installation.root);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
result.removed.push(installation.root);
|
|
540
|
+
if (options.dryRun !== true) {
|
|
541
|
+
await rm(installation.root, { recursive: true, force: true });
|
|
542
|
+
const remaining = await readdir(versionRoot).catch(() => []);
|
|
543
|
+
if (remaining.length === 0)
|
|
544
|
+
await rm(versionRoot, { recursive: true, force: true });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
result.removed.sort();
|
|
549
|
+
result.skippedInUse.sort();
|
|
550
|
+
result.kept.sort();
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
export async function findCurrentBrowserInstallation(options = {}) {
|
|
554
|
+
const cacheRoot = resolve(options.cacheRoot ?? defaultCacheRoot());
|
|
555
|
+
const lookup = {
|
|
556
|
+
platform: options.platform ?? currentPlatform(),
|
|
557
|
+
arch: options.arch ?? currentArch(),
|
|
558
|
+
kernelMajor: options.kernelMajor ?? "latest",
|
|
559
|
+
};
|
|
560
|
+
if (lookup.kernelMajor !== "latest" && (!Number.isSafeInteger(lookup.kernelMajor) || lookup.kernelMajor < 1)) {
|
|
561
|
+
throw new ArtifactError("kernelMajor must be a positive integer or latest", "version_policy_invalid");
|
|
562
|
+
}
|
|
563
|
+
const currentKey = `${lookup.platform}-${lookup.arch}-${lookup.kernelMajor}.json`;
|
|
564
|
+
try {
|
|
565
|
+
const pointer = JSON.parse(await readFile(join(cacheRoot, "stable", "current", currentKey), "utf8"));
|
|
566
|
+
if (typeof pointer.root === "string") {
|
|
567
|
+
const current = await readLooseInstallation(pointer.root, lookup);
|
|
568
|
+
if (current)
|
|
569
|
+
return current;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
// Fall back to scanning older caches that predate the current pointer.
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
const stable = join(cacheRoot, "stable");
|
|
577
|
+
const versions = await readdir(stable, { withFileTypes: true });
|
|
578
|
+
const candidates = [];
|
|
579
|
+
for (const version of versions) {
|
|
580
|
+
if (!version.isDirectory() || version.name === "current" || !/^\d+(\.\d+){0,7}$/.test(version.name) ||
|
|
581
|
+
!matchesKernelMajor(version.name, lookup.kernelMajor))
|
|
582
|
+
continue;
|
|
583
|
+
const identities = await readdir(join(stable, version.name), { withFileTypes: true }).catch(() => []);
|
|
584
|
+
for (const identity of identities) {
|
|
585
|
+
if (!identity.isDirectory())
|
|
586
|
+
continue;
|
|
587
|
+
const installation = await readLooseInstallation(join(stable, version.name, identity.name), lookup);
|
|
588
|
+
if (installation)
|
|
589
|
+
candidates.push(installation);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
candidates.sort((left, right) => compareVersion(right.version, left.version));
|
|
593
|
+
return candidates[0] ?? null;
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
export async function installGrantedBrowser(client, grant, options = {}) {
|
|
600
|
+
const cacheRoot = resolve(options.cacheRoot ?? defaultCacheRoot());
|
|
601
|
+
const identity = `${grant.platform}-${grant.arch}-${grant.artifact.sha256.slice(0, 16)}`;
|
|
602
|
+
const installRoot = join(cacheRoot, "stable", grant.browserVersion, identity);
|
|
603
|
+
const existing = await readInstallation(installRoot, grant);
|
|
604
|
+
if (existing)
|
|
605
|
+
return existing;
|
|
606
|
+
await mkdir(dirname(installRoot), { recursive: true });
|
|
607
|
+
const lockPath = `${installRoot}.lock`;
|
|
608
|
+
const releaseLock = await acquireLock(lockPath, options.lockTimeoutMs ?? 60_000);
|
|
609
|
+
let temporaryDirectory;
|
|
610
|
+
try {
|
|
611
|
+
const raced = await readInstallation(installRoot, grant);
|
|
612
|
+
if (raced)
|
|
613
|
+
return raced;
|
|
614
|
+
const downloads = join(cacheRoot, "downloads");
|
|
615
|
+
await mkdir(downloads, { recursive: true });
|
|
616
|
+
const archive = join(downloads, `${grant.artifact.sha256}.${grant.artifact.archiveFormat}`);
|
|
617
|
+
let archiveValid = false;
|
|
618
|
+
try {
|
|
619
|
+
await verifyArtifact(archive, grant.artifact);
|
|
620
|
+
archiveValid = true;
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
archiveValid = false;
|
|
624
|
+
}
|
|
625
|
+
if (!archiveValid) {
|
|
626
|
+
await quarantinePath(archive);
|
|
627
|
+
const temporaryArchive = `${archive}.${process.pid}.${Date.now()}.${randomUUID()}.part`;
|
|
628
|
+
try {
|
|
629
|
+
const response = isRuntimeGrant(grant)
|
|
630
|
+
? await client.downloadRuntimeArtifact(grant)
|
|
631
|
+
: await client.downloadArtifact(grant);
|
|
632
|
+
assertContentLength(response, grant.artifact.size);
|
|
633
|
+
await pipeline(Readable.fromWeb(response.body), enforceDownloadSizeLimit(grant.artifact.size), createWriteStream(temporaryArchive, { flags: "wx", mode: 0o600 }));
|
|
634
|
+
await verifyArtifact(temporaryArchive, grant.artifact);
|
|
635
|
+
await rename(temporaryArchive, archive);
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
await rm(temporaryArchive, { force: true });
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
await verifyArtifact(archive, grant.artifact);
|
|
643
|
+
temporaryDirectory = join(dirname(installRoot), `.extract-${process.pid}-${Date.now()}-${randomUUID()}`);
|
|
644
|
+
await mkdir(temporaryDirectory, { recursive: false });
|
|
645
|
+
await (options.extractor ?? (async (source, destination) => safeExtractArchive(source, destination, grant.artifact.archiveFormat, maximumExpandedBytes(grant.artifact.size))))(archive, temporaryDirectory);
|
|
646
|
+
const pair = await verifyRuntime(temporaryDirectory, grant);
|
|
647
|
+
await quarantinePath(installRoot);
|
|
648
|
+
await rename(temporaryDirectory, installRoot);
|
|
649
|
+
temporaryDirectory = undefined;
|
|
650
|
+
const installation = {
|
|
651
|
+
version: grant.browserVersion,
|
|
652
|
+
platform: grant.platform,
|
|
653
|
+
arch: grant.arch,
|
|
654
|
+
root: installRoot,
|
|
655
|
+
browserExecutable: resolve(installRoot, grant.artifact.browserExecutable),
|
|
656
|
+
driverExecutable: resolve(installRoot, grant.artifact.driverExecutable),
|
|
657
|
+
artifactSha256: grant.artifact.sha256,
|
|
658
|
+
};
|
|
659
|
+
await writeFile(join(installRoot, ".sly-install.json"), `${JSON.stringify(installation, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
660
|
+
await writeCurrentPointers(cacheRoot, installation);
|
|
661
|
+
return installation;
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
if (temporaryDirectory)
|
|
665
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
666
|
+
await releaseLock();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=installer.js.map
|