@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 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
- logger.error(`\n❌ Error: ${error.message}`);
616
-
617
- // Report with an allowlisted subset of argv. Sending argv wholesale shipped
618
- // the customer's --api-key to Sentry on every error.
619
- captureCliError(error, argv);
620
-
621
- // Ensure the event is sent before the process exits
622
- await flushTelemetry(2000);
623
-
624
- if (error instanceof ApiError) {
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 (error.statusCode >= 500) {
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 && error.stack) {
634
- logger.debug(error.stack);
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
- .parse();
1127
+ .parseAsync();
1104
1128
 
1105
1129
  } catch (error) {
1106
- await handleError(error, error.config || {});
1130
+ await handleError(error, currentArgv);
1107
1131
  }
1108
1132
  }
1109
1133
 
@@ -1 +1 @@
1
- 290890e25589fb329a3052e32215ef3096683035
1
+ ff1be9eada71eb4343cf5ead2c53317671f1b413
@@ -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
- const loc = i.id ? ` [${i.id}]` : i.path ? ` [${i.path}]` : '';
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
@@ -19,3 +19,4 @@ export function fromSidecars(files, sourceKind = 'upload') {
19
19
  captures,
20
20
  };
21
21
  }
22
+ //# sourceMappingURL=from-sidecars.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 type { 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';
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
@@ -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
@@ -96,3 +96,4 @@ export function sha256Hex(data) {
96
96
  hex += HEX[b];
97
97
  return hex;
98
98
  }
99
+ //# sourceMappingURL=sha256.js.map
@@ -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
@@ -25,3 +25,4 @@ export function sourceKeyOf(manifest) {
25
25
  const platform = manifest.source.platform ?? 'web';
26
26
  return `${manifest.source.kind}:${platform}`;
27
27
  }
28
+ //# sourceMappingURL=storage-key.js.map
@@ -140,8 +140,76 @@ export interface ScfManifest {
140
140
  warnings?: ValidationIssue[];
141
141
  [key: string]: unknown;
142
142
  }
143
- /** A bundle as an in-memory map of bundle-relative POSIX path -> file bytes. */
144
- export type BundleFiles = Map<string, Uint8Array>;
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;
@@ -1,2 +1,38 @@
1
1
  /** Types for the Scry Capture Format (SCF) 1.0. See ../../../spec/scf-1.0.md. */
2
- export {};
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
- const EXT_FAMILY = { png: 'png', jpg: 'jpeg', jpeg: 'jpeg', webp: 'webp' };
18
- /** Sniffs the magic bytes so a `.png` with the wrong content (or vice versa) is still caught. */
19
- function detectImageFamily(bytes) {
20
- if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
21
- return 'png';
22
- }
23
- if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
24
- return 'jpeg';
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
- return JSON.parse(decoder.decode(files.get(path)));
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 bytes = files.get(image);
257
- const family = bytes ? detectImageFamily(bytes) : null;
258
- if (!ALLOWED_IMAGE_EXT.has(ext) || !family || EXT_FAMILY[ext] !== family) {
259
- errors.push(issue('IMAGE_FORMAT_INVALID', `Image is not PNG/JPEG/WebP: ${image}`, { id, path: image }));
260
- }
261
- if ((bytes?.byteLength ?? 0) > MAX_IMAGE_BYTES) {
262
- errors.push(issue('IMAGE_TOO_LARGE', `Image is over 20 MB: ${image}`, { id, path: image }));
263
- }
264
- if (bytes && family) {
265
- // Header-only read (no decode): a tiny file can still declare an enormous canvas, which
266
- // is a resource-exhaustion risk for whatever decodes it later (ledger F25). An unreadable
267
- // header (truncated file, or a WebP shape this parser doesn't cover) fails closed.
268
- const dims = readImageDimensions(bytes, family);
269
- if (!dims) {
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 (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION) {
276
- 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 }));
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 structBytes = files.get(structPath);
302
- if (!structBytes) {
303
- errors.push(issue('STRUCTURE_FILE_MISSING', `structure.file not found in bundle: ${structPath}`, { id, path: structPath }));
304
- }
305
- else if (structBytes.byteLength > MAX_STRUCTURE_HARD_BYTES) {
306
- errors.push(issue('STRUCTURE_TREE_TOO_LARGE', `structure file is over 10 MB: ${structPath}`, { id, path: structPath }));
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
- if (structBytes.byteLength > MAX_STRUCTURE_BYTES) {
310
- warnings.push(issue('STRUCTURE_TREE_LARGE', `structure file is over 2 MB: ${structPath}`, { id, path: structPath }));
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
- catch {
317
- parsedTree = undefined;
318
- }
319
- if (!looksLikeScfTree(parsedTree)) {
320
- errors.push(issue('STRUCTURE_FORMAT_INVALID', `structure.file does not parse as a scf-tree/1 document: ${structPath}`, { id, path: structPath }));
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 sourceBytes = files.get(sourcePath);
342
- if (!sourceBytes) {
343
- errors.push(issue('SOURCE_TEXT_FILE_MISSING', `sourceText.file not found in bundle: ${sourcePath}`, {
344
- id,
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 if (looksLikeBinary(sourceBytes) || !isValidUtf8(sourceBytes)) {
352
- errors.push(issue('SOURCE_TEXT_NOT_TEXT', `sourceText.file is not valid UTF-8 text: ${sourcePath}`, {
353
- id,
354
- path: sourcePath,
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
@@ -58,3 +58,4 @@ function readLocalEntry(buf, localHeaderOffset, compressionMethod, compressedSiz
58
58
  return new Uint8Array(inflateRawSync(compressed));
59
59
  throw new Error(`Unsupported ZIP compression method: ${compressionMethod}`);
60
60
  }
61
+ //# sourceMappingURL=zip.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.10.0-next.20260929163821",
3
+ "version": "0.10.0",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {