@jini-ai/platform 0.5.9 → 0.5.10
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 +7 -0
- package/dist/asset-cache.js +26 -25
- package/dist/blob-storage.d.ts +7 -2
- package/dist/blob-storage.js +99 -59
- package/dist/child-environment.d.ts +36 -0
- package/dist/child-environment.js +68 -0
- package/dist/download.js +192 -148
- package/dist/fs/atomic-write.js +105 -93
- package/dist/fs/boot-identity.d.ts +16 -0
- package/dist/fs/boot-identity.js +38 -17
- package/dist/fs/directory-home.js +6 -0
- package/dist/fs/directory-registry.d.ts +1 -1
- package/dist/fs/directory-registry.js +23 -16
- package/dist/fs/directory-running-lock.d.ts +1 -1
- package/dist/fs/directory-running-lock.js +18 -7
- package/dist/fs/directory-sync.d.ts +1 -1
- package/dist/fs/directory-sync.js +64 -51
- package/dist/fs/directory-user-config.js +108 -67
- package/dist/fs/directory-workspace.d.ts +11 -0
- package/dist/fs/directory-workspace.js +16 -11
- package/dist/fs/durable-json.js +49 -37
- package/dist/fs/file-lock.js +214 -149
- package/dist/fs/guarded-reader.js +47 -35
- package/dist/fs/user-data-directories.d.ts +1 -1
- package/dist/fs/user-data-directories.js +25 -9
- package/dist/fs/verified-directory-move.d.ts +13 -11
- package/dist/fs/verified-directory-move.js +105 -68
- package/dist/fs/verified-file-relocation.d.ts +1 -1
- package/dist/fs/verified-file-relocation.js +111 -94
- package/dist/http/guarded/client.d.ts +3 -4
- package/dist/http/guarded/client.js +50 -60
- package/dist/http/guarded/fetch-adapter.d.ts +2 -3
- package/dist/http/guarded/fetch-adapter.js +26 -15
- package/dist/mail/builtin-providers.d.ts +39 -0
- package/dist/mail/builtin-providers.js +60 -0
- package/dist/mail/feedback.d.ts +84 -0
- package/dist/mail/feedback.js +90 -0
- package/dist/mail/index.d.ts +11 -0
- package/dist/mail/index.js +6 -0
- package/dist/mail/policy.d.ts +77 -0
- package/dist/mail/policy.js +155 -0
- package/dist/mail/ports.d.ts +56 -7
- package/dist/mail/provider.d.ts +117 -0
- package/dist/mail/provider.js +74 -0
- package/dist/mail/rate-limit.d.ts +33 -0
- package/dist/mail/rate-limit.js +35 -0
- package/dist/mail/sender-identity.d.ts +68 -0
- package/dist/mail/sender-identity.js +50 -0
- package/dist/mail/types.d.ts +9 -0
- package/dist/net/address.d.ts +9 -23
- package/dist/net/address.js +28 -133
- package/dist/net/endpoint-policy.d.ts +26 -2
- package/dist/net/endpoint-policy.js +159 -56
- package/dist/object-store/index.d.ts +2 -0
- package/dist/object-store/index.js +2 -0
- package/dist/object-store/local-fs.d.ts +17 -0
- package/dist/object-store/local-fs.js +146 -0
- package/dist/process.js +2 -0
- package/dist/proxy-env.js +131 -135
- package/dist/secrets/credential-sets/contracts.d.ts +1 -0
- package/dist/secrets/credential-sets/contracts.js +1 -0
- package/dist/secrets/credential-sets/index.d.ts +1 -0
- package/dist/secrets/credential-sets/index.js +1 -0
- package/dist/secrets/credential-sets/provider-matches.d.ts +61 -0
- package/dist/secrets/credential-sets/provider-matches.js +61 -0
- package/dist/secrets/credential-sets/types.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.meta.js +58 -20
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.repo.memory.js +43 -36
- package/dist/secrets/credential-sets/vault.service.d.ts +9 -0
- package/dist/secrets/credential-sets/vault.service.js +131 -61
- package/dist/secrets/credential-sets/vault.types.d.ts +11 -3
- package/dist/secrets/credential-sets/vault.types.js +22 -8
- package/dist/secrets/keyring.env.js +14 -10
- package/dist/toolchain.js +119 -91
- package/dist/web/html-document.js +73 -69
- package/dist/web/html-to-markdown.js +127 -102
- package/dist/web/web-page.js +160 -80
- package/package.json +11 -3
package/dist/download.js
CHANGED
|
@@ -166,8 +166,15 @@ async function directoryIsEmpty(path) {
|
|
|
166
166
|
const entries = await readdir(path);
|
|
167
167
|
return entries.length === 0;
|
|
168
168
|
}
|
|
169
|
+
// Every caller feeds this a `JSON.parse` result, and a parsed JSON array can
|
|
170
|
+
// never carry the named string keys (`kind`, `algorithm`, `pid`, ...) each
|
|
171
|
+
// guard goes on to require, so arrays are rejected without a separate
|
|
172
|
+
// `Array.isArray` check.
|
|
173
|
+
function isPlainObject(value) {
|
|
174
|
+
return typeof value === "object" && value != null;
|
|
175
|
+
}
|
|
169
176
|
function isStoreSentinel(value) {
|
|
170
|
-
if (
|
|
177
|
+
if (!isPlainObject(value))
|
|
171
178
|
return false;
|
|
172
179
|
const record = value;
|
|
173
180
|
return record.kind === STORE_KIND && record.schemaVersion === STORE_SCHEMA_VERSION && typeof record.createdAt === "string";
|
|
@@ -216,30 +223,33 @@ async function ensureManagedBase(basePath) {
|
|
|
216
223
|
await ensureStoreDirs(basePath);
|
|
217
224
|
}
|
|
218
225
|
function isChecksum(value) {
|
|
219
|
-
if (
|
|
226
|
+
if (!isPlainObject(value))
|
|
220
227
|
return false;
|
|
221
228
|
const checksum = value;
|
|
222
229
|
return ((checksum.algorithm === "sha256" || checksum.algorithm === "sha512") &&
|
|
223
230
|
typeof checksum.value === "string");
|
|
224
231
|
}
|
|
232
|
+
const MANIFEST_STRING_FIELDS = [
|
|
233
|
+
"bucket",
|
|
234
|
+
"fileName",
|
|
235
|
+
"targetKey",
|
|
236
|
+
"identityDigest",
|
|
237
|
+
"urlDigest",
|
|
238
|
+
"createdAt",
|
|
239
|
+
"updatedAt",
|
|
240
|
+
];
|
|
225
241
|
function isManifest(value) {
|
|
226
|
-
if (
|
|
242
|
+
if (!isPlainObject(value))
|
|
227
243
|
return false;
|
|
228
244
|
const record = value;
|
|
229
245
|
return (record.kind === MANIFEST_KIND &&
|
|
230
246
|
record.schemaVersion === MANIFEST_SCHEMA_VERSION &&
|
|
231
|
-
typeof record
|
|
232
|
-
typeof record.fileName === "string" &&
|
|
233
|
-
typeof record.targetKey === "string" &&
|
|
234
|
-
typeof record.identityDigest === "string" &&
|
|
235
|
-
typeof record.urlDigest === "string" &&
|
|
247
|
+
MANIFEST_STRING_FIELDS.every((field) => typeof record[field] === "string") &&
|
|
236
248
|
isChecksum(record.checksum) &&
|
|
237
|
-
(record.state === "complete" || record.state === "partial")
|
|
238
|
-
typeof record.createdAt === "string" &&
|
|
239
|
-
typeof record.updatedAt === "string");
|
|
249
|
+
(record.state === "complete" || record.state === "partial"));
|
|
240
250
|
}
|
|
241
251
|
function isDownloadLockFile(value) {
|
|
242
|
-
if (
|
|
252
|
+
if (!isPlainObject(value))
|
|
243
253
|
return false;
|
|
244
254
|
const record = value;
|
|
245
255
|
return typeof record.createdAt === "string" &&
|
|
@@ -326,7 +336,8 @@ async function statFileSize(path) {
|
|
|
326
336
|
}
|
|
327
337
|
async function emitExistingProgress(path, totalBytes, emit) {
|
|
328
338
|
// `totalBytes` is required (not `number | undefined`): its only real call
|
|
329
|
-
// site (`tryResumeDownload`) always resolves one, via a `??` chain
|
|
339
|
+
// site (`tryResumeDownload`) always resolves one, via a `??` chain (in
|
|
340
|
+
// `acceptedResumeTotalBytes`) whose
|
|
330
341
|
// last resort is arithmetic (`partialBytes + (contentLength(response) ??
|
|
331
342
|
// 0)`) — never `undefined`.
|
|
332
343
|
const existing = await statFileSize(path);
|
|
@@ -361,16 +372,21 @@ function parseContentRange(value) {
|
|
|
361
372
|
if (value == null)
|
|
362
373
|
return null;
|
|
363
374
|
const match = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(value.trim());
|
|
364
|
-
|
|
375
|
+
// Every group in the pattern is mandatory, so a match always carries all three.
|
|
376
|
+
if (match == null)
|
|
365
377
|
return null;
|
|
378
|
+
// `\d+` always parses to an integer — except a digit run long enough to
|
|
379
|
+
// overflow to Infinity, which `Number.isInteger` rejects.
|
|
366
380
|
const start = Number(match[1]);
|
|
367
381
|
const end = Number(match[2]);
|
|
368
|
-
const totalBytes = match[3] === "*" ? undefined : Number(match[3]);
|
|
369
382
|
if (!Number.isInteger(start) || !Number.isInteger(end) || end < start)
|
|
370
383
|
return null;
|
|
371
|
-
if (
|
|
384
|
+
if (match[3] === "*")
|
|
385
|
+
return { start, end };
|
|
386
|
+
const totalBytes = Number(match[3]);
|
|
387
|
+
if (!Number.isInteger(totalBytes) || totalBytes <= end)
|
|
372
388
|
return null;
|
|
373
|
-
return { start, end,
|
|
389
|
+
return { start, end, totalBytes };
|
|
374
390
|
}
|
|
375
391
|
async function writeResponseBodyToPartial(response, target, options) {
|
|
376
392
|
if (response.body == null)
|
|
@@ -391,24 +407,36 @@ async function writeResponseBodyToPartial(response, target, options) {
|
|
|
391
407
|
});
|
|
392
408
|
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(target.partialPath, { flags: options.startBytes > 0 ? "a" : "w" }));
|
|
393
409
|
}
|
|
394
|
-
|
|
410
|
+
function resumeRequestHeaders(input, options) {
|
|
411
|
+
const etag = input.manifest.validators?.etag;
|
|
412
|
+
return {
|
|
413
|
+
...(options.requestHeaders ?? {}),
|
|
414
|
+
...(etag == null ? {} : { "If-Range": etag }),
|
|
415
|
+
Range: `bytes=${input.partialBytes}-`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/** The resumed total size, or `null` when the response cannot continue the saved partial. */
|
|
419
|
+
function acceptedResumeTotalBytes(input) {
|
|
420
|
+
const { manifest, partialBytes, response } = input;
|
|
421
|
+
if (response.status !== 206)
|
|
422
|
+
return null;
|
|
423
|
+
const range = parseContentRange(response.headers.get("content-range"));
|
|
424
|
+
if (range == null || range.start !== partialBytes || validatorsConflict(manifest.validators, response)) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
return range.totalBytes ?? manifest.totalBytes ?? partialBytes + (contentLength(response) ?? 0);
|
|
428
|
+
}
|
|
429
|
+
async function tryResumeDownload(input, options) {
|
|
430
|
+
const { emit, fetchImpl, manifest, target } = input;
|
|
395
431
|
const partialBytes = await statFileSize(target.partialPath);
|
|
396
432
|
if (partialBytes == null || partialBytes <= 0)
|
|
397
433
|
return "restart";
|
|
398
434
|
const response = await fetchImpl(target.url, {
|
|
399
|
-
headers: {
|
|
400
|
-
...(requestHeaders ?? {}),
|
|
401
|
-
...(manifest.validators?.etag == null ? {} : { "If-Range": manifest.validators.etag }),
|
|
402
|
-
Range: `bytes=${partialBytes}-`,
|
|
403
|
-
},
|
|
435
|
+
headers: resumeRequestHeaders({ manifest, partialBytes }, options),
|
|
404
436
|
});
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const range = parseContentRange(response.headers.get("content-range"));
|
|
408
|
-
if (range == null || range.start !== partialBytes || validatorsConflict(manifest.validators, response)) {
|
|
437
|
+
const totalBytes = acceptedResumeTotalBytes({ manifest, partialBytes, response });
|
|
438
|
+
if (totalBytes == null)
|
|
409
439
|
return "restart";
|
|
410
|
-
}
|
|
411
|
-
const totalBytes = range.totalBytes ?? manifest.totalBytes ?? partialBytes + (contentLength(response) ?? 0);
|
|
412
440
|
await emitExistingProgress(target.partialPath, totalBytes, emit);
|
|
413
441
|
await writeJson(target.manifestPath, {
|
|
414
442
|
...manifest,
|
|
@@ -420,7 +448,9 @@ async function tryResumeDownload(target, manifest, fetchImpl, emit, requestHeade
|
|
|
420
448
|
await writeResponseBodyToPartial(response, target, { emit, startBytes: partialBytes, totalBytes });
|
|
421
449
|
return { resumed: true, totalBytes };
|
|
422
450
|
}
|
|
423
|
-
async function downloadFromZero(
|
|
451
|
+
async function downloadFromZero(input, options) {
|
|
452
|
+
const { emit, fetchImpl, target } = input;
|
|
453
|
+
const { requestHeaders } = options;
|
|
424
454
|
await rm(target.partialPath, { force: true }).catch(() => undefined);
|
|
425
455
|
const response = await fetchImpl(target.url, {
|
|
426
456
|
...(requestHeaders == null ? {} : { headers: requestHeaders }),
|
|
@@ -436,63 +466,66 @@ async function downloadFromZero(target, fetchImpl, emit, requestHeaders) {
|
|
|
436
466
|
async function downloadWithRetries(target, manifest, options) {
|
|
437
467
|
let lastError;
|
|
438
468
|
let nextManifest = manifest;
|
|
439
|
-
|
|
469
|
+
const deps = { emit: options.emit, fetchImpl: options.fetchImpl };
|
|
440
470
|
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
|
441
471
|
try {
|
|
442
472
|
if (nextManifest?.state === "partial") {
|
|
443
|
-
const resume = await tryResumeDownload(
|
|
473
|
+
const resume = await tryResumeDownload({ ...deps, manifest: nextManifest, target }, options);
|
|
444
474
|
if (resume !== "restart")
|
|
445
|
-
return
|
|
475
|
+
return resume;
|
|
446
476
|
await rm(target.partialPath, { force: true }).catch(() => undefined);
|
|
447
477
|
nextManifest = null;
|
|
448
478
|
}
|
|
449
|
-
|
|
450
|
-
resumed = resumed || full.resumed;
|
|
451
|
-
return { ...full, resumed };
|
|
479
|
+
return await downloadFromZero({ ...deps, target }, options);
|
|
452
480
|
}
|
|
453
481
|
catch (error) {
|
|
454
482
|
lastError = error;
|
|
455
|
-
|
|
456
|
-
if (partialBytes != null && partialBytes > 0) {
|
|
457
|
-
nextManifest = {
|
|
458
|
-
...(nextManifest ?? createManifest(target, "partial")),
|
|
459
|
-
state: "partial",
|
|
460
|
-
updatedAt: new Date().toISOString(),
|
|
461
|
-
};
|
|
462
|
-
await writeJson(target.manifestPath, nextManifest).catch(() => undefined);
|
|
463
|
-
}
|
|
483
|
+
nextManifest = await manifestAfterFailedAttempt({ manifest: nextManifest, target });
|
|
464
484
|
}
|
|
465
485
|
}
|
|
466
486
|
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.NETWORK_EXHAUSTED, `download failed after ${options.maxAttempts} attempts: ${errorMessage(lastError)}`);
|
|
467
487
|
}
|
|
488
|
+
/** After a failed attempt, mark any bytes already on disk as a resumable partial (best-effort persisted). */
|
|
489
|
+
async function manifestAfterFailedAttempt(input) {
|
|
490
|
+
const { manifest, target } = input;
|
|
491
|
+
const partialBytes = await statFileSize(target.partialPath);
|
|
492
|
+
if (partialBytes == null || partialBytes <= 0)
|
|
493
|
+
return manifest;
|
|
494
|
+
const next = {
|
|
495
|
+
...(manifest ?? createManifest(target, "partial")),
|
|
496
|
+
state: "partial",
|
|
497
|
+
updatedAt: new Date().toISOString(),
|
|
498
|
+
};
|
|
499
|
+
await writeJson(target.manifestPath, next).catch(() => undefined);
|
|
500
|
+
return next;
|
|
501
|
+
}
|
|
468
502
|
async function acquireLock(target) {
|
|
469
503
|
await mkdir(dirname(target.lockPath), { recursive: true });
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
504
|
+
// At most two create attempts: the second only after the first hit an
|
|
505
|
+
// existing lock that `clearStaleLock` proved stale and removed; a second
|
|
506
|
+
// EEXIST is real contention. (This was formerly a `for (;;)` loop whose
|
|
507
|
+
// fall-through branch V8 instrumented but no path could take — see archived
|
|
508
|
+
// provenance ledger's 2026-07-22 entry; the straight-line form has none.)
|
|
509
|
+
if (await tryCreateLockFile(target))
|
|
510
|
+
return { path: target.lockPath };
|
|
511
|
+
if (await clearStaleLock(target) && await tryCreateLockFile(target))
|
|
512
|
+
return { path: target.lockPath };
|
|
513
|
+
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.TARGET_LOCKED, `download target is locked: ${target.bucket}/${target.fileName}`);
|
|
514
|
+
}
|
|
515
|
+
/** Exclusively create the lock file; `false` when one already exists, any other failure is rethrown. */
|
|
516
|
+
async function tryCreateLockFile(target) {
|
|
517
|
+
try {
|
|
518
|
+
await writeFile(target.lockPath, `${JSON.stringify({
|
|
519
|
+
createdAt: new Date().toISOString(),
|
|
520
|
+
pid: process.pid,
|
|
521
|
+
processStartedAt: new Date(PROCESS_STARTED_AT_MS).toISOString(),
|
|
522
|
+
})}\n`, { flag: "wx" });
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
if (errorCode(error) === "EEXIST")
|
|
527
|
+
return false;
|
|
528
|
+
throw error;
|
|
496
529
|
}
|
|
497
530
|
}
|
|
498
531
|
async function clearStaleLock(target) {
|
|
@@ -519,88 +552,52 @@ async function loadReusableState(target) {
|
|
|
519
552
|
return await suspiciousReset(target);
|
|
520
553
|
}
|
|
521
554
|
if (manifest == null) {
|
|
522
|
-
|
|
523
|
-
return await suspiciousReset(target);
|
|
524
|
-
return { manifest: null };
|
|
555
|
+
return finalExists || partialExists ? await suspiciousReset(target) : { manifest: null };
|
|
525
556
|
}
|
|
526
557
|
if (!manifestMatchesTarget(manifest, target)) {
|
|
527
558
|
return await suspiciousReset(target);
|
|
528
559
|
}
|
|
529
|
-
if (manifest.state === "complete")
|
|
530
|
-
|
|
531
|
-
return await suspiciousReset(target);
|
|
532
|
-
const actual = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
|
|
533
|
-
if (actual !== target.checksum.value)
|
|
534
|
-
return await suspiciousReset(target);
|
|
535
|
-
const bytes = await statFileSize(target.finalPath);
|
|
536
|
-
if (bytes == null)
|
|
537
|
-
return await suspiciousReset(target);
|
|
538
|
-
return {
|
|
539
|
-
manifest,
|
|
540
|
-
result: {
|
|
541
|
-
bucket: target.bucket,
|
|
542
|
-
bytes,
|
|
543
|
-
checksum: target.checksum,
|
|
544
|
-
fileName: target.fileName,
|
|
545
|
-
path: target.finalPath,
|
|
546
|
-
reusedComplete: true,
|
|
547
|
-
resumed: false,
|
|
548
|
-
urlDigest: target.urlDigest,
|
|
549
|
-
},
|
|
550
|
-
};
|
|
551
|
-
}
|
|
560
|
+
if (manifest.state === "complete")
|
|
561
|
+
return await reusableCompleteState({ manifest, target });
|
|
552
562
|
if (!partialExists)
|
|
553
563
|
return await suspiciousReset(target);
|
|
554
564
|
return { manifest };
|
|
555
565
|
}
|
|
566
|
+
/** Reuse a complete download only when its final file exists and still hashes to the requested checksum. */
|
|
567
|
+
async function reusableCompleteState(input) {
|
|
568
|
+
const { manifest, target } = input;
|
|
569
|
+
// A missing final file needs no separate check: hashing it rejects, which
|
|
570
|
+
// maps to `null` and fails the checksum comparison into the same reset.
|
|
571
|
+
const actual = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
|
|
572
|
+
if (actual !== target.checksum.value)
|
|
573
|
+
return await suspiciousReset(target);
|
|
574
|
+
const bytes = await statFileSize(target.finalPath);
|
|
575
|
+
if (bytes == null)
|
|
576
|
+
return await suspiciousReset(target);
|
|
577
|
+
return {
|
|
578
|
+
manifest,
|
|
579
|
+
result: {
|
|
580
|
+
bucket: target.bucket,
|
|
581
|
+
bytes,
|
|
582
|
+
checksum: target.checksum,
|
|
583
|
+
fileName: target.fileName,
|
|
584
|
+
path: target.finalPath,
|
|
585
|
+
reusedComplete: true,
|
|
586
|
+
resumed: false,
|
|
587
|
+
urlDigest: target.urlDigest,
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
}
|
|
556
591
|
async function runManagedDownload(target, options) {
|
|
557
592
|
await ensureManagedBase(target.basePath);
|
|
558
593
|
await pruneManagedDownloads({ basePath: target.basePath }).catch(() => undefined);
|
|
559
|
-
|
|
594
|
+
const held = { lock: await acquireLock(target) };
|
|
560
595
|
try {
|
|
561
|
-
|
|
596
|
+
const state = await loadStateAllowingOneReset({ held, target });
|
|
562
597
|
if (state.result != null)
|
|
563
598
|
return state.result;
|
|
564
|
-
if (state.reset === true) {
|
|
565
|
-
// A reset removes the lock as part of the managed base cleanup, so
|
|
566
|
-
// reacquire it before writing the fresh target state.
|
|
567
|
-
await releaseLock(lock);
|
|
568
|
-
lock = null;
|
|
569
|
-
await ensureManagedBase(target.basePath);
|
|
570
|
-
lock = await acquireLock(target);
|
|
571
|
-
state = await loadReusableState(target);
|
|
572
|
-
if (state.result != null)
|
|
573
|
-
return state.result;
|
|
574
|
-
if (state.reset === true) {
|
|
575
|
-
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "download state kept resetting after base cleanup");
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
599
|
const download = await downloadWithRetries(target, state.manifest, options);
|
|
579
|
-
const
|
|
580
|
-
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, `downloaded partial could not be hashed: ${errorMessage(error)}`);
|
|
581
|
-
});
|
|
582
|
-
if (actual !== target.checksum.value) {
|
|
583
|
-
await resetOwnedBase(target.basePath).catch(() => undefined);
|
|
584
|
-
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.CHECKSUM_MISMATCH, "downloaded file checksum did not match requested payload", {
|
|
585
|
-
actual,
|
|
586
|
-
expected: target.checksum.value,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
await mkdir(dirname(target.finalPath), { recursive: true });
|
|
590
|
-
if (await pathExists(target.finalPath)) {
|
|
591
|
-
const existing = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
|
|
592
|
-
if (existing !== target.checksum.value) {
|
|
593
|
-
await resetOwnedBase(target.basePath).catch(() => undefined);
|
|
594
|
-
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "existing complete file did not match requested payload");
|
|
595
|
-
}
|
|
596
|
-
await rm(target.partialPath, { force: true }).catch(() => undefined);
|
|
597
|
-
}
|
|
598
|
-
else {
|
|
599
|
-
await rename(target.partialPath, target.finalPath);
|
|
600
|
-
}
|
|
601
|
-
const bytes = await statFileSize(target.finalPath);
|
|
602
|
-
if (bytes == null)
|
|
603
|
-
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "complete file is missing after promotion");
|
|
600
|
+
const bytes = await promoteVerifiedPartial(target);
|
|
604
601
|
await writeJson(target.manifestPath, createManifest(target, "complete", { totalBytes: download.totalBytes }));
|
|
605
602
|
return {
|
|
606
603
|
bucket: target.bucket,
|
|
@@ -614,8 +611,57 @@ async function runManagedDownload(target, options) {
|
|
|
614
611
|
};
|
|
615
612
|
}
|
|
616
613
|
finally {
|
|
617
|
-
await releaseLock(lock);
|
|
614
|
+
await releaseLock(held.lock);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/** Load reusable state, re-locking and reloading once if the first load had to reset the managed base. */
|
|
618
|
+
async function loadStateAllowingOneReset(input) {
|
|
619
|
+
const { held, target } = input;
|
|
620
|
+
const state = await loadReusableState(target);
|
|
621
|
+
if (state.reset !== true)
|
|
622
|
+
return state;
|
|
623
|
+
// A reset removes the lock as part of the managed base cleanup, so
|
|
624
|
+
// reacquire it before writing the fresh target state. `held.lock` is
|
|
625
|
+
// nulled first so a failed re-acquire never releases (deletes) a lock
|
|
626
|
+
// another process may have taken in the meantime.
|
|
627
|
+
await releaseLock(held.lock);
|
|
628
|
+
held.lock = null;
|
|
629
|
+
await ensureManagedBase(target.basePath);
|
|
630
|
+
held.lock = await acquireLock(target);
|
|
631
|
+
const reloaded = await loadReusableState(target);
|
|
632
|
+
if (reloaded.reset === true) {
|
|
633
|
+
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "download state kept resetting after base cleanup");
|
|
634
|
+
}
|
|
635
|
+
return reloaded;
|
|
636
|
+
}
|
|
637
|
+
/** Verify the downloaded partial's checksum, promote it to the final path, and return the final size. */
|
|
638
|
+
async function promoteVerifiedPartial(target) {
|
|
639
|
+
const actual = await hashFile(target.partialPath, target.checksum.algorithm).catch((error) => {
|
|
640
|
+
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, `downloaded partial could not be hashed: ${errorMessage(error)}`);
|
|
641
|
+
});
|
|
642
|
+
if (actual !== target.checksum.value) {
|
|
643
|
+
await resetOwnedBase(target.basePath).catch(() => undefined);
|
|
644
|
+
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.CHECKSUM_MISMATCH, "downloaded file checksum did not match requested payload", {
|
|
645
|
+
actual,
|
|
646
|
+
expected: target.checksum.value,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
await mkdir(dirname(target.finalPath), { recursive: true });
|
|
650
|
+
if (await pathExists(target.finalPath)) {
|
|
651
|
+
const existing = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
|
|
652
|
+
if (existing !== target.checksum.value) {
|
|
653
|
+
await resetOwnedBase(target.basePath).catch(() => undefined);
|
|
654
|
+
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "existing complete file did not match requested payload");
|
|
655
|
+
}
|
|
656
|
+
await rm(target.partialPath, { force: true }).catch(() => undefined);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
await rename(target.partialPath, target.finalPath);
|
|
618
660
|
}
|
|
661
|
+
const bytes = await statFileSize(target.finalPath);
|
|
662
|
+
if (bytes == null)
|
|
663
|
+
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "complete file is missing after promotion");
|
|
664
|
+
return bytes;
|
|
619
665
|
}
|
|
620
666
|
function activeKey(target) {
|
|
621
667
|
return `${target.basePath}\0${target.targetKey}\0${target.identityDigest}`;
|
|
@@ -768,14 +814,18 @@ export async function downloadCopyAndClear(options) {
|
|
|
768
814
|
await releaseCopyLease(lease);
|
|
769
815
|
}
|
|
770
816
|
}
|
|
771
|
-
|
|
772
|
-
|
|
817
|
+
/** Resolve a target's paths for callers (inspect/remove) that have no payload; the placeholder never reaches the network. */
|
|
818
|
+
function targetWithoutPayload(options) {
|
|
819
|
+
return targetFromOptions({
|
|
773
820
|
...options,
|
|
774
821
|
payload: {
|
|
775
822
|
checksum: { algorithm: "sha256", value: "0".repeat(64) },
|
|
776
823
|
url: "https://example.invalid/",
|
|
777
824
|
},
|
|
778
825
|
});
|
|
826
|
+
}
|
|
827
|
+
export async function inspectManagedDownload(options) {
|
|
828
|
+
const target = targetWithoutPayload(options);
|
|
779
829
|
await ensureManagedBase(target.basePath);
|
|
780
830
|
const rawManifest = await readManifest(target.manifestPath);
|
|
781
831
|
const manifest = rawManifest === "invalid" ? "unreadable" : rawManifest?.state ?? "missing";
|
|
@@ -788,13 +838,7 @@ export async function inspectManagedDownload(options) {
|
|
|
788
838
|
};
|
|
789
839
|
}
|
|
790
840
|
export async function removeManagedDownload(options) {
|
|
791
|
-
const target =
|
|
792
|
-
...options,
|
|
793
|
-
payload: {
|
|
794
|
-
checksum: { algorithm: "sha256", value: "0".repeat(64) },
|
|
795
|
-
url: "https://example.invalid/",
|
|
796
|
-
},
|
|
797
|
-
});
|
|
841
|
+
const target = targetWithoutPayload(options);
|
|
798
842
|
if (activeTargets.has(targetActiveKey(target))) {
|
|
799
843
|
throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.TARGET_LOCKED, `download target is active: ${target.bucket}/${target.fileName}`);
|
|
800
844
|
}
|