@gmickel/gno 1.32.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/assets/skill/SKILL.md +11 -0
- package/assets/skill/cli-reference.md +10 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +16 -1
- package/spec/output-schemas/publish-artifact.schema.json +76 -1
- package/src/cli/commands/publish.ts +43 -7
- package/src/ingestion/strip.ts +152 -26
- package/src/publish/artifact-asset-codec.ts +75 -0
- package/src/publish/artifact-asset-contract.ts +152 -0
- package/src/publish/artifact-asset-parse.ts +401 -0
- package/src/publish/artifact-asset-sniff.ts +108 -0
- package/src/publish/artifact-asset-validate.ts +209 -0
- package/src/publish/artifact-assets.ts +58 -0
- package/src/publish/artifact-validation.ts +32 -6
- package/src/publish/artifact.ts +50 -3
- package/src/publish/attachment-bundle.ts +145 -0
- package/src/publish/attachment-discover.ts +203 -0
- package/src/publish/attachment-load.ts +133 -0
- package/src/publish/attachment-obsidian.ts +45 -0
- package/src/publish/attachment-path.ts +334 -0
- package/src/publish/attachment-raster.ts +852 -0
- package/src/publish/attachment-resolver.ts +280 -0
- package/src/publish/attachment-types.ts +54 -0
- package/src/publish/encrypted-export.ts +121 -44
- package/src/publish/export-attachments.ts +224 -0
- package/src/publish/export-service.ts +142 -80
- package/src/publish/obsidian-sanitize.ts +121 -13
- package/src/serve/routes/api.ts +2 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded raster signature + dimension validation for publish attachments.
|
|
3
|
+
* Extension/MIME are untrusted; only sniffed bytes decide media type.
|
|
4
|
+
*
|
|
5
|
+
* Structural validation is synchronous and closed-parser safe.
|
|
6
|
+
* Full image decodability is a separate async producer/file-ingress check.
|
|
7
|
+
*
|
|
8
|
+
* @module src/publish/attachment-raster
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// node:zlib is required because Bun.inflateSync has no bounded-output option.
|
|
12
|
+
import { inflateSync } from "node:zlib";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
MAX_PUBLISH_UPLOAD_BYTES,
|
|
16
|
+
MAX_RASTER_DIMENSION_PX,
|
|
17
|
+
MIN_RASTER_DIMENSION_PX,
|
|
18
|
+
type PublishAssetDiagnosticCode,
|
|
19
|
+
type SupportedRasterMediaType,
|
|
20
|
+
} from "./artifact-asset-contract";
|
|
21
|
+
import { sniffRasterMediaType } from "./artifact-asset-sniff";
|
|
22
|
+
|
|
23
|
+
/** Header probe size: enough for signatures + early boxes/markers. */
|
|
24
|
+
export const RASTER_HEADER_PROBE_BYTES = 65_536;
|
|
25
|
+
|
|
26
|
+
export interface RasterValidationOk {
|
|
27
|
+
ok: true;
|
|
28
|
+
height: number;
|
|
29
|
+
mediaType: SupportedRasterMediaType;
|
|
30
|
+
width: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RasterValidationFail {
|
|
34
|
+
ok: false;
|
|
35
|
+
code: PublishAssetDiagnosticCode;
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type RasterValidationResult = RasterValidationOk | RasterValidationFail;
|
|
40
|
+
|
|
41
|
+
const fail = (
|
|
42
|
+
code: PublishAssetDiagnosticCode,
|
|
43
|
+
message: string
|
|
44
|
+
): RasterValidationFail => ({ ok: false, code, message });
|
|
45
|
+
|
|
46
|
+
const asciiSlice = (bytes: Uint8Array, start: number, end: number): string =>
|
|
47
|
+
String.fromCharCode(...bytes.subarray(start, end));
|
|
48
|
+
|
|
49
|
+
const readU16BE = (bytes: Uint8Array, offset: number): number =>
|
|
50
|
+
((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
|
|
51
|
+
|
|
52
|
+
const readU16LE = (bytes: Uint8Array, offset: number): number =>
|
|
53
|
+
(bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8);
|
|
54
|
+
|
|
55
|
+
const readU24LE = (bytes: Uint8Array, offset: number): number =>
|
|
56
|
+
(bytes[offset] ?? 0) |
|
|
57
|
+
((bytes[offset + 1] ?? 0) << 8) |
|
|
58
|
+
((bytes[offset + 2] ?? 0) << 16);
|
|
59
|
+
|
|
60
|
+
const readU32BE = (bytes: Uint8Array, offset: number): number =>
|
|
61
|
+
(((bytes[offset] ?? 0) << 24) |
|
|
62
|
+
((bytes[offset + 1] ?? 0) << 16) |
|
|
63
|
+
((bytes[offset + 2] ?? 0) << 8) |
|
|
64
|
+
(bytes[offset + 3] ?? 0)) >>>
|
|
65
|
+
0;
|
|
66
|
+
|
|
67
|
+
const readU32LE = (bytes: Uint8Array, offset: number): number =>
|
|
68
|
+
(((bytes[offset + 3] ?? 0) << 24) |
|
|
69
|
+
((bytes[offset + 2] ?? 0) << 16) |
|
|
70
|
+
((bytes[offset + 1] ?? 0) << 8) |
|
|
71
|
+
(bytes[offset] ?? 0)) >>>
|
|
72
|
+
0;
|
|
73
|
+
|
|
74
|
+
const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
75
|
+
let crc = value;
|
|
76
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
77
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
78
|
+
}
|
|
79
|
+
return crc >>> 0;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const crc32 = (bytes: Uint8Array, start: number, end: number): number => {
|
|
83
|
+
let crc = 0xffffffff;
|
|
84
|
+
for (let index = start; index < end; index += 1) {
|
|
85
|
+
const tableIndex = (crc ^ (bytes[index] ?? 0)) & 0xff;
|
|
86
|
+
crc = (crc >>> 8) ^ (CRC32_TABLE[tableIndex] ?? 0);
|
|
87
|
+
}
|
|
88
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const validateDimensions = (
|
|
92
|
+
width: number,
|
|
93
|
+
height: number
|
|
94
|
+
): RasterValidationFail | null => {
|
|
95
|
+
if (
|
|
96
|
+
!Number.isSafeInteger(width) ||
|
|
97
|
+
!Number.isSafeInteger(height) ||
|
|
98
|
+
width < MIN_RASTER_DIMENSION_PX ||
|
|
99
|
+
height < MIN_RASTER_DIMENSION_PX
|
|
100
|
+
) {
|
|
101
|
+
return fail(
|
|
102
|
+
"ASSET_DIMENSION_INVALID",
|
|
103
|
+
`Raster dimensions ${width}x${height} are zero or non-integer`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (width > MAX_RASTER_DIMENSION_PX || height > MAX_RASTER_DIMENSION_PX) {
|
|
107
|
+
return fail(
|
|
108
|
+
"ASSET_DIMENSION_INVALID",
|
|
109
|
+
`Raster dimensions ${width}x${height} exceed ${MAX_RASTER_DIMENSION_PX}px limit`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const parsePngDimensions = (
|
|
116
|
+
bytes: Uint8Array
|
|
117
|
+
): { width: number; height: number } | null => {
|
|
118
|
+
if (bytes.length < 24) return null;
|
|
119
|
+
if (asciiSlice(bytes, 12, 16) !== "IHDR") return null;
|
|
120
|
+
return {
|
|
121
|
+
width: readU32BE(bytes, 16),
|
|
122
|
+
height: readU32BE(bytes, 20),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const parseGifDimensions = (
|
|
127
|
+
bytes: Uint8Array
|
|
128
|
+
): { width: number; height: number } | null => {
|
|
129
|
+
if (bytes.length < 10) return null;
|
|
130
|
+
return {
|
|
131
|
+
width: readU16LE(bytes, 6),
|
|
132
|
+
height: readU16LE(bytes, 8),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const parseJpegDimensions = (
|
|
137
|
+
bytes: Uint8Array
|
|
138
|
+
): { width: number; height: number } | null => {
|
|
139
|
+
let offset = 2;
|
|
140
|
+
const limit = bytes.length;
|
|
141
|
+
while (offset + 9 < limit) {
|
|
142
|
+
if (bytes[offset] !== 0xff) return null;
|
|
143
|
+
while (offset < limit && bytes[offset] === 0xff) offset += 1;
|
|
144
|
+
if (offset >= limit) return null;
|
|
145
|
+
const marker = bytes[offset] ?? 0;
|
|
146
|
+
offset += 1;
|
|
147
|
+
if (marker === 0xd8 || marker === 0xd9) continue;
|
|
148
|
+
if (offset + 2 > limit) return null;
|
|
149
|
+
const segmentLength = readU16BE(bytes, offset);
|
|
150
|
+
if (segmentLength < 2) return null;
|
|
151
|
+
const sof =
|
|
152
|
+
(marker >= 0xc0 && marker <= 0xc3) ||
|
|
153
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
154
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
155
|
+
(marker >= 0xcd && marker <= 0xcf);
|
|
156
|
+
if (sof) {
|
|
157
|
+
if (offset + 7 >= limit) return null;
|
|
158
|
+
return {
|
|
159
|
+
height: readU16BE(bytes, offset + 3),
|
|
160
|
+
width: readU16BE(bytes, offset + 5),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
offset += segmentLength;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const parseWebpDimensions = (
|
|
169
|
+
bytes: Uint8Array
|
|
170
|
+
): { width: number; height: number } | null => {
|
|
171
|
+
if (bytes.length < 30) return null;
|
|
172
|
+
const chunk = asciiSlice(bytes, 12, 16);
|
|
173
|
+
if (chunk === "VP8X" && bytes.length >= 30) {
|
|
174
|
+
return {
|
|
175
|
+
width: readU24LE(bytes, 24) + 1,
|
|
176
|
+
height: readU24LE(bytes, 27) + 1,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (chunk === "VP8 " && bytes.length >= 30) {
|
|
180
|
+
// Lossy bitstream start code 0x9d012a at payload+3
|
|
181
|
+
if (
|
|
182
|
+
bytes[23] === 0x9d &&
|
|
183
|
+
bytes[24] === 0x01 &&
|
|
184
|
+
bytes[25] === 0x2a &&
|
|
185
|
+
bytes.length >= 30
|
|
186
|
+
) {
|
|
187
|
+
return {
|
|
188
|
+
width: readU16LE(bytes, 26) & 0x3fff,
|
|
189
|
+
height: readU16LE(bytes, 28) & 0x3fff,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (chunk === "VP8L" && bytes.length >= 25) {
|
|
195
|
+
if (bytes[20] !== 0x2f) return null;
|
|
196
|
+
const b0 = bytes[21] ?? 0;
|
|
197
|
+
const b1 = bytes[22] ?? 0;
|
|
198
|
+
const b2 = bytes[23] ?? 0;
|
|
199
|
+
const b3 = bytes[24] ?? 0;
|
|
200
|
+
const bits = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
|
|
201
|
+
return {
|
|
202
|
+
width: (bits & 0x3fff) + 1,
|
|
203
|
+
height: ((bits >> 14) & 0x3fff) + 1,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** ISO BMFF containers that may hold `ispe` (directly or nested). */
|
|
210
|
+
const BMFF_NEST_BOXES = new Set(["meta", "iprp", "ipco", "moov"]);
|
|
211
|
+
/** FullBox containers: 4-byte version+flags after the box header. */
|
|
212
|
+
const BMFF_FULLBOX_CONTAINERS = new Set(["meta"]);
|
|
213
|
+
const BMFF_MAX_DEPTH = 12;
|
|
214
|
+
const BMFF_MAX_BOXES = 512;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Bounded recursive BMFF scan for HEIF/AVIF `ispe` (Image Spatial Extents).
|
|
218
|
+
* Handles nested containers, `meta` FullBox payload, and 64-bit size fields.
|
|
219
|
+
* The input is already bounded by the attachment byte limit, so scan it all;
|
|
220
|
+
* valid files may place arbitrarily large metadata boxes before `meta`.
|
|
221
|
+
*/
|
|
222
|
+
const parseAvifDimensions = (
|
|
223
|
+
bytes: Uint8Array
|
|
224
|
+
): { width: number; height: number } | null => {
|
|
225
|
+
const limit = bytes.length;
|
|
226
|
+
|
|
227
|
+
const scanRange = (
|
|
228
|
+
start: number,
|
|
229
|
+
end: number,
|
|
230
|
+
depth: number,
|
|
231
|
+
boxBudget: { remaining: number }
|
|
232
|
+
): { width: number; height: number } | null => {
|
|
233
|
+
if (depth > BMFF_MAX_DEPTH) return null;
|
|
234
|
+
let offset = start;
|
|
235
|
+
while (offset + 8 <= end && boxBudget.remaining > 0) {
|
|
236
|
+
boxBudget.remaining -= 1;
|
|
237
|
+
let size = readU32BE(bytes, offset);
|
|
238
|
+
const type = asciiSlice(bytes, offset + 4, offset + 8);
|
|
239
|
+
let headerSize = 8;
|
|
240
|
+
if (size === 1) {
|
|
241
|
+
if (offset + 16 > end) return null;
|
|
242
|
+
const high = readU32BE(bytes, offset + 8);
|
|
243
|
+
const low = readU32BE(bytes, offset + 12);
|
|
244
|
+
if (high !== 0) return null;
|
|
245
|
+
size = low;
|
|
246
|
+
headerSize = 16;
|
|
247
|
+
} else if (size === 0) {
|
|
248
|
+
size = end - offset;
|
|
249
|
+
}
|
|
250
|
+
if (size < headerSize) return null;
|
|
251
|
+
if (offset + headerSize > end) return null;
|
|
252
|
+
|
|
253
|
+
// Clamp to the validated input boundary so malformed boxes cannot escape it.
|
|
254
|
+
const boxEnd = Math.min(offset + size, end);
|
|
255
|
+
|
|
256
|
+
if (type === "ispe") {
|
|
257
|
+
// ispe is a FullBox: version(1)+flags(3) then width/height u32be.
|
|
258
|
+
const payload = offset + headerSize;
|
|
259
|
+
if (payload + 12 > boxEnd) return null;
|
|
260
|
+
const width = readU32BE(bytes, payload + 4);
|
|
261
|
+
const height = readU32BE(bytes, payload + 8);
|
|
262
|
+
return { width, height };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (BMFF_NEST_BOXES.has(type)) {
|
|
266
|
+
let payloadStart = offset + headerSize;
|
|
267
|
+
if (BMFF_FULLBOX_CONTAINERS.has(type)) {
|
|
268
|
+
if (payloadStart + 4 > boxEnd) return null;
|
|
269
|
+
payloadStart += 4;
|
|
270
|
+
}
|
|
271
|
+
const nested = scanRange(payloadStart, boxEnd, depth + 1, boxBudget);
|
|
272
|
+
if (nested) return nested;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (offset + size > end) {
|
|
276
|
+
// Truncated declared size — stop rather than walk past the range.
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
offset += size;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return scanRange(0, limit, 0, { remaining: BMFF_MAX_BOXES });
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const parseDimensions = (
|
|
288
|
+
mediaType: SupportedRasterMediaType,
|
|
289
|
+
bytes: Uint8Array
|
|
290
|
+
): { width: number; height: number } | null => {
|
|
291
|
+
switch (mediaType) {
|
|
292
|
+
case "image/png":
|
|
293
|
+
return parsePngDimensions(bytes);
|
|
294
|
+
case "image/jpeg":
|
|
295
|
+
return parseJpegDimensions(bytes);
|
|
296
|
+
case "image/gif":
|
|
297
|
+
return parseGifDimensions(bytes);
|
|
298
|
+
case "image/webp":
|
|
299
|
+
return parseWebpDimensions(bytes);
|
|
300
|
+
case "image/avif":
|
|
301
|
+
return parseAvifDimensions(bytes);
|
|
302
|
+
default:
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Validate the complete PNG chunk stream. This prevents a signature + fabricated
|
|
309
|
+
* IHDR prefix from crossing the boundary as an image. CRCs, image data, the
|
|
310
|
+
* terminal IEND chunk, and no trailing bytes are required.
|
|
311
|
+
*/
|
|
312
|
+
const pngPasses = [
|
|
313
|
+
[0, 0, 8, 8],
|
|
314
|
+
[4, 0, 8, 8],
|
|
315
|
+
[0, 4, 4, 8],
|
|
316
|
+
[2, 0, 4, 4],
|
|
317
|
+
[0, 2, 2, 4],
|
|
318
|
+
[1, 0, 2, 2],
|
|
319
|
+
[0, 1, 1, 2],
|
|
320
|
+
] as const;
|
|
321
|
+
|
|
322
|
+
const validatePngPixels = (
|
|
323
|
+
bytes: Uint8Array,
|
|
324
|
+
idatChunks: readonly Uint8Array[]
|
|
325
|
+
): boolean => {
|
|
326
|
+
const width = readU32BE(bytes, 16);
|
|
327
|
+
const height = readU32BE(bytes, 20);
|
|
328
|
+
const bitDepth = bytes[24] ?? 0;
|
|
329
|
+
const colorType = bytes[25] ?? 0;
|
|
330
|
+
const interlace = bytes[28] ?? 0;
|
|
331
|
+
const channels = new Map([
|
|
332
|
+
[0, 1],
|
|
333
|
+
[2, 3],
|
|
334
|
+
[3, 1],
|
|
335
|
+
[4, 2],
|
|
336
|
+
[6, 4],
|
|
337
|
+
]).get(colorType);
|
|
338
|
+
const validDepths = new Map<number, readonly number[]>([
|
|
339
|
+
[0, [1, 2, 4, 8, 16]],
|
|
340
|
+
[2, [8, 16]],
|
|
341
|
+
[3, [1, 2, 4, 8]],
|
|
342
|
+
[4, [8, 16]],
|
|
343
|
+
[6, [8, 16]],
|
|
344
|
+
]).get(colorType);
|
|
345
|
+
if (
|
|
346
|
+
channels === undefined ||
|
|
347
|
+
!validDepths?.includes(bitDepth) ||
|
|
348
|
+
bytes[26] !== 0 ||
|
|
349
|
+
bytes[27] !== 0 ||
|
|
350
|
+
(interlace !== 0 && interlace !== 1)
|
|
351
|
+
) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
const passes = interlace === 0 ? ([[0, 0, 1, 1]] as const) : pngPasses;
|
|
355
|
+
const rowLengths: number[] = [];
|
|
356
|
+
let expectedLength = 0;
|
|
357
|
+
for (const [startX, startY, stepX, stepY] of passes) {
|
|
358
|
+
const passWidth = width <= startX ? 0 : Math.ceil((width - startX) / stepX);
|
|
359
|
+
const passHeight =
|
|
360
|
+
height <= startY ? 0 : Math.ceil((height - startY) / stepY);
|
|
361
|
+
if (passWidth === 0 || passHeight === 0) continue;
|
|
362
|
+
const rowBytes = Math.ceil((passWidth * channels * bitDepth) / 8);
|
|
363
|
+
expectedLength += (rowBytes + 1) * passHeight;
|
|
364
|
+
if (expectedLength > MAX_PUBLISH_UPLOAD_BYTES) return false;
|
|
365
|
+
for (let row = 0; row < passHeight; row += 1) rowLengths.push(rowBytes);
|
|
366
|
+
}
|
|
367
|
+
const compressedLength = idatChunks.reduce(
|
|
368
|
+
(total, chunk) => total + chunk.length,
|
|
369
|
+
0
|
|
370
|
+
);
|
|
371
|
+
const compressed = new Uint8Array(compressedLength);
|
|
372
|
+
let compressedOffset = 0;
|
|
373
|
+
for (const chunk of idatChunks) {
|
|
374
|
+
compressed.set(chunk, compressedOffset);
|
|
375
|
+
compressedOffset += chunk.length;
|
|
376
|
+
}
|
|
377
|
+
let inflated: Uint8Array;
|
|
378
|
+
try {
|
|
379
|
+
inflated = inflateSync(compressed, {
|
|
380
|
+
maxOutputLength: expectedLength,
|
|
381
|
+
windowBits: 15,
|
|
382
|
+
});
|
|
383
|
+
} catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
if (inflated.length !== expectedLength) return false;
|
|
387
|
+
let rowOffset = 0;
|
|
388
|
+
for (const rowLength of rowLengths) {
|
|
389
|
+
if ((inflated[rowOffset] ?? 5) > 4) return false;
|
|
390
|
+
rowOffset += rowLength + 1;
|
|
391
|
+
}
|
|
392
|
+
return rowOffset === inflated.length;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const isCompletePng = (bytes: Uint8Array): boolean => {
|
|
396
|
+
let offset = 8;
|
|
397
|
+
let chunkIndex = 0;
|
|
398
|
+
let sawIdat = false;
|
|
399
|
+
const idatChunks: Uint8Array[] = [];
|
|
400
|
+
while (offset + 12 <= bytes.length) {
|
|
401
|
+
const dataLength = readU32BE(bytes, offset);
|
|
402
|
+
const typeStart = offset + 4;
|
|
403
|
+
const dataStart = offset + 8;
|
|
404
|
+
const dataEnd = dataStart + dataLength;
|
|
405
|
+
const chunkEnd = dataEnd + 4;
|
|
406
|
+
if (dataEnd < dataStart || chunkEnd > bytes.length) return false;
|
|
407
|
+
const type = asciiSlice(bytes, typeStart, dataStart);
|
|
408
|
+
if (crc32(bytes, typeStart, dataEnd) !== readU32BE(bytes, dataEnd)) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
if (chunkIndex === 0 && (type !== "IHDR" || dataLength !== 13)) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
if (type === "IDAT") {
|
|
415
|
+
sawIdat = true;
|
|
416
|
+
idatChunks.push(bytes.subarray(dataStart, dataEnd));
|
|
417
|
+
}
|
|
418
|
+
if (type === "IEND") {
|
|
419
|
+
return (
|
|
420
|
+
dataLength === 0 &&
|
|
421
|
+
sawIdat &&
|
|
422
|
+
chunkEnd === bytes.length &&
|
|
423
|
+
validatePngPixels(bytes, idatChunks)
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
offset = chunkEnd;
|
|
427
|
+
chunkIndex += 1;
|
|
428
|
+
}
|
|
429
|
+
return false;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** Complete marker walk: a JPEG needs frame metadata, a scan, and terminal EOI. */
|
|
433
|
+
const isCompleteJpeg = (bytes: Uint8Array): boolean => {
|
|
434
|
+
if (bytes.length < 6 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
|
|
435
|
+
let offset = 2;
|
|
436
|
+
let sawFrame = false;
|
|
437
|
+
let sawScan = false;
|
|
438
|
+
let sawEntropyData = false;
|
|
439
|
+
while (offset < bytes.length) {
|
|
440
|
+
if (bytes[offset] !== 0xff) return false;
|
|
441
|
+
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
|
|
442
|
+
if (offset >= bytes.length) return false;
|
|
443
|
+
const marker = bytes[offset] ?? 0;
|
|
444
|
+
offset += 1;
|
|
445
|
+
if (marker === 0xd9) {
|
|
446
|
+
return sawFrame && sawScan && sawEntropyData && offset === bytes.length;
|
|
447
|
+
}
|
|
448
|
+
if (
|
|
449
|
+
marker === 0xd8 ||
|
|
450
|
+
(marker >= 0xd0 && marker <= 0xd7) ||
|
|
451
|
+
marker === 0x01
|
|
452
|
+
) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (offset + 2 > bytes.length) return false;
|
|
456
|
+
const segmentLength = readU16BE(bytes, offset);
|
|
457
|
+
if (segmentLength < 2 || offset + segmentLength > bytes.length)
|
|
458
|
+
return false;
|
|
459
|
+
const isFrame =
|
|
460
|
+
(marker >= 0xc0 && marker <= 0xc3) ||
|
|
461
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
462
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
463
|
+
(marker >= 0xcd && marker <= 0xcf);
|
|
464
|
+
if (isFrame) sawFrame = true;
|
|
465
|
+
if (marker !== 0xda) {
|
|
466
|
+
offset += segmentLength;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
sawScan = true;
|
|
470
|
+
offset += segmentLength;
|
|
471
|
+
while (offset < bytes.length) {
|
|
472
|
+
if (bytes[offset] !== 0xff) {
|
|
473
|
+
sawEntropyData = true;
|
|
474
|
+
offset += 1;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const next = bytes[offset + 1];
|
|
478
|
+
if (next === undefined) return false;
|
|
479
|
+
if (next === 0x00 || (next >= 0xd0 && next <= 0xd7)) {
|
|
480
|
+
offset += 2;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const skipGifSubBlocks = (bytes: Uint8Array, start: number): number | null => {
|
|
490
|
+
let offset = start;
|
|
491
|
+
while (offset < bytes.length) {
|
|
492
|
+
const size = bytes[offset] ?? 0;
|
|
493
|
+
offset += 1;
|
|
494
|
+
if (size === 0) return offset;
|
|
495
|
+
if (offset + size > bytes.length) return null;
|
|
496
|
+
offset += size;
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const validateGifLzwSubBlocks = (
|
|
502
|
+
bytes: Uint8Array,
|
|
503
|
+
start: number,
|
|
504
|
+
minimumCodeSize: number,
|
|
505
|
+
expectedPixels: number
|
|
506
|
+
): number | null => {
|
|
507
|
+
const compressed: number[] = [];
|
|
508
|
+
let offset = start;
|
|
509
|
+
let sawTerminator = false;
|
|
510
|
+
while (offset < bytes.length) {
|
|
511
|
+
const size = bytes[offset] ?? 0;
|
|
512
|
+
offset += 1;
|
|
513
|
+
if (size === 0) {
|
|
514
|
+
sawTerminator = true;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
if (offset + size > bytes.length) return null;
|
|
518
|
+
for (const value of bytes.subarray(offset, offset + size)) {
|
|
519
|
+
compressed.push(value);
|
|
520
|
+
}
|
|
521
|
+
offset += size;
|
|
522
|
+
}
|
|
523
|
+
if (!sawTerminator || compressed.length === 0) return null;
|
|
524
|
+
|
|
525
|
+
const clearCode = 1 << minimumCodeSize;
|
|
526
|
+
const endCode = clearCode + 1;
|
|
527
|
+
let codeSize = minimumCodeSize + 1;
|
|
528
|
+
let nextCode = endCode + 1;
|
|
529
|
+
let bitOffset = 0;
|
|
530
|
+
let previousCode: number | null = null;
|
|
531
|
+
let previousLength = 0;
|
|
532
|
+
let decodedPixels = 0;
|
|
533
|
+
let sawClear = false;
|
|
534
|
+
const dictionaryLengths = new Uint16Array(4096);
|
|
535
|
+
for (let code = 0; code < clearCode; code += 1) {
|
|
536
|
+
dictionaryLengths[code] = 1;
|
|
537
|
+
}
|
|
538
|
+
while (bitOffset + codeSize <= compressed.length * 8) {
|
|
539
|
+
let code = 0;
|
|
540
|
+
for (let bit = 0; bit < codeSize; bit += 1) {
|
|
541
|
+
const position = bitOffset + bit;
|
|
542
|
+
const value = compressed[position >> 3] ?? 0;
|
|
543
|
+
code |= ((value >> (position & 7)) & 1) << bit;
|
|
544
|
+
}
|
|
545
|
+
bitOffset += codeSize;
|
|
546
|
+
if (code === clearCode) {
|
|
547
|
+
sawClear = true;
|
|
548
|
+
codeSize = minimumCodeSize + 1;
|
|
549
|
+
nextCode = endCode + 1;
|
|
550
|
+
previousCode = null;
|
|
551
|
+
previousLength = 0;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (!sawClear) return null;
|
|
555
|
+
if (code === endCode) {
|
|
556
|
+
return decodedPixels === expectedPixels ? offset : null;
|
|
557
|
+
}
|
|
558
|
+
if (previousCode === null) {
|
|
559
|
+
if (code >= clearCode) return null;
|
|
560
|
+
previousCode = code;
|
|
561
|
+
previousLength = 1;
|
|
562
|
+
decodedPixels += 1;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (code > nextCode) return null;
|
|
566
|
+
const decodedLength =
|
|
567
|
+
code === nextCode ? previousLength + 1 : (dictionaryLengths[code] ?? 0);
|
|
568
|
+
if (decodedLength === 0 || decodedPixels + decodedLength > expectedPixels) {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
decodedPixels += decodedLength;
|
|
572
|
+
if (nextCode < 4096) {
|
|
573
|
+
dictionaryLengths[nextCode] = previousLength + 1;
|
|
574
|
+
nextCode += 1;
|
|
575
|
+
if (nextCode === 1 << codeSize && codeSize < 12) codeSize += 1;
|
|
576
|
+
}
|
|
577
|
+
previousCode = code;
|
|
578
|
+
previousLength = decodedLength;
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/** Complete GIF block walk with at least one image and a terminal trailer. */
|
|
584
|
+
const isCompleteGif = (bytes: Uint8Array): boolean => {
|
|
585
|
+
if (bytes.length < 14) return false;
|
|
586
|
+
let offset = 13;
|
|
587
|
+
const globalTable = (bytes[10] ?? 0) & 0x80;
|
|
588
|
+
if (globalTable) offset += 3 * 2 ** (((bytes[10] ?? 0) & 0x07) + 1);
|
|
589
|
+
if (offset > bytes.length) return false;
|
|
590
|
+
let sawImage = false;
|
|
591
|
+
while (offset < bytes.length) {
|
|
592
|
+
const introducer = bytes[offset] ?? 0;
|
|
593
|
+
offset += 1;
|
|
594
|
+
if (introducer === 0x3b) return sawImage && offset === bytes.length;
|
|
595
|
+
if (introducer === 0x21) {
|
|
596
|
+
if (offset >= bytes.length) return false;
|
|
597
|
+
offset += 1;
|
|
598
|
+
const next = skipGifSubBlocks(bytes, offset);
|
|
599
|
+
if (next === null) return false;
|
|
600
|
+
offset = next;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (introducer !== 0x2c || offset + 9 > bytes.length) return false;
|
|
604
|
+
const frameWidth = readU16LE(bytes, offset + 4);
|
|
605
|
+
const frameHeight = readU16LE(bytes, offset + 6);
|
|
606
|
+
if (frameWidth === 0 || frameHeight === 0) return false;
|
|
607
|
+
const packed = bytes[offset + 8] ?? 0;
|
|
608
|
+
offset += 9;
|
|
609
|
+
if (packed & 0x80) offset += 3 * 2 ** ((packed & 0x07) + 1);
|
|
610
|
+
if (offset >= bytes.length) return false;
|
|
611
|
+
const minimumCodeSize = bytes[offset] ?? 0;
|
|
612
|
+
if (minimumCodeSize < 2 || minimumCodeSize > 8) return false;
|
|
613
|
+
offset += 1;
|
|
614
|
+
if ((bytes[offset] ?? 0) === 0) return false;
|
|
615
|
+
const next = validateGifLzwSubBlocks(
|
|
616
|
+
bytes,
|
|
617
|
+
offset,
|
|
618
|
+
minimumCodeSize,
|
|
619
|
+
frameWidth * frameHeight
|
|
620
|
+
);
|
|
621
|
+
if (next === null) return false;
|
|
622
|
+
offset = next;
|
|
623
|
+
sawImage = true;
|
|
624
|
+
}
|
|
625
|
+
return false;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const isWebpImageChunk = (
|
|
629
|
+
bytes: Uint8Array,
|
|
630
|
+
type: string,
|
|
631
|
+
dataStart: number,
|
|
632
|
+
size: number
|
|
633
|
+
): boolean => {
|
|
634
|
+
if (type === "VP8 ") {
|
|
635
|
+
if (
|
|
636
|
+
size <= 10 ||
|
|
637
|
+
bytes[dataStart + 3] !== 0x9d ||
|
|
638
|
+
bytes[dataStart + 4] !== 0x01 ||
|
|
639
|
+
bytes[dataStart + 5] !== 0x2a
|
|
640
|
+
) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
const frameTag =
|
|
644
|
+
(bytes[dataStart] ?? 0) |
|
|
645
|
+
((bytes[dataStart + 1] ?? 0) << 8) |
|
|
646
|
+
((bytes[dataStart + 2] ?? 0) << 16);
|
|
647
|
+
const firstPartitionLength = frameTag >>> 5;
|
|
648
|
+
return firstPartitionLength > 0 && 10 + firstPartitionLength < size;
|
|
649
|
+
}
|
|
650
|
+
// VP8L's five-byte signature/dimension header must be followed by image data.
|
|
651
|
+
return type === "VP8L" && size > 5 && bytes[dataStart] === 0x2f;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const animationFrameHasImageData = (
|
|
655
|
+
bytes: Uint8Array,
|
|
656
|
+
dataStart: number,
|
|
657
|
+
size: number
|
|
658
|
+
): boolean => {
|
|
659
|
+
if (size < 16) return false;
|
|
660
|
+
const frameEnd = dataStart + size;
|
|
661
|
+
let offset = dataStart + 16;
|
|
662
|
+
let sawImageData = false;
|
|
663
|
+
while (offset + 8 <= frameEnd) {
|
|
664
|
+
const type = asciiSlice(bytes, offset, offset + 4);
|
|
665
|
+
const chunkSize = readU32LE(bytes, offset + 4);
|
|
666
|
+
const chunkDataStart = offset + 8;
|
|
667
|
+
const paddedEnd = chunkDataStart + chunkSize + (chunkSize % 2);
|
|
668
|
+
if (paddedEnd < chunkDataStart || paddedEnd > frameEnd) return false;
|
|
669
|
+
if (isWebpImageChunk(bytes, type, chunkDataStart, chunkSize)) {
|
|
670
|
+
sawImageData = true;
|
|
671
|
+
}
|
|
672
|
+
offset = paddedEnd;
|
|
673
|
+
}
|
|
674
|
+
return sawImageData && offset === frameEnd;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
/** RIFF size/chunk walk; metadata and empty animation frames are not image data. */
|
|
678
|
+
const isCompleteWebp = (bytes: Uint8Array): boolean => {
|
|
679
|
+
if (bytes.length < 20 || readU32LE(bytes, 4) + 8 !== bytes.length) {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
let offset = 12;
|
|
683
|
+
let sawImageData = false;
|
|
684
|
+
while (offset + 8 <= bytes.length) {
|
|
685
|
+
const type = asciiSlice(bytes, offset, offset + 4);
|
|
686
|
+
const size = readU32LE(bytes, offset + 4);
|
|
687
|
+
const dataStart = offset + 8;
|
|
688
|
+
const paddedEnd = dataStart + size + (size % 2);
|
|
689
|
+
if (paddedEnd < dataStart || paddedEnd > bytes.length) return false;
|
|
690
|
+
const isAnimationFrame =
|
|
691
|
+
type === "ANMF" && animationFrameHasImageData(bytes, dataStart, size);
|
|
692
|
+
if (isWebpImageChunk(bytes, type, dataStart, size) || isAnimationFrame) {
|
|
693
|
+
sawImageData = true;
|
|
694
|
+
}
|
|
695
|
+
offset = paddedEnd;
|
|
696
|
+
}
|
|
697
|
+
return sawImageData && offset === bytes.length;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/** Exact top-level BMFF walk; AVIF needs metadata and a non-empty media payload. */
|
|
701
|
+
const isCompleteAvif = (bytes: Uint8Array): boolean => {
|
|
702
|
+
let offset = 0;
|
|
703
|
+
let sawFtyp = false;
|
|
704
|
+
let sawMeta = false;
|
|
705
|
+
let sawMediaData = false;
|
|
706
|
+
while (offset + 8 <= bytes.length) {
|
|
707
|
+
let size = readU32BE(bytes, offset);
|
|
708
|
+
const type = asciiSlice(bytes, offset + 4, offset + 8);
|
|
709
|
+
let headerSize = 8;
|
|
710
|
+
if (size === 1) {
|
|
711
|
+
if (offset + 16 > bytes.length || readU32BE(bytes, offset + 8) !== 0) {
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
size = readU32BE(bytes, offset + 12);
|
|
715
|
+
headerSize = 16;
|
|
716
|
+
} else if (size === 0) {
|
|
717
|
+
size = bytes.length - offset;
|
|
718
|
+
}
|
|
719
|
+
if (size < headerSize || offset + size > bytes.length) return false;
|
|
720
|
+
if (type === "ftyp" && size >= headerSize + 8) sawFtyp = true;
|
|
721
|
+
if (type === "meta") sawMeta = true;
|
|
722
|
+
if (type === "mdat" && size > headerSize) sawMediaData = true;
|
|
723
|
+
offset += size;
|
|
724
|
+
}
|
|
725
|
+
return sawFtyp && sawMeta && sawMediaData && offset === bytes.length;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const isCompleteRaster = (
|
|
729
|
+
mediaType: SupportedRasterMediaType,
|
|
730
|
+
bytes: Uint8Array
|
|
731
|
+
): boolean => {
|
|
732
|
+
switch (mediaType) {
|
|
733
|
+
case "image/png":
|
|
734
|
+
return isCompletePng(bytes);
|
|
735
|
+
case "image/jpeg":
|
|
736
|
+
return isCompleteJpeg(bytes);
|
|
737
|
+
case "image/gif":
|
|
738
|
+
return isCompleteGif(bytes);
|
|
739
|
+
case "image/webp":
|
|
740
|
+
return isCompleteWebp(bytes);
|
|
741
|
+
case "image/avif":
|
|
742
|
+
return isCompleteAvif(bytes);
|
|
743
|
+
default:
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Synchronous structural raster validation: signature sniff, bounded
|
|
750
|
+
* dimensions, and container completeness. Does **not** prove AV1
|
|
751
|
+
* decodability for AVIF — producers must also await
|
|
752
|
+
* `validateRasterDecodable` before bundling.
|
|
753
|
+
*/
|
|
754
|
+
export const validateRasterBytesStructural = (
|
|
755
|
+
bytes: Uint8Array
|
|
756
|
+
): RasterValidationResult => {
|
|
757
|
+
if (bytes.byteLength === 0) {
|
|
758
|
+
return fail("ASSET_CORRUPT", "Image payload is empty");
|
|
759
|
+
}
|
|
760
|
+
if (bytes.byteLength > MAX_PUBLISH_UPLOAD_BYTES) {
|
|
761
|
+
return fail(
|
|
762
|
+
"ASSET_OVERSIZE",
|
|
763
|
+
`Image payload is ${bytes.byteLength} bytes; max is ${MAX_PUBLISH_UPLOAD_BYTES}`
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const mediaType = sniffRasterMediaType(bytes);
|
|
768
|
+
if (!mediaType) {
|
|
769
|
+
if (
|
|
770
|
+
bytes.length >= 5 &&
|
|
771
|
+
(asciiSlice(bytes, 0, 5) === "<?xml" ||
|
|
772
|
+
asciiSlice(bytes, 0, 4).toLowerCase() === "<svg")
|
|
773
|
+
) {
|
|
774
|
+
return fail(
|
|
775
|
+
"ASSET_UNSUPPORTED_FORMAT",
|
|
776
|
+
"SVG is unsupported for bundled publish assets"
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
return fail(
|
|
780
|
+
"ASSET_UNSUPPORTED_FORMAT",
|
|
781
|
+
"Payload does not match a supported raster signature"
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const dims = parseDimensions(mediaType, bytes);
|
|
786
|
+
if (!dims) {
|
|
787
|
+
return fail(
|
|
788
|
+
"ASSET_CORRUPT",
|
|
789
|
+
`Unable to parse ${mediaType} dimensions from bounded header`
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
const dimError = validateDimensions(dims.width, dims.height);
|
|
793
|
+
if (dimError) return dimError;
|
|
794
|
+
if (!isCompleteRaster(mediaType, bytes)) {
|
|
795
|
+
return fail(
|
|
796
|
+
"ASSET_CORRUPT",
|
|
797
|
+
`${mediaType} payload is not a complete, structurally renderable image`
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
ok: true,
|
|
803
|
+
height: dims.height,
|
|
804
|
+
mediaType,
|
|
805
|
+
width: dims.width,
|
|
806
|
+
};
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const MAX_RASTER_DECODE_INPUT_PIXELS =
|
|
810
|
+
MAX_RASTER_DIMENSION_PX * MAX_RASTER_DIMENSION_PX;
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Prove payloads contain a decodable image via a bounded 1x1 decode.
|
|
814
|
+
* Bun has no native image decoder; sharp provides the cross-platform path.
|
|
815
|
+
* Call only after structural dimension/bomb checks. Avoids retaining decoded
|
|
816
|
+
* pixel buffers beyond the 1x1 probe.
|
|
817
|
+
*/
|
|
818
|
+
const assertRasterDecodable = async (
|
|
819
|
+
bytes: Uint8Array,
|
|
820
|
+
structural: RasterValidationOk
|
|
821
|
+
): Promise<RasterValidationResult> => {
|
|
822
|
+
try {
|
|
823
|
+
// sharp — Bun has no native AV1/image decoder; pin exact version in package.json.
|
|
824
|
+
const sharp = (await import("sharp")).default;
|
|
825
|
+
await sharp(bytes, {
|
|
826
|
+
failOn: "error",
|
|
827
|
+
limitInputPixels: MAX_RASTER_DECODE_INPUT_PIXELS,
|
|
828
|
+
})
|
|
829
|
+
.resize(1, 1, { fit: "fill" })
|
|
830
|
+
.raw()
|
|
831
|
+
.toBuffer();
|
|
832
|
+
return structural;
|
|
833
|
+
} catch {
|
|
834
|
+
return fail(
|
|
835
|
+
"ASSET_CORRUPT",
|
|
836
|
+
`${structural.mediaType} payload is not image-decodable`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Producer/file-ingress validation: structural checks, then full image
|
|
843
|
+
* decodability for every supported raster type. Closed artifact parsers use
|
|
844
|
+
* `validateRasterBytesStructural` only (sync).
|
|
845
|
+
*/
|
|
846
|
+
export const validateRasterDecodable = async (
|
|
847
|
+
bytes: Uint8Array
|
|
848
|
+
): Promise<RasterValidationResult> => {
|
|
849
|
+
const structural = validateRasterBytesStructural(bytes);
|
|
850
|
+
if (!structural.ok) return structural;
|
|
851
|
+
return assertRasterDecodable(bytes, structural);
|
|
852
|
+
};
|