@scrymore/scry-deployer 0.10.0-next.20260929163821 → 0.10.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/bin/cli.js +42 -18
- package/lib/vendor/scf/VERSION +1 -1
- package/lib/vendor/scf/cli.js +9 -2
- package/lib/vendor/scf/from-sbcov.d.ts +0 -5
- package/lib/vendor/scf/from-sbcov.js +15 -3
- package/lib/vendor/scf/from-sidecars.js +1 -0
- package/lib/vendor/scf/image-dimensions.d.ts +58 -0
- package/lib/vendor/scf/image-dimensions.js +85 -12
- package/lib/vendor/scf/index.d.ts +5 -2
- package/lib/vendor/scf/index.js +4 -1
- package/lib/vendor/scf/sha256.js +1 -0
- package/lib/vendor/scf/sidecars-internal.js +3 -1
- package/lib/vendor/scf/storage-key.js +1 -0
- package/lib/vendor/scf/types.d.ts +70 -2
- package/lib/vendor/scf/types.js +37 -1
- package/lib/vendor/scf/validate.d.ts +33 -1
- package/lib/vendor/scf/validate.js +299 -70
- package/lib/vendor/scf/zip.js +1 -0
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const { zipDirectory } = require('../lib/archive.js');
|
|
10
|
-
const { getApiClient, uploadBuild } = require('../lib/apiClient.js');
|
|
10
|
+
const { getApiClient, uploadBuild, sanitizeServerText } = require('../lib/apiClient.js');
|
|
11
11
|
const { createLogger } = require('../lib/logger.js');
|
|
12
12
|
const { ApiError } = require('../lib/errors.js');
|
|
13
13
|
const { loadConfig } = require('../lib/config.js');
|
|
@@ -610,30 +610,43 @@ function reportIndexingOutcome({ argv, analysis, uploadResult, emptyArchive, sbc
|
|
|
610
610
|
return reportNoMetadataProduced(sbcovFailure, logger);
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
+
/** The parsed argv of the running command (set by middleware); handleError reads --verbose from it. */
|
|
614
|
+
let currentArgv = {};
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The one place every CLI failure ends. Prints a one-line human message, the
|
|
618
|
+
* `Ref: <id>` of the failing response (when it had one) and a suggestion;
|
|
619
|
+
* reports to Sentry (tagged with the request id) and flushes before exiting
|
|
620
|
+
* non-zero. The stack is printed only with --verbose.
|
|
621
|
+
*/
|
|
613
622
|
async function handleError(error, argv) {
|
|
614
623
|
const logger = createLogger(argv || {});
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
if (error.requestId) logger.error(`Ref: ${error.requestId}`);
|
|
626
|
-
if (error.statusCode === 401) {
|
|
624
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
625
|
+
// Strip server-provided control sequences, and never echo the API key.
|
|
626
|
+
let message = sanitizeServerText(err.message || 'Unknown error', 400);
|
|
627
|
+
const key = argv && (argv.apiKey || argv['api-key']);
|
|
628
|
+
if (typeof key === 'string' && key.length >= 4) message = message.split(key).join('<redacted>');
|
|
629
|
+
logger.error(`\n\u274c Error: ${message}`);
|
|
630
|
+
|
|
631
|
+
if (err instanceof ApiError) {
|
|
632
|
+
if (err.requestId) logger.error(`Ref: ${sanitizeServerText(err.requestId, 64)}`);
|
|
633
|
+
if (err.statusCode === 401) {
|
|
627
634
|
logger.error('Suggestion: Check that your API key is correct and has not expired.');
|
|
628
|
-
} else if (
|
|
635
|
+
} else if (err.statusCode >= 500) {
|
|
629
636
|
logger.error('Suggestion: This seems to be a server-side issue. Please try again later or contact support.');
|
|
630
637
|
}
|
|
631
638
|
}
|
|
632
639
|
|
|
633
|
-
if (argv && argv.verbose &&
|
|
634
|
-
logger.debug(
|
|
640
|
+
if (argv && argv.verbose && err.stack) {
|
|
641
|
+
logger.debug(err.stack);
|
|
635
642
|
}
|
|
636
643
|
|
|
644
|
+
// Report with an allowlisted subset of argv. Sending argv wholesale shipped
|
|
645
|
+
// the customer's --api-key to Sentry on every error.
|
|
646
|
+
captureCliError(err, argv);
|
|
647
|
+
// Ensure the event is sent before the process exits
|
|
648
|
+
try { await flushTelemetry(2000); } catch { /* telemetry must never mask the real error */ }
|
|
649
|
+
|
|
637
650
|
process.exit(1);
|
|
638
651
|
}
|
|
639
652
|
|
|
@@ -646,6 +659,17 @@ async function main() {
|
|
|
646
659
|
// The command handlers registered below do all the work; the parsed argv itself
|
|
647
660
|
// (yargs' .parse() resolution) is never needed here.
|
|
648
661
|
await yargs(hideBin(process.argv))
|
|
662
|
+
// Every failure surfaces as a rejection of parseAsync (below), handled by handleError;
|
|
663
|
+
// yargs must not print usage + a raw error and exit on its own. Usage errors (no Error
|
|
664
|
+
// object, e.g. a bad option) keep yargs' help output.
|
|
665
|
+
.exitProcess(false)
|
|
666
|
+
.fail((msg, err, y) => {
|
|
667
|
+
if (err) throw err;
|
|
668
|
+
y.showHelp('error');
|
|
669
|
+
console.error(`\n${msg}`);
|
|
670
|
+
process.exit(1);
|
|
671
|
+
})
|
|
672
|
+
.middleware((a) => { currentArgv = a; })
|
|
649
673
|
.command('$0', 'Deploy Storybook static build', (yargs) => {
|
|
650
674
|
return yargs
|
|
651
675
|
.option('dir', {
|
|
@@ -1100,10 +1124,10 @@ async function main() {
|
|
|
1100
1124
|
.help()
|
|
1101
1125
|
.alias('help', 'h')
|
|
1102
1126
|
.version(false) // Disable built-in version since we use -v for deploy-version
|
|
1103
|
-
.
|
|
1127
|
+
.parseAsync();
|
|
1104
1128
|
|
|
1105
1129
|
} catch (error) {
|
|
1106
|
-
await handleError(error,
|
|
1130
|
+
await handleError(error, currentArgv);
|
|
1107
1131
|
}
|
|
1108
1132
|
}
|
|
1109
1133
|
|
package/lib/vendor/scf/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
ff1be9eada71eb4343cf5ead2c53317671f1b413
|
package/lib/vendor/scf/cli.js
CHANGED
|
@@ -8,9 +8,15 @@ function printHuman(target, result) {
|
|
|
8
8
|
else {
|
|
9
9
|
console.log(`FAIL ${target}`);
|
|
10
10
|
}
|
|
11
|
+
const issueLocation = (i) => {
|
|
12
|
+
if (i.id)
|
|
13
|
+
return ` [${i.id}]`;
|
|
14
|
+
if (i.path)
|
|
15
|
+
return ` [${i.path}]`;
|
|
16
|
+
return '';
|
|
17
|
+
};
|
|
11
18
|
const printIssue = (prefix) => (i) => {
|
|
12
|
-
|
|
13
|
-
console.log(` ${prefix} ${i.code}${loc}: ${i.message}`);
|
|
19
|
+
console.log(` ${prefix} ${i.code}${issueLocation(i)}: ${i.message}`);
|
|
14
20
|
};
|
|
15
21
|
result.errors.forEach(printIssue('error'));
|
|
16
22
|
result.warnings.forEach(printIssue('warn '));
|
|
@@ -47,3 +53,4 @@ main()
|
|
|
47
53
|
console.error(err instanceof Error ? err.message : String(err));
|
|
48
54
|
process.exitCode = 1;
|
|
49
55
|
});
|
|
56
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import type { ScfManifest } from './types.js';
|
|
2
|
-
/** Mirrors Storybook's `toId(kind, name)` closely enough to reproduce the same identity that the
|
|
3
|
-
* dashboard's suggest feature already derives from `storyTitle` + `testName` when no `storyId`
|
|
4
|
-
* field is present in metadata.json (the normal case — see search-api-client.ts:208-228). This id
|
|
5
|
-
* is for capture identity only; the legacy storage key stays `basename(screenshotPath)` (contract §3,
|
|
6
|
-
* guarantee G1), so byte-identical web rows do not depend on this function. */
|
|
7
2
|
export declare function toStorybookId(title: string, name: string): string;
|
|
8
3
|
/**
|
|
9
4
|
* Converts a legacy sbcov `metadata.json` (+ optional `sbcov-manifest.json`) into an SCF 1.0
|
|
@@ -8,12 +8,23 @@ const ID_SANITIZE_RE = /[ ,'’()!@#$%^&*+=<>{}[\]|\\;:/?.]+/g;
|
|
|
8
8
|
* field is present in metadata.json (the normal case — see search-api-client.ts:208-228). This id
|
|
9
9
|
* is for capture identity only; the legacy storage key stays `basename(screenshotPath)` (contract §3,
|
|
10
10
|
* guarantee G1), so byte-identical web rows do not depend on this function. */
|
|
11
|
+
/** Trims leading/trailing `-` without a regex anchored on `$`, which sonarjs flags as
|
|
12
|
+
* super-linear: an unanchored quantifier run ending in a literal that never matches the
|
|
13
|
+
* string's actual end backtracks once per run position (O(n^2) on adversarial input). */
|
|
14
|
+
function trimDashes(value) {
|
|
15
|
+
let start = 0;
|
|
16
|
+
let end = value.length;
|
|
17
|
+
while (start < end && value[start] === '-')
|
|
18
|
+
start++;
|
|
19
|
+
while (end > start && value[end - 1] === '-')
|
|
20
|
+
end--;
|
|
21
|
+
return value.slice(start, end);
|
|
22
|
+
}
|
|
11
23
|
export function toStorybookId(title, name) {
|
|
12
|
-
const sanitize = (value) => value
|
|
24
|
+
const sanitize = (value) => trimDashes(value
|
|
13
25
|
.toLowerCase()
|
|
14
26
|
.replace(ID_SANITIZE_RE, '-')
|
|
15
|
-
.replace(/-+/g, '-')
|
|
16
|
-
.replace(/^-+|-+$/g, '');
|
|
27
|
+
.replace(/-+/g, '-'));
|
|
17
28
|
const kind = sanitize(title || 'unknown');
|
|
18
29
|
const leaf = name ? sanitize(name) : '';
|
|
19
30
|
return leaf ? `${kind}--${leaf}` : kind;
|
|
@@ -114,3 +125,4 @@ export function fromSbcov(metadataJson, manifestJson) {
|
|
|
114
125
|
}
|
|
115
126
|
return result;
|
|
116
127
|
}
|
|
128
|
+
//# sourceMappingURL=from-sbcov.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BundleFileMeasured } from './types.js';
|
|
1
2
|
/**
|
|
2
3
|
* Reads pixel width/height straight from an image's header — never decodes pixels — so the
|
|
3
4
|
* validator can enforce the spec's "at most 16384 px on the longest side" without pulling in an
|
|
@@ -16,4 +17,61 @@ export interface ImageDimensions {
|
|
|
16
17
|
}
|
|
17
18
|
/** Best-effort header-only dimension read for the three formats SCF allows. */
|
|
18
19
|
export declare function readImageDimensions(bytes: Uint8Array, family: 'png' | 'jpeg' | 'webp'): ImageDimensions | null;
|
|
20
|
+
export type ImageFamily = 'png' | 'jpeg' | 'webp';
|
|
21
|
+
/** Sniffs the magic bytes to tell which of the three SCF-allowed image formats `bytes` is, so a
|
|
22
|
+
* `.png` with the wrong content (or vice versa) is still caught. Moved here (from `validate.ts`)
|
|
23
|
+
* so `measureImage` below can use it without a circular import; re-exported from `validate.ts` for
|
|
24
|
+
* callers that only need family detection (e.g. a `{head, size}` image entry's magic-byte check). */
|
|
25
|
+
export declare function detectImageFamily(bytes: Uint8Array): ImageFamily | null;
|
|
26
|
+
export interface MeasuredImage extends ImageDimensions {
|
|
27
|
+
family: ImageFamily;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The recommended cap on how large a `prefixBytes` a streaming caller should ever accumulate before
|
|
31
|
+
* giving up on `measureImage` and treating the image as unmeasurable (ledger F69). PNG's IHDR is
|
|
32
|
+
* always in the first 24 bytes and WebP's header in the first ~30, so this bound is really about
|
|
33
|
+
* JPEG: a real photo's SOF0/SOF2 marker is essentially always within a few KiB, but a JPEG can
|
|
34
|
+
* legally carry a large APPn/EXIF segment (an embedded thumbnail, ICC profile, XMP block) before its
|
|
35
|
+
* first SOF marker. 64 KiB comfortably covers realistic EXIF payloads while keeping the *transient*
|
|
36
|
+
* per-image buffer a streaming caller holds — never the persisted record — small and bounded.
|
|
37
|
+
*/
|
|
38
|
+
export declare const MEASURE_IMAGE_MAX_PREFIX_BYTES: number;
|
|
39
|
+
/**
|
|
40
|
+
* Combines magic-byte family detection with a header-only dimension read into the one call a
|
|
41
|
+
* memory-bounded streaming reader needs (ledger F69: F32/F60's fixes still left the IMAGE member
|
|
42
|
+
* category exposed to the same "keep it all in memory" problem — an 8,000-entry bundle of honest,
|
|
43
|
+
* individually-tiny images could still retain ~500 MB via the old `{head, size}` shape, since a
|
|
44
|
+
* `{head, size}` entry whose real size is under the head cap retains the WHOLE image). A caller
|
|
45
|
+
* streaming a large bundle should feed this whatever *prefix* of an image's real decompressed bytes
|
|
46
|
+
* it has accumulated so far (never the whole file) — this function is a pure, stateless read of
|
|
47
|
+
* whatever prefix you hand it, so calling it again as more bytes arrive (up to
|
|
48
|
+
* `MEASURE_IMAGE_MAX_PREFIX_BYTES`, or once the entry finishes if it's smaller than that) is always
|
|
49
|
+
* safe and cheap; there is no separate "streaming" API to construct or tear down.
|
|
50
|
+
*
|
|
51
|
+
* Once this returns a non-null result (or the caller has accumulated `MEASURE_IMAGE_MAX_PREFIX_BYTES`
|
|
52
|
+
* and it's still null), the caller should discard the prefix buffer entirely and retain only the
|
|
53
|
+
* small `{family, width, height}` record plus the image's real total size — never the bytes
|
|
54
|
+
* themselves. That's the whole point: peak memory per image, beyond that small fixed record, is
|
|
55
|
+
* bounded by this function's own bounded input, not by how many images (or how large any one of
|
|
56
|
+
* them) the bundle contains.
|
|
57
|
+
*
|
|
58
|
+
* Returns null when the family can't be identified from the bytes present, OR when the family is
|
|
59
|
+
* known but the dimensions can't be read from this prefix (truncated/corrupt content, a WebP variant
|
|
60
|
+
* this parser doesn't cover, or — for JPEG — a real SOF marker that never showed up within the
|
|
61
|
+
* prefix a caller was willing to buffer). Both failure modes collapse to the same `null` on purpose:
|
|
62
|
+
* a caller holding only a bounded prefix has no bytes left over to tell "not a valid image" apart
|
|
63
|
+
* from "couldn't read far enough into a valid one" once that prefix is discarded, and a `{measured}`
|
|
64
|
+
* record built from a `null` result is rejected the same way either way (see `validate.ts`'s
|
|
65
|
+
* `IMAGE_FORMAT_INVALID` handling for a `{measured}` entry).
|
|
66
|
+
*/
|
|
67
|
+
export declare function measureImage(prefixBytes: Uint8Array): MeasuredImage | null;
|
|
68
|
+
/**
|
|
69
|
+
* Ledger F126 (G7): builds the `{measured: true, ...}` record a streaming caller hands `validateBundle`
|
|
70
|
+
* from a bounded prefix. Unlike `measureImage` (which collapses "wrong format" and "right format,
|
|
71
|
+
* header unreadable" into `null`), this KEEPS the detected family when the dimensions cannot be read
|
|
72
|
+
* (`width`/`height` 0), so `validateBundle` reports IMAGE_HEADER_UNREADABLE for a truncated header,
|
|
73
|
+
* exactly like the directory/CLI path, instead of IMAGE_FORMAT_INVALID. Use this, not `measureImage`,
|
|
74
|
+
* in any streaming reader (upload route, build processing).
|
|
75
|
+
*/
|
|
76
|
+
export declare function measureImageRecord(prefixBytes: Uint8Array, size: number): BundleFileMeasured;
|
|
19
77
|
//# sourceMappingURL=image-dimensions.d.ts.map
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reads pixel width/height straight from an image's header — never decodes pixels — so the
|
|
3
|
-
* validator can enforce the spec's "at most 16384 px on the longest side" without pulling in an
|
|
4
|
-
* image-decoding dependency (which would also break "zero runtime deps"). A tiny file can still
|
|
5
|
-
* declare an enormous canvas (PNG/JPEG/WebP all compress a large solid-colour image well under the
|
|
6
|
-
* 20 MB byte cap), which is a resource-exhaustion risk for whatever decodes it later (thumbnailing,
|
|
7
|
-
* pixel diff) — see ledger F25.
|
|
8
|
-
*
|
|
9
|
-
* Returns null when the format can't be determined (truncated/corrupt file, or a WebP variant this
|
|
10
|
-
* parser doesn't recognise) rather than guessing; callers should not error on null, only on a
|
|
11
|
-
* confirmed over-limit size.
|
|
12
|
-
*/
|
|
13
1
|
function pngDimensions(bytes) {
|
|
14
2
|
// Signature (8) + chunk length (4) + "IHDR" (4) + width (4) + height (4) = 24 bytes minimum.
|
|
15
3
|
if (bytes.length < 24)
|
|
@@ -92,3 +80,88 @@ export function readImageDimensions(bytes, family) {
|
|
|
92
80
|
return jpegDimensions(bytes);
|
|
93
81
|
return webpDimensions(bytes);
|
|
94
82
|
}
|
|
83
|
+
/** Sniffs the magic bytes to tell which of the three SCF-allowed image formats `bytes` is, so a
|
|
84
|
+
* `.png` with the wrong content (or vice versa) is still caught. Moved here (from `validate.ts`)
|
|
85
|
+
* so `measureImage` below can use it without a circular import; re-exported from `validate.ts` for
|
|
86
|
+
* callers that only need family detection (e.g. a `{head, size}` image entry's magic-byte check). */
|
|
87
|
+
export function detectImageFamily(bytes) {
|
|
88
|
+
if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
89
|
+
return 'png';
|
|
90
|
+
}
|
|
91
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
92
|
+
return 'jpeg';
|
|
93
|
+
}
|
|
94
|
+
if (bytes.length >= 12 &&
|
|
95
|
+
bytes[0] === 0x52 &&
|
|
96
|
+
bytes[1] === 0x49 &&
|
|
97
|
+
bytes[2] === 0x46 &&
|
|
98
|
+
bytes[3] === 0x46 &&
|
|
99
|
+
bytes[8] === 0x57 &&
|
|
100
|
+
bytes[9] === 0x45 &&
|
|
101
|
+
bytes[10] === 0x42 &&
|
|
102
|
+
bytes[11] === 0x50) {
|
|
103
|
+
return 'webp';
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The recommended cap on how large a `prefixBytes` a streaming caller should ever accumulate before
|
|
109
|
+
* giving up on `measureImage` and treating the image as unmeasurable (ledger F69). PNG's IHDR is
|
|
110
|
+
* always in the first 24 bytes and WebP's header in the first ~30, so this bound is really about
|
|
111
|
+
* JPEG: a real photo's SOF0/SOF2 marker is essentially always within a few KiB, but a JPEG can
|
|
112
|
+
* legally carry a large APPn/EXIF segment (an embedded thumbnail, ICC profile, XMP block) before its
|
|
113
|
+
* first SOF marker. 64 KiB comfortably covers realistic EXIF payloads while keeping the *transient*
|
|
114
|
+
* per-image buffer a streaming caller holds — never the persisted record — small and bounded.
|
|
115
|
+
*/
|
|
116
|
+
export const MEASURE_IMAGE_MAX_PREFIX_BYTES = 64 * 1024;
|
|
117
|
+
/**
|
|
118
|
+
* Combines magic-byte family detection with a header-only dimension read into the one call a
|
|
119
|
+
* memory-bounded streaming reader needs (ledger F69: F32/F60's fixes still left the IMAGE member
|
|
120
|
+
* category exposed to the same "keep it all in memory" problem — an 8,000-entry bundle of honest,
|
|
121
|
+
* individually-tiny images could still retain ~500 MB via the old `{head, size}` shape, since a
|
|
122
|
+
* `{head, size}` entry whose real size is under the head cap retains the WHOLE image). A caller
|
|
123
|
+
* streaming a large bundle should feed this whatever *prefix* of an image's real decompressed bytes
|
|
124
|
+
* it has accumulated so far (never the whole file) — this function is a pure, stateless read of
|
|
125
|
+
* whatever prefix you hand it, so calling it again as more bytes arrive (up to
|
|
126
|
+
* `MEASURE_IMAGE_MAX_PREFIX_BYTES`, or once the entry finishes if it's smaller than that) is always
|
|
127
|
+
* safe and cheap; there is no separate "streaming" API to construct or tear down.
|
|
128
|
+
*
|
|
129
|
+
* Once this returns a non-null result (or the caller has accumulated `MEASURE_IMAGE_MAX_PREFIX_BYTES`
|
|
130
|
+
* and it's still null), the caller should discard the prefix buffer entirely and retain only the
|
|
131
|
+
* small `{family, width, height}` record plus the image's real total size — never the bytes
|
|
132
|
+
* themselves. That's the whole point: peak memory per image, beyond that small fixed record, is
|
|
133
|
+
* bounded by this function's own bounded input, not by how many images (or how large any one of
|
|
134
|
+
* them) the bundle contains.
|
|
135
|
+
*
|
|
136
|
+
* Returns null when the family can't be identified from the bytes present, OR when the family is
|
|
137
|
+
* known but the dimensions can't be read from this prefix (truncated/corrupt content, a WebP variant
|
|
138
|
+
* this parser doesn't cover, or — for JPEG — a real SOF marker that never showed up within the
|
|
139
|
+
* prefix a caller was willing to buffer). Both failure modes collapse to the same `null` on purpose:
|
|
140
|
+
* a caller holding only a bounded prefix has no bytes left over to tell "not a valid image" apart
|
|
141
|
+
* from "couldn't read far enough into a valid one" once that prefix is discarded, and a `{measured}`
|
|
142
|
+
* record built from a `null` result is rejected the same way either way (see `validate.ts`'s
|
|
143
|
+
* `IMAGE_FORMAT_INVALID` handling for a `{measured}` entry).
|
|
144
|
+
*/
|
|
145
|
+
export function measureImage(prefixBytes) {
|
|
146
|
+
const family = detectImageFamily(prefixBytes);
|
|
147
|
+
if (!family)
|
|
148
|
+
return null;
|
|
149
|
+
const dims = readImageDimensions(prefixBytes, family);
|
|
150
|
+
if (!dims)
|
|
151
|
+
return null;
|
|
152
|
+
return { family, width: dims.width, height: dims.height };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Ledger F126 (G7): builds the `{measured: true, ...}` record a streaming caller hands `validateBundle`
|
|
156
|
+
* from a bounded prefix. Unlike `measureImage` (which collapses "wrong format" and "right format,
|
|
157
|
+
* header unreadable" into `null`), this KEEPS the detected family when the dimensions cannot be read
|
|
158
|
+
* (`width`/`height` 0), so `validateBundle` reports IMAGE_HEADER_UNREADABLE for a truncated header,
|
|
159
|
+
* exactly like the directory/CLI path, instead of IMAGE_FORMAT_INVALID. Use this, not `measureImage`,
|
|
160
|
+
* in any streaming reader (upload route, build processing).
|
|
161
|
+
*/
|
|
162
|
+
export function measureImageRecord(prefixBytes, size) {
|
|
163
|
+
const family = detectImageFamily(prefixBytes);
|
|
164
|
+
const dims = family ? readImageDimensions(prefixBytes, family) : null;
|
|
165
|
+
return { measured: true, family, width: dims?.width ?? 0, height: dims?.height ?? 0, size };
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=image-dimensions.js.map
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* @scrymore/scf — the Scry Capture Format 1.0 validator and converters.
|
|
3
3
|
* Zero runtime dependencies; safe to vendor into a Worker or a Node service (see repo README).
|
|
4
4
|
*/
|
|
5
|
-
export { validateBundle } from './validate.js';
|
|
5
|
+
export { validateBundle, checkStructureMember, checkSourceTextMember } from './validate.js';
|
|
6
6
|
export { fromSbcov, toStorybookId } from './from-sbcov.js';
|
|
7
7
|
export { fromSidecars } from './from-sidecars.js';
|
|
8
8
|
export { storageKey, companionKey, sourceKeyOf } from './storage-key.js';
|
|
9
9
|
export { sha256Hex } from './sha256.js';
|
|
10
|
-
export
|
|
10
|
+
export { bundleFileFull, bundleFileHead, bundleFileSize, isCheckedBundleFile, isMeasuredBundleFile } from './types.js';
|
|
11
|
+
export { detectImageFamily, measureImage, measureImageRecord, readImageDimensions, MEASURE_IMAGE_MAX_PREFIX_BYTES } from './image-dimensions.js';
|
|
12
|
+
export type { ImageFamily, MeasuredImage } from './image-dimensions.js';
|
|
13
|
+
export type { BundleFileBytes, BundleFileChecked, BundleFileHeadAndSize, BundleFileMeasured, BundleFiles, CaptureBlock, CaptureCode, CaptureCrop, CaptureFlow, CaptureKind, CaptureLinks, CaptureMethod, CaptureSourceText, CaptureStructure, CaptureVariant, DeviceRef, ScfCapture, ScfCounts, ScfManifest, ScfRepository, ScfSource, ScfTree, ScfTreeNode, Severity, SkipReason, StructureOrigin, ValidationIssue, ValidationResult, } from './types.js';
|
|
11
14
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/vendor/scf/index.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* @scrymore/scf — the Scry Capture Format 1.0 validator and converters.
|
|
3
3
|
* Zero runtime dependencies; safe to vendor into a Worker or a Node service (see repo README).
|
|
4
4
|
*/
|
|
5
|
-
export { validateBundle } from './validate.js';
|
|
5
|
+
export { validateBundle, checkStructureMember, checkSourceTextMember } from './validate.js';
|
|
6
6
|
export { fromSbcov, toStorybookId } from './from-sbcov.js';
|
|
7
7
|
export { fromSidecars } from './from-sidecars.js';
|
|
8
8
|
export { storageKey, companionKey, sourceKeyOf } from './storage-key.js';
|
|
9
9
|
export { sha256Hex } from './sha256.js';
|
|
10
|
+
export { bundleFileFull, bundleFileHead, bundleFileSize, isCheckedBundleFile, isMeasuredBundleFile } from './types.js';
|
|
11
|
+
export { detectImageFamily, measureImage, measureImageRecord, readImageDimensions, MEASURE_IMAGE_MAX_PREFIX_BYTES } from './image-dimensions.js';
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
package/lib/vendor/scf/sha256.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bundleFileFull } from './types.js';
|
|
1
2
|
/**
|
|
2
3
|
* Builds capture objects for "sidecar mode": one entry per image path, with an optional
|
|
3
4
|
* `<stem>.json` sidecar merged in. Shared by the native `"captures": "sidecars"` expansion in
|
|
@@ -8,7 +9,7 @@ export function sidecarCapturesFromImages(files, imagePaths) {
|
|
|
8
9
|
for (const imagePath of [...imagePaths].sort()) {
|
|
9
10
|
const dot = imagePath.lastIndexOf('.');
|
|
10
11
|
const stem = dot === -1 ? imagePath : imagePath.slice(0, dot);
|
|
11
|
-
const sidecarBytes = files.get(`${stem}.json`);
|
|
12
|
+
const sidecarBytes = bundleFileFull(files.get(`${stem}.json`));
|
|
12
13
|
let sidecar = {};
|
|
13
14
|
if (sidecarBytes) {
|
|
14
15
|
try {
|
|
@@ -27,3 +28,4 @@ export function sidecarCapturesFromImages(files, imagePaths) {
|
|
|
27
28
|
}
|
|
28
29
|
return captures;
|
|
29
30
|
}
|
|
31
|
+
//# sourceMappingURL=sidecars-internal.js.map
|
|
@@ -140,8 +140,76 @@ export interface ScfManifest {
|
|
|
140
140
|
warnings?: ValidationIssue[];
|
|
141
141
|
[key: string]: unknown;
|
|
142
142
|
}
|
|
143
|
-
/**
|
|
144
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Full bytes, or — for an image entry only — a memory-bounded stand-in: the first bytes of the
|
|
145
|
+
* file (enough for magic-byte family detection and a header-only dimension read, see
|
|
146
|
+
* `image-dimensions.ts`) plus its real total size. A caller that streams a large bundle instead of
|
|
147
|
+
* buffering it whole (ledger F31/F32: a Worker-safe bundle-upload route must never hold a full
|
|
148
|
+
* decompressed image, let alone a whole decompressed bundle, in memory) can supply this instead of
|
|
149
|
+
* the full decoded image. Every other bundle member (`scf.json`, `structure.file`/`sourceText.file`,
|
|
150
|
+
* sidecar JSON) MUST still be supplied in full — `validateBundle` only treats the `{head, size}`
|
|
151
|
+
* shape as an image, via the `image` field of a capture (security review F50).
|
|
152
|
+
*/
|
|
153
|
+
export type BundleFileHeadAndSize = {
|
|
154
|
+
head: Uint8Array;
|
|
155
|
+
size: number;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* A stand-in for a `structure/*.json` or `source/*` member whose full inflated bytes were already
|
|
159
|
+
* run through `checkStructureMember`/`checkSourceTextMember` (this package) by a streaming caller,
|
|
160
|
+
* which recorded whatever issues that produced and then discarded the bytes to stay within a
|
|
161
|
+
* bounded memory budget (security review F60: a Worker-safe bundle-upload route must not retain
|
|
162
|
+
* every non-image member in full up to its own per-entry cap — structure trees especially can run
|
|
163
|
+
* 100s of MB across a large Storybook). `size` is the member's real total byte length. Accepted by
|
|
164
|
+
* `validateBundle` ONLY for those two path prefixes; any other member given this shape is refused
|
|
165
|
+
* with `MEMBER_BYTES_REQUIRED`, same as an ordinary `{head, size}` entry would be.
|
|
166
|
+
*/
|
|
167
|
+
export type BundleFileChecked = {
|
|
168
|
+
checked: true;
|
|
169
|
+
size: number;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* A stand-in for an image entry that a streaming caller has already run through `measureImage`
|
|
173
|
+
* (`image-dimensions.ts`) against a bounded prefix of its real decompressed bytes, and then
|
|
174
|
+
* discarded the bytes entirely (ledger F69: even the `{head, size}` shape below still retains up to
|
|
175
|
+
* `imageHeadBytes` — the WHOLE image, for anything at or under that size — which an 8,000-image
|
|
176
|
+
* bundle of honest, individually-tiny images could turn into ~500 MB of live memory). `family` is
|
|
177
|
+
* `null` when `measureImage` couldn't identify or measure the image at all from the prefix the
|
|
178
|
+
* caller was willing to buffer (collapsed on purpose — see `measureImage`'s own doc comment); `size`
|
|
179
|
+
* is always the image's real total byte length, exactly as in `BundleFileHeadAndSize`. Accepted by
|
|
180
|
+
* `validateBundle` ONLY for image-extension paths, same restriction as `{head, size}` (F50) — any
|
|
181
|
+
* other member given this shape is refused with `MEMBER_BYTES_REQUIRED`.
|
|
182
|
+
*/
|
|
183
|
+
export type BundleFileMeasured = {
|
|
184
|
+
measured: true;
|
|
185
|
+
family: 'png' | 'jpeg' | 'webp' | null;
|
|
186
|
+
width: number;
|
|
187
|
+
height: number;
|
|
188
|
+
size: number;
|
|
189
|
+
};
|
|
190
|
+
export type BundleFileBytes = Uint8Array | BundleFileHeadAndSize | BundleFileChecked | BundleFileMeasured;
|
|
191
|
+
/** A bundle as an in-memory map of bundle-relative POSIX path -> file bytes (see `BundleFileBytes`). */
|
|
192
|
+
export type BundleFiles = Map<string, BundleFileBytes>;
|
|
193
|
+
/** True for a `{checked: true, size}` entry (ledger F60) — never for a plain `Uint8Array`, a
|
|
194
|
+
* `{head, size}` image entry, or a `{measured, ...}` image entry. */
|
|
195
|
+
export declare function isCheckedBundleFile(entry: BundleFileBytes | undefined): entry is BundleFileChecked;
|
|
196
|
+
/** True for a `{measured: true, family, width, height, size}` entry (ledger F69) — never for a
|
|
197
|
+
* plain `Uint8Array`, a `{head, size}` image entry, or a `{checked, size}` entry. */
|
|
198
|
+
export declare function isMeasuredBundleFile(entry: BundleFileBytes | undefined): entry is BundleFileMeasured;
|
|
199
|
+
/** Normalizes a `BundleFiles` entry to bytes usable for magic-byte/header inspection: the full
|
|
200
|
+
* bytes for a plain entry, or just the head for a `{head, size}` image entry. `undefined` for a
|
|
201
|
+
* `{checked, size}` or `{measured, ...}` entry (no bytes were ever retained for either) — never the
|
|
202
|
+
* image's real full content when given a partial entry; use `bundleFileSize` for the true byte
|
|
203
|
+
* length. */
|
|
204
|
+
export declare function bundleFileHead(entry: BundleFileBytes | undefined): Uint8Array | undefined;
|
|
205
|
+
/** The entry's full bytes, or `undefined` for a `{head, size}`, `{checked, size}` or `{measured,
|
|
206
|
+
* ...}` entry. Every non-image member (JSON, structure trees, source text) is read through this, so
|
|
207
|
+
* a partial entry can never be validated from its head alone (security review F50). */
|
|
208
|
+
export declare function bundleFileFull(entry: BundleFileBytes | undefined): Uint8Array | undefined;
|
|
209
|
+
/** The entry's real total byte size: `byteLength` for a full entry, or the caller-reported `size`
|
|
210
|
+
* for a `{head, size}`, `{checked, size}` or `{measured, ...}` entry (its true size, never a
|
|
211
|
+
* head/partial length). */
|
|
212
|
+
export declare function bundleFileSize(entry: BundleFileBytes | undefined): number | undefined;
|
|
145
213
|
/** scf-tree/1, see ../../../spec/scf-1.0.md. */
|
|
146
214
|
export interface ScfTreeNode {
|
|
147
215
|
type: string;
|
package/lib/vendor/scf/types.js
CHANGED
|
@@ -1,2 +1,38 @@
|
|
|
1
1
|
/** Types for the Scry Capture Format (SCF) 1.0. See ../../../spec/scf-1.0.md. */
|
|
2
|
-
|
|
2
|
+
/** True for a `{checked: true, size}` entry (ledger F60) — never for a plain `Uint8Array`, a
|
|
3
|
+
* `{head, size}` image entry, or a `{measured, ...}` image entry. */
|
|
4
|
+
export function isCheckedBundleFile(entry) {
|
|
5
|
+
return entry !== undefined && !(entry instanceof Uint8Array) && 'checked' in entry && entry.checked === true;
|
|
6
|
+
}
|
|
7
|
+
/** True for a `{measured: true, family, width, height, size}` entry (ledger F69) — never for a
|
|
8
|
+
* plain `Uint8Array`, a `{head, size}` image entry, or a `{checked, size}` entry. */
|
|
9
|
+
export function isMeasuredBundleFile(entry) {
|
|
10
|
+
return entry !== undefined && !(entry instanceof Uint8Array) && 'measured' in entry && entry.measured === true;
|
|
11
|
+
}
|
|
12
|
+
/** Normalizes a `BundleFiles` entry to bytes usable for magic-byte/header inspection: the full
|
|
13
|
+
* bytes for a plain entry, or just the head for a `{head, size}` image entry. `undefined` for a
|
|
14
|
+
* `{checked, size}` or `{measured, ...}` entry (no bytes were ever retained for either) — never the
|
|
15
|
+
* image's real full content when given a partial entry; use `bundleFileSize` for the true byte
|
|
16
|
+
* length. */
|
|
17
|
+
export function bundleFileHead(entry) {
|
|
18
|
+
if (entry === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (entry instanceof Uint8Array)
|
|
21
|
+
return entry;
|
|
22
|
+
return 'head' in entry ? entry.head : undefined;
|
|
23
|
+
}
|
|
24
|
+
/** The entry's full bytes, or `undefined` for a `{head, size}`, `{checked, size}` or `{measured,
|
|
25
|
+
* ...}` entry. Every non-image member (JSON, structure trees, source text) is read through this, so
|
|
26
|
+
* a partial entry can never be validated from its head alone (security review F50). */
|
|
27
|
+
export function bundleFileFull(entry) {
|
|
28
|
+
return entry instanceof Uint8Array ? entry : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** The entry's real total byte size: `byteLength` for a full entry, or the caller-reported `size`
|
|
31
|
+
* for a `{head, size}`, `{checked, size}` or `{measured, ...}` entry (its true size, never a
|
|
32
|
+
* head/partial length). */
|
|
33
|
+
export function bundleFileSize(entry) {
|
|
34
|
+
if (entry === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
return entry instanceof Uint8Array ? entry.byteLength : entry.size;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BundleFiles, ValidationResult } from './types.js';
|
|
1
|
+
import type { BundleFiles, ValidationIssue, ValidationResult } from './types.js';
|
|
2
2
|
/** Reads a directory recursively into a BundleFiles map. Node only — never imported by a Worker
|
|
3
3
|
* build, since callers only reach this path when `input` is a string (a filesystem path). */
|
|
4
4
|
/**
|
|
@@ -8,10 +8,42 @@ import type { BundleFiles, ValidationResult } from './types.js';
|
|
|
8
8
|
* the shared G6/G7 gate, so it refuses them outright instead of relying on every consumer.
|
|
9
9
|
*/
|
|
10
10
|
export declare function isSafeRelPath(path: string): boolean;
|
|
11
|
+
interface MemberCheckResult {
|
|
12
|
+
errors: ValidationIssue[];
|
|
13
|
+
warnings: ValidationIssue[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The content checks `validateBundle` applies to a `structure.file` member — a hard 10 MB size cap,
|
|
17
|
+
* a 2 MB soft (warning) threshold, and a shape check (parses as JSON and looks like a scf-tree/1
|
|
18
|
+
* document). Extracted (ledger F60) so a caller that streams a large bundle member-by-member rather
|
|
19
|
+
* than buffering the whole thing (e.g. a Worker-safe bundle-upload route, or `scry-build-processing-
|
|
20
|
+
* service`'s own streaming read) can run exactly these checks the moment a `structure/*.json` member
|
|
21
|
+
* is fully inflated, record the result, and then discard the bytes — passing `{checked: true, size}`
|
|
22
|
+
* for that member to `validateBundle` afterwards instead of its full content (see `BundleFileChecked`).
|
|
23
|
+
* `path` is only used in issue messages/paths, never re-derived from it; the caller decides which
|
|
24
|
+
* member this is. Returns no `id` — the caller (`validateBundle`, or a streaming caller once it has
|
|
25
|
+
* matched this path back to a capture) attaches that itself.
|
|
26
|
+
*/
|
|
27
|
+
export declare function checkStructureMember(path: string, bytes: Uint8Array): MemberCheckResult;
|
|
28
|
+
/**
|
|
29
|
+
* The content checks `validateBundle` applies to a `sourceText.file` member — a 1 MB size cap and a
|
|
30
|
+
* plausible-UTF-8-text check (`looksLikeBinary`/`isValidUtf8`, ledger F24). Extracted (ledger F60)
|
|
31
|
+
* for the same streaming reason as `checkStructureMember` above — see its doc comment.
|
|
32
|
+
*
|
|
33
|
+
* `optedIn` is a fast-path only: when a caller already knows, at check time, that the manifest does
|
|
34
|
+
* NOT set `optIn.sourceText: true` (e.g. it read `scf.json` earlier in the same stream), passing
|
|
35
|
+
* `false` raises `SOURCE_TEXT_NOT_OPT_IN` immediately for this member instead of spending time on the
|
|
36
|
+
* size/binary/UTF-8 scan. It is never required for correctness: `validateBundle`'s own aggregate
|
|
37
|
+
* `SOURCE_TEXT_NOT_OPT_IN` check (across every capture, once the whole manifest is known) is always
|
|
38
|
+
* the source of truth and runs regardless, which is why `validateBundle` itself always calls this
|
|
39
|
+
* with `optedIn: true` — it would otherwise duplicate its own aggregate error.
|
|
40
|
+
*/
|
|
41
|
+
export declare function checkSourceTextMember(path: string, bytes: Uint8Array, optedIn: boolean): MemberCheckResult;
|
|
11
42
|
/**
|
|
12
43
|
* Validates an SCF bundle (or a legacy sbcov bundle, converted first) against spec/scf-1.0.md.
|
|
13
44
|
* `input` is either an in-memory bundle (a Map of bundle-relative POSIX path -> bytes — the shape
|
|
14
45
|
* a Worker or the upload service already has after reading a ZIP) or a directory path (Node only).
|
|
15
46
|
*/
|
|
16
47
|
export declare function validateBundle(input: BundleFiles | string): Promise<ValidationResult>;
|
|
48
|
+
export {};
|
|
17
49
|
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fromSbcov } from './from-sbcov.js';
|
|
2
|
-
import { readImageDimensions } from './image-dimensions.js';
|
|
2
|
+
import { detectImageFamily, readImageDimensions } from './image-dimensions.js';
|
|
3
3
|
import { sidecarCapturesFromImages } from './sidecars-internal.js';
|
|
4
|
+
import { bundleFileFull, bundleFileHead, bundleFileSize, isCheckedBundleFile, isMeasuredBundleFile } from './types.js';
|
|
4
5
|
const SUPPORTED_SCF_VERSIONS = new Set(['1.0']);
|
|
5
6
|
const ALLOWED_IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'webp']);
|
|
6
7
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
@@ -9,36 +10,109 @@ const MAX_STRUCTURE_BYTES = 2 * 1024 * 1024; // soft (warning) threshold, spec's
|
|
|
9
10
|
const MAX_STRUCTURE_HARD_BYTES = 10 * 1024 * 1024; // hard (error) threshold
|
|
10
11
|
const MAX_SOURCE_TEXT_BYTES = 1 * 1024 * 1024;
|
|
11
12
|
const MAX_LINK_URL_LENGTH = 2048;
|
|
13
|
+
/** Spec MUSTs (bundle-wide, not per-entry): a bundle-wide entry-count cap that bounds validation
|
|
14
|
+
* cost regardless of how many members a bundle has, independent of any per-image/per-entry memory
|
|
15
|
+
* bound (ledger F69's own root cause, one root cause up: `readBoundedZip`'s own `maxEntries` already
|
|
16
|
+
* enforces this number before inflating a single byte; `validateBundle` enforces it too since it's
|
|
17
|
+
* the shared gate for any caller — CLI, a plain directory, a future non-ZIP source — not just a
|
|
18
|
+
* streaming ZIP reader). See spec/scf-1.0.md's "Bundle layout" section. */
|
|
19
|
+
const MAX_BUNDLE_MEMBERS = 20_000;
|
|
20
|
+
/** Spec MUST: a bundle-wide cap on `captures.length` (or the sidecar-derived equivalent), independent
|
|
21
|
+
* of the member-count cap above — a bundle could stay under `MAX_BUNDLE_MEMBERS` while still
|
|
22
|
+
* declaring an enormous `captures` array that shares images/duplicates ids, or (in sidecar mode)
|
|
23
|
+
* every capture is itself is a member so the two caps are related but not redundant. */
|
|
24
|
+
const MAX_BUNDLE_CAPTURES = 10_000;
|
|
12
25
|
const decoder = new TextDecoder();
|
|
13
26
|
function extOf(path) {
|
|
14
27
|
const m = /\.([a-zA-Z0-9]+)$/.exec(path);
|
|
15
28
|
return m ? m[1].toLowerCase() : '';
|
|
16
29
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (bytes.length >= 12 &&
|
|
27
|
-
bytes[0] === 0x52 &&
|
|
28
|
-
bytes[1] === 0x49 &&
|
|
29
|
-
bytes[2] === 0x46 &&
|
|
30
|
-
bytes[3] === 0x46 &&
|
|
31
|
-
bytes[8] === 0x57 &&
|
|
32
|
-
bytes[9] === 0x45 &&
|
|
33
|
-
bytes[10] === 0x42 &&
|
|
34
|
-
bytes[11] === 0x50) {
|
|
35
|
-
return 'webp';
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
30
|
+
/**
|
|
31
|
+
* The only two member prefixes a `{checked: true, size}` entry (ledger F60) is ever accepted for —
|
|
32
|
+
* a streaming caller may only skip retaining full bytes for these, since `checkStructureMember`/
|
|
33
|
+
* `checkSourceTextMember` are the only two extracted per-member checks this package exposes for
|
|
34
|
+
* that purpose. Anything else (scf.json, sidecar JSON, an image) must still be supplied in full or
|
|
35
|
+
* as the images-only `{head, size}` shape (F50).
|
|
36
|
+
*/
|
|
37
|
+
function isCheckableStructureOrSourcePath(path) {
|
|
38
|
+
return (path.startsWith('structure/') && extOf(path) === 'json') || path.startsWith('source/');
|
|
38
39
|
}
|
|
40
|
+
// ImageFamily/detectImageFamily live in image-dimensions.ts (shared with measureImage, ledger F69).
|
|
41
|
+
const EXT_FAMILY = { png: 'png', jpg: 'jpeg', jpeg: 'jpeg', webp: 'webp' };
|
|
39
42
|
function issue(code, message, extra) {
|
|
40
43
|
return { code, message, ...extra };
|
|
41
44
|
}
|
|
45
|
+
/** Ledger F125: every enum/const the published schema/scf-1.0.json enforces. The validator MUST
|
|
46
|
+
* reject exactly what the schema rejects (G7: schema, CLI and server agree). Keep in lockstep with
|
|
47
|
+
* the schema; test/schema-parity.test.ts runs every fixture through both and asserts they agree. */
|
|
48
|
+
const SOURCE_KINDS = new Set([
|
|
49
|
+
'storybook', 'storybook-rn', 'compose-preview', 'swiftui-preview', 'uikit', 'widgetbook', 'flutter-golden',
|
|
50
|
+
'playwright', 'cypress', 'maestro', 'xcuitest', 'crawler', 'figma', 'argos', 'percy', 'docs', 'upload',
|
|
51
|
+
]);
|
|
52
|
+
const SOURCE_PLATFORMS = new Set(['web', 'ios', 'android', 'macos', 'windows', 'email', 'other']);
|
|
53
|
+
const CAPTURE_METHODS = new Set([
|
|
54
|
+
'browser', 'simulator', 'emulator', 'device', 'jvm-render', 'headless-render', 'design-export', 'manual',
|
|
55
|
+
]);
|
|
56
|
+
const CAPTURE_CROPS = new Set(['root', 'viewport', 'fullpage', 'element', 'none']);
|
|
57
|
+
const CAPTURE_KINDS = new Set(['component', 'screen', 'page', 'flow-step', 'region', 'doc-image']);
|
|
58
|
+
const STRUCTURE_ORIGINS = new Set([
|
|
59
|
+
'dom', 'rn-fiber', 'compose-semantics', 'uiautomator', 'xcui-accessibility', 'flutter-widgets',
|
|
60
|
+
]);
|
|
61
|
+
const SKIP_REASONS = new Set(['error', 'timeout', 'filtered', 'unsupported', 'empty']);
|
|
62
|
+
function enumIssue(field, value, allowed, id) {
|
|
63
|
+
return issue('ENUM_VALUE_INVALID', `${field} must be one of ${[...allowed].join(', ')}: ${JSON.stringify(value)}`, { id });
|
|
64
|
+
}
|
|
65
|
+
/** Pushes ENUM_VALUE_INVALID when `value` is present (not undefined) and not in `allowed`. `null` is
|
|
66
|
+
* present and off-enum, matching JSON Schema. */
|
|
67
|
+
function checkEnum(errors, field, value, allowed, id) {
|
|
68
|
+
if (value === undefined)
|
|
69
|
+
return;
|
|
70
|
+
if (typeof value !== 'string' || !allowed.has(value))
|
|
71
|
+
errors.push(enumIssue(field, value, allowed, id));
|
|
72
|
+
}
|
|
73
|
+
function checkCaptureBlockEnums(errors, prefix, block, id) {
|
|
74
|
+
if (block === null || typeof block !== 'object' || Array.isArray(block))
|
|
75
|
+
return;
|
|
76
|
+
const b = block;
|
|
77
|
+
checkEnum(errors, `${prefix}.method`, b.method, CAPTURE_METHODS, id);
|
|
78
|
+
checkEnum(errors, `${prefix}.crop`, b.crop, CAPTURE_CROPS, id);
|
|
79
|
+
}
|
|
80
|
+
function checkManifestEnums(errors, manifest) {
|
|
81
|
+
const source = manifest.source;
|
|
82
|
+
if (source !== null && typeof source === 'object' && !Array.isArray(source)) {
|
|
83
|
+
const src = source;
|
|
84
|
+
const kind = src.kind;
|
|
85
|
+
if (kind !== undefined && (typeof kind !== 'string' || !(SOURCE_KINDS.has(kind) || /^x-./.test(kind)))) {
|
|
86
|
+
errors.push(issue('ENUM_VALUE_INVALID', `source.kind must be one of ${[...SOURCE_KINDS].join(', ')} or an x-<name> value: ${JSON.stringify(kind)}`));
|
|
87
|
+
}
|
|
88
|
+
checkEnum(errors, 'source.platform', src.platform, SOURCE_PLATFORMS);
|
|
89
|
+
}
|
|
90
|
+
const defaults = manifest.defaults;
|
|
91
|
+
if (defaults !== null && typeof defaults === 'object' && !Array.isArray(defaults)) {
|
|
92
|
+
checkCaptureBlockEnums(errors, 'defaults.capture', defaults.capture);
|
|
93
|
+
}
|
|
94
|
+
const skipped = manifest.counts?.skipped;
|
|
95
|
+
if (Array.isArray(skipped)) {
|
|
96
|
+
for (const item of skipped) {
|
|
97
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
98
|
+
checkEnum(errors, 'counts.skipped[].reason', item.reason, SKIP_REASONS);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function checkCaptureEnums(errors, capture, id) {
|
|
104
|
+
if (capture === null || typeof capture !== 'object')
|
|
105
|
+
return;
|
|
106
|
+
const c = capture;
|
|
107
|
+
checkEnum(errors, 'kind', c.kind, CAPTURE_KINDS, id);
|
|
108
|
+
checkCaptureBlockEnums(errors, 'capture', c.capture, id);
|
|
109
|
+
const structure = c.structure;
|
|
110
|
+
if (structure !== null && typeof structure === 'object' && !Array.isArray(structure)) {
|
|
111
|
+
const st = structure;
|
|
112
|
+
checkEnum(errors, 'structure.origin', st.origin, STRUCTURE_ORIGINS, id);
|
|
113
|
+
checkEnum(errors, 'structure.format', st.format, new Set(['scf-tree/1']), id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
42
116
|
/** Reads a directory recursively into a BundleFiles map. Node only — never imported by a Worker
|
|
43
117
|
* build, since callers only reach this path when `input` is a string (a filesystem path). */
|
|
44
118
|
/**
|
|
@@ -76,7 +150,10 @@ async function readDir(dir) {
|
|
|
76
150
|
return files;
|
|
77
151
|
}
|
|
78
152
|
function parseJson(files, path) {
|
|
79
|
-
|
|
153
|
+
const bytes = bundleFileFull(files.get(path));
|
|
154
|
+
if (bytes === undefined)
|
|
155
|
+
throw new Error(`${path} must be supplied in full, not as a {head, size} entry`);
|
|
156
|
+
return JSON.parse(decoder.decode(bytes));
|
|
80
157
|
}
|
|
81
158
|
/**
|
|
82
159
|
* `links.live` is auto-embedded as an iframe wherever Storybook is embedded today (contract §8);
|
|
@@ -156,15 +233,92 @@ function looksLikeScfTree(parsed) {
|
|
|
156
233
|
return false;
|
|
157
234
|
return typeof tree.root.type === 'string';
|
|
158
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* The content checks `validateBundle` applies to a `structure.file` member — a hard 10 MB size cap,
|
|
238
|
+
* a 2 MB soft (warning) threshold, and a shape check (parses as JSON and looks like a scf-tree/1
|
|
239
|
+
* document). Extracted (ledger F60) so a caller that streams a large bundle member-by-member rather
|
|
240
|
+
* than buffering the whole thing (e.g. a Worker-safe bundle-upload route, or `scry-build-processing-
|
|
241
|
+
* service`'s own streaming read) can run exactly these checks the moment a `structure/*.json` member
|
|
242
|
+
* is fully inflated, record the result, and then discard the bytes — passing `{checked: true, size}`
|
|
243
|
+
* for that member to `validateBundle` afterwards instead of its full content (see `BundleFileChecked`).
|
|
244
|
+
* `path` is only used in issue messages/paths, never re-derived from it; the caller decides which
|
|
245
|
+
* member this is. Returns no `id` — the caller (`validateBundle`, or a streaming caller once it has
|
|
246
|
+
* matched this path back to a capture) attaches that itself.
|
|
247
|
+
*/
|
|
248
|
+
export function checkStructureMember(path, bytes) {
|
|
249
|
+
const errors = [];
|
|
250
|
+
const warnings = [];
|
|
251
|
+
if (bytes.byteLength > MAX_STRUCTURE_HARD_BYTES) {
|
|
252
|
+
errors.push(issue('STRUCTURE_TREE_TOO_LARGE', `structure file is over 10 MB: ${path}`, { path }));
|
|
253
|
+
return { errors, warnings };
|
|
254
|
+
}
|
|
255
|
+
if (bytes.byteLength > MAX_STRUCTURE_BYTES) {
|
|
256
|
+
warnings.push(issue('STRUCTURE_TREE_LARGE', `structure file is over 2 MB: ${path}`, { path }));
|
|
257
|
+
}
|
|
258
|
+
let parsedTree;
|
|
259
|
+
try {
|
|
260
|
+
parsedTree = JSON.parse(decoder.decode(bytes));
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
parsedTree = undefined;
|
|
264
|
+
}
|
|
265
|
+
if (!looksLikeScfTree(parsedTree)) {
|
|
266
|
+
errors.push(issue('STRUCTURE_FORMAT_INVALID', `structure.file does not parse as a scf-tree/1 document: ${path}`, { path }));
|
|
267
|
+
}
|
|
268
|
+
return { errors, warnings };
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The content checks `validateBundle` applies to a `sourceText.file` member — a 1 MB size cap and a
|
|
272
|
+
* plausible-UTF-8-text check (`looksLikeBinary`/`isValidUtf8`, ledger F24). Extracted (ledger F60)
|
|
273
|
+
* for the same streaming reason as `checkStructureMember` above — see its doc comment.
|
|
274
|
+
*
|
|
275
|
+
* `optedIn` is a fast-path only: when a caller already knows, at check time, that the manifest does
|
|
276
|
+
* NOT set `optIn.sourceText: true` (e.g. it read `scf.json` earlier in the same stream), passing
|
|
277
|
+
* `false` raises `SOURCE_TEXT_NOT_OPT_IN` immediately for this member instead of spending time on the
|
|
278
|
+
* size/binary/UTF-8 scan. It is never required for correctness: `validateBundle`'s own aggregate
|
|
279
|
+
* `SOURCE_TEXT_NOT_OPT_IN` check (across every capture, once the whole manifest is known) is always
|
|
280
|
+
* the source of truth and runs regardless, which is why `validateBundle` itself always calls this
|
|
281
|
+
* with `optedIn: true` — it would otherwise duplicate its own aggregate error.
|
|
282
|
+
*/
|
|
283
|
+
export function checkSourceTextMember(path, bytes, optedIn) {
|
|
284
|
+
const errors = [];
|
|
285
|
+
const warnings = [];
|
|
286
|
+
if (bytes.byteLength > MAX_SOURCE_TEXT_BYTES) {
|
|
287
|
+
errors.push(issue('SOURCE_TEXT_TOO_LARGE', `sourceText.file is over 1 MB: ${path}`, { path }));
|
|
288
|
+
}
|
|
289
|
+
else if (looksLikeBinary(bytes) || !isValidUtf8(bytes)) {
|
|
290
|
+
errors.push(issue('SOURCE_TEXT_NOT_TEXT', `sourceText.file is not valid UTF-8 text: ${path}`, { path }));
|
|
291
|
+
}
|
|
292
|
+
if (optedIn === false) {
|
|
293
|
+
errors.push(issue('SOURCE_TEXT_NOT_OPT_IN', `sourceText.file present but the manifest does not set optIn.sourceText: true: ${path}`, { path }));
|
|
294
|
+
}
|
|
295
|
+
return { errors, warnings };
|
|
296
|
+
}
|
|
159
297
|
/**
|
|
160
298
|
* Validates an SCF bundle (or a legacy sbcov bundle, converted first) against spec/scf-1.0.md.
|
|
161
299
|
* `input` is either an in-memory bundle (a Map of bundle-relative POSIX path -> bytes — the shape
|
|
162
300
|
* a Worker or the upload service already has after reading a ZIP) or a directory path (Node only).
|
|
163
301
|
*/
|
|
302
|
+
// This is the package's single security-sensitive "shared gate" (contract guarantee G7; security-review
|
|
303
|
+
// ledger F18/F24/F25/F27/F50/F60/F69 all depend on its exact error set/order); decomposing it to reach the
|
|
304
|
+
// 15-point cognitive-complexity limit is a genuine behavior-risk rewrite, not an economical fix — tracked
|
|
305
|
+
// as pre-existing debt (F13 Sonar-lint pass) rather than attempted here.
|
|
306
|
+
// eslint-disable-next-line sonarjs/cognitive-complexity -- 187 vs 15; full decomposition deferred, see above
|
|
164
307
|
export async function validateBundle(input) {
|
|
165
308
|
const files = typeof input === 'string' ? await readDir(input) : input;
|
|
166
309
|
const errors = [];
|
|
167
310
|
const warnings = [];
|
|
311
|
+
// Spec MUST, bundle-wide entry-count cap (ledger F69): checked first, before anything else about
|
|
312
|
+
// this bundle is even looked at, so a bundle with an absurd member count is rejected in O(1) (a
|
|
313
|
+
// single Map.size read) rather than after however much per-member work the checks below would
|
|
314
|
+
// otherwise do. This is deliberately independent of any streaming reader's own `maxEntries` (e.g.
|
|
315
|
+
// scry-storybook-upload-service's `readBoundedZip`, which enforces the same number before inflating
|
|
316
|
+
// a single byte) — validateBundle is the shared gate (contract's guarantee G7) for every caller,
|
|
317
|
+
// including a plain directory or an in-memory map nobody streamed through a ZIP at all.
|
|
318
|
+
if (files.size > MAX_BUNDLE_MEMBERS) {
|
|
319
|
+
errors.push(issue('BUNDLE_TOO_MANY_MEMBERS', `Bundle has ${files.size} members, over the ${MAX_BUNDLE_MEMBERS} limit.`));
|
|
320
|
+
return { ok: false, errors, warnings, manifest: null };
|
|
321
|
+
}
|
|
168
322
|
const unsafeMembers = [...files.keys()].filter((p) => !isSafeRelPath(p));
|
|
169
323
|
if (unsafeMembers.length > 0) {
|
|
170
324
|
for (const p of unsafeMembers) {
|
|
@@ -172,6 +326,29 @@ export async function validateBundle(input) {
|
|
|
172
326
|
}
|
|
173
327
|
return { ok: false, errors, warnings, manifest: null };
|
|
174
328
|
}
|
|
329
|
+
// Ledger F50: the {head, size} shape is for images only. Ledger F60: a NEW, distinct shape,
|
|
330
|
+
// {checked: true, size}, is additionally accepted for structure/*.json and source/* members only —
|
|
331
|
+
// it means a streaming caller already ran checkStructureMember/checkSourceTextMember against this
|
|
332
|
+
// member's full inflated bytes, recorded whatever issues that produced, and discarded the bytes to
|
|
333
|
+
// stay within a bounded memory budget. Any other member given either partial shape (including a
|
|
334
|
+
// {checked, size} entry outside those two prefixes, or a {head, size} entry that isn't an image) is
|
|
335
|
+
// refused outright, so scf.json, sidecars and anything not explicitly exempted are always validated
|
|
336
|
+
// from their full bytes.
|
|
337
|
+
const partialNonImages = [...files.entries()]
|
|
338
|
+
.filter(([p, entry]) => {
|
|
339
|
+
if (entry instanceof Uint8Array)
|
|
340
|
+
return false;
|
|
341
|
+
if (isCheckedBundleFile(entry))
|
|
342
|
+
return !isCheckableStructureOrSourcePath(p);
|
|
343
|
+
return !ALLOWED_IMAGE_EXT.has(extOf(p));
|
|
344
|
+
})
|
|
345
|
+
.map(([p]) => p);
|
|
346
|
+
if (partialNonImages.length > 0) {
|
|
347
|
+
for (const p of partialNonImages) {
|
|
348
|
+
errors.push(issue('MEMBER_BYTES_REQUIRED', `Only images may be supplied as {head, size}; ${p} must be supplied in full.`, { path: p }));
|
|
349
|
+
}
|
|
350
|
+
return { ok: false, errors, warnings, manifest: null };
|
|
351
|
+
}
|
|
175
352
|
let manifest;
|
|
176
353
|
let isLegacy = false;
|
|
177
354
|
if (files.has('scf.json')) {
|
|
@@ -213,6 +390,7 @@ export async function validateBundle(input) {
|
|
|
213
390
|
if (typeof manifest.scf !== 'string' || !SUPPORTED_SCF_VERSIONS.has(manifest.scf)) {
|
|
214
391
|
errors.push(issue('SCF_VERSION_UNSUPPORTED', `Unsupported scf version: ${JSON.stringify(manifest.scf)}.`));
|
|
215
392
|
}
|
|
393
|
+
checkManifestEnums(errors, manifest);
|
|
216
394
|
const rawCaptures = manifest.captures;
|
|
217
395
|
const isSidecarMode = rawCaptures === 'sidecars';
|
|
218
396
|
let captures;
|
|
@@ -227,12 +405,21 @@ export async function validateBundle(input) {
|
|
|
227
405
|
errors.push(issue('CAPTURES_MISSING', 'captures is missing, and not "sidecars" either.'));
|
|
228
406
|
captures = [];
|
|
229
407
|
}
|
|
408
|
+
// Spec MUST, bundle-wide capture-count cap (ledger F69) — independent of MAX_BUNDLE_MEMBERS above:
|
|
409
|
+
// a bundle could stay under the member cap while still declaring (or, in sidecar mode, deriving) an
|
|
410
|
+
// enormous captures list. Checked before the per-capture loop below so a bundle over this limit
|
|
411
|
+
// doesn't also pay for however much per-capture validation work the loop would otherwise do.
|
|
412
|
+
if (captures.length > MAX_BUNDLE_CAPTURES) {
|
|
413
|
+
errors.push(issue('BUNDLE_TOO_MANY_CAPTURES', `Bundle has ${captures.length} captures, over the ${MAX_BUNDLE_CAPTURES} limit.`));
|
|
414
|
+
return { ok: false, errors, warnings, manifest };
|
|
415
|
+
}
|
|
230
416
|
const seenIds = new Map();
|
|
231
417
|
const seenImages = new Map();
|
|
232
418
|
const referencedPaths = new Set(['scf.json']);
|
|
233
419
|
const sourceTextCaptureIds = [];
|
|
234
420
|
for (const capture of captures) {
|
|
235
421
|
const id = typeof capture?.id === 'string' ? capture.id : undefined;
|
|
422
|
+
checkCaptureEnums(errors, capture, id);
|
|
236
423
|
if (!id || id.length > 512) {
|
|
237
424
|
errors.push(issue('CAPTURE_ID_INVALID', 'Capture id is missing, empty, or over 512 characters.', { id }));
|
|
238
425
|
}
|
|
@@ -253,27 +440,61 @@ export async function validateBundle(input) {
|
|
|
253
440
|
}
|
|
254
441
|
else {
|
|
255
442
|
const ext = extOf(image);
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
443
|
+
const imageEntry = files.get(image);
|
|
444
|
+
const size = bundleFileSize(imageEntry) ?? 0;
|
|
445
|
+
if (isMeasuredBundleFile(imageEntry)) {
|
|
446
|
+
// Ledger F69: a streaming caller already ran `measureImage` against a bounded prefix of
|
|
447
|
+
// this image's real bytes and discarded them — there is nothing left here to re-sniff or
|
|
448
|
+
// re-read a header from, only the small `{family, width, height, size}` record it kept.
|
|
449
|
+
// `family: null` is `measureImage`'s own collapsed "couldn't identify or measure it at
|
|
450
|
+
// all" result (see its doc comment) — treated the same as a format mismatch, since neither
|
|
451
|
+
// this validator nor the caller that discarded the bytes has any way left to tell "wrong
|
|
452
|
+
// format" apart from "header unreadable" once the prefix is gone.
|
|
453
|
+
const { family, width, height } = imageEntry;
|
|
454
|
+
if (!ALLOWED_IMAGE_EXT.has(ext) || !family || EXT_FAMILY[ext] !== family) {
|
|
455
|
+
errors.push(issue('IMAGE_FORMAT_INVALID', `Image is not PNG/JPEG/WebP: ${image}`, { id, path: image }));
|
|
456
|
+
}
|
|
457
|
+
if (size > MAX_IMAGE_BYTES) {
|
|
458
|
+
errors.push(issue('IMAGE_TOO_LARGE', `Image is over 20 MB: ${image}`, { id, path: image }));
|
|
459
|
+
}
|
|
460
|
+
if (family && EXT_FAMILY[ext] === family && (width < 1 || height < 1)) {
|
|
461
|
+
// Ledger F126 (G7): family identified but no dimensions recorded (see `measureImageRecord`):
|
|
462
|
+
// the same IMAGE_HEADER_UNREADABLE the full-bytes path reports for a truncated header.
|
|
270
463
|
errors.push(issue('IMAGE_HEADER_UNREADABLE', `Could not read image dimensions from the header: ${image}`, {
|
|
271
464
|
id,
|
|
272
465
|
path: image,
|
|
273
466
|
}));
|
|
274
467
|
}
|
|
275
|
-
else if (
|
|
276
|
-
errors.push(issue('IMAGE_DIMENSION_TOO_LARGE', `Image is ${
|
|
468
|
+
else if (family && (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION)) {
|
|
469
|
+
errors.push(issue('IMAGE_DIMENSION_TOO_LARGE', `Image is ${width}x${height}px, over the ${MAX_IMAGE_DIMENSION}px limit: ${image}`, { id, path: image }));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
// `head` is the whole file for a plain entry, or just its first bytes for a `{head, size}`
|
|
474
|
+
// partial image entry (ledger F31/F32) — either is enough for magic-byte + header-only
|
|
475
|
+
// dimension checks. `size` is always the image's real total byte length.
|
|
476
|
+
const head = bundleFileHead(imageEntry);
|
|
477
|
+
const family = head ? detectImageFamily(head) : null;
|
|
478
|
+
if (!ALLOWED_IMAGE_EXT.has(ext) || !family || EXT_FAMILY[ext] !== family) {
|
|
479
|
+
errors.push(issue('IMAGE_FORMAT_INVALID', `Image is not PNG/JPEG/WebP: ${image}`, { id, path: image }));
|
|
480
|
+
}
|
|
481
|
+
if (size > MAX_IMAGE_BYTES) {
|
|
482
|
+
errors.push(issue('IMAGE_TOO_LARGE', `Image is over 20 MB: ${image}`, { id, path: image }));
|
|
483
|
+
}
|
|
484
|
+
if (head && family) {
|
|
485
|
+
// Header-only read (no decode): a tiny file can still declare an enormous canvas, which
|
|
486
|
+
// is a resource-exhaustion risk for whatever decodes it later (ledger F25). An unreadable
|
|
487
|
+
// header (truncated file, or a WebP shape this parser doesn't cover) fails closed.
|
|
488
|
+
const dims = readImageDimensions(head, family);
|
|
489
|
+
if (!dims) {
|
|
490
|
+
errors.push(issue('IMAGE_HEADER_UNREADABLE', `Could not read image dimensions from the header: ${image}`, {
|
|
491
|
+
id,
|
|
492
|
+
path: image,
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
else if (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION) {
|
|
496
|
+
errors.push(issue('IMAGE_DIMENSION_TOO_LARGE', `Image is ${dims.width}x${dims.height}px, over the ${MAX_IMAGE_DIMENSION}px limit: ${image}`, { id, path: image }));
|
|
497
|
+
}
|
|
277
498
|
}
|
|
278
499
|
}
|
|
279
500
|
}
|
|
@@ -298,26 +519,25 @@ export async function validateBundle(input) {
|
|
|
298
519
|
}
|
|
299
520
|
else {
|
|
300
521
|
referencedPaths.add(structPath);
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
522
|
+
const entry = files.get(structPath);
|
|
523
|
+
if (isCheckedBundleFile(entry)) {
|
|
524
|
+
// Ledger F60: this member was already content-checked (checkStructureMember) and its bytes
|
|
525
|
+
// discarded by a streaming caller before validateBundle ever saw them. The cross-checks
|
|
526
|
+
// above (referenced-by-a-capture) and existence (an entry is present at all) are all that's
|
|
527
|
+
// left to do here — there is nothing left to re-check content-wise, and no bytes to do it
|
|
528
|
+
// with even if there were.
|
|
307
529
|
}
|
|
308
530
|
else {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
let parsedTree;
|
|
313
|
-
try {
|
|
314
|
-
parsedTree = JSON.parse(decoder.decode(structBytes));
|
|
531
|
+
const structBytes = bundleFileFull(entry);
|
|
532
|
+
if (!structBytes) {
|
|
533
|
+
errors.push(issue('STRUCTURE_FILE_MISSING', `structure.file not found in bundle: ${structPath}`, { id, path: structPath }));
|
|
315
534
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
535
|
+
else {
|
|
536
|
+
const result = checkStructureMember(structPath, structBytes);
|
|
537
|
+
for (const e of result.errors)
|
|
538
|
+
errors.push({ ...e, id });
|
|
539
|
+
for (const w of result.warnings)
|
|
540
|
+
warnings.push({ ...w, id });
|
|
321
541
|
}
|
|
322
542
|
}
|
|
323
543
|
}
|
|
@@ -338,21 +558,29 @@ export async function validateBundle(input) {
|
|
|
338
558
|
}
|
|
339
559
|
else {
|
|
340
560
|
referencedPaths.add(sourcePath);
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
path: sourcePath,
|
|
346
|
-
}));
|
|
347
|
-
}
|
|
348
|
-
else if (sourceBytes.byteLength > MAX_SOURCE_TEXT_BYTES) {
|
|
349
|
-
errors.push(issue('SOURCE_TEXT_TOO_LARGE', `sourceText.file is over 1 MB: ${sourcePath}`, { id, path: sourcePath }));
|
|
561
|
+
const entry = files.get(sourcePath);
|
|
562
|
+
if (isCheckedBundleFile(entry)) {
|
|
563
|
+
// Ledger F60: already content-checked (checkSourceTextMember) upstream; see the structure.file
|
|
564
|
+
// branch above for the full reasoning — same shape, same trust boundary.
|
|
350
565
|
}
|
|
351
|
-
else
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
566
|
+
else {
|
|
567
|
+
const sourceBytes = bundleFileFull(entry);
|
|
568
|
+
if (!sourceBytes) {
|
|
569
|
+
errors.push(issue('SOURCE_TEXT_FILE_MISSING', `sourceText.file not found in bundle: ${sourcePath}`, {
|
|
570
|
+
id,
|
|
571
|
+
path: sourcePath,
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
// optedIn: true — the aggregate SOURCE_TEXT_NOT_OPT_IN check below (sourceTextCaptureIds vs.
|
|
576
|
+
// manifest.optIn.sourceText) is this function's sole authority on opt-in; see
|
|
577
|
+
// checkSourceTextMember's own doc comment for why validateBundle always passes true here.
|
|
578
|
+
const result = checkSourceTextMember(sourcePath, sourceBytes, true);
|
|
579
|
+
for (const e of result.errors)
|
|
580
|
+
errors.push({ ...e, id });
|
|
581
|
+
for (const w of result.warnings)
|
|
582
|
+
warnings.push({ ...w, id });
|
|
583
|
+
}
|
|
356
584
|
}
|
|
357
585
|
}
|
|
358
586
|
}
|
|
@@ -407,3 +635,4 @@ export async function validateBundle(input) {
|
|
|
407
635
|
}
|
|
408
636
|
return { ok: errors.length === 0, errors, warnings, manifest };
|
|
409
637
|
}
|
|
638
|
+
//# sourceMappingURL=validate.js.map
|
package/lib/vendor/scf/zip.js
CHANGED