Search / static /reader-security.js
vomebook
fix: load current OCR manifest layout
68badc3
Raw History Blame Contribute Delete
15.6 kB
(function (root) {
"use strict";
const LIMITS = Object.freeze({
documentBytes: 512 * 1024 * 1024,
archiveCompressedBytes: 512 * 1024 * 1024,
archiveExpandedBytes: 1536 * 1024 * 1024,
archiveEntries: 20000,
archiveRatio: 200,
manifestBytes: 8 * 1024 * 1024,
pdfPages: 100000,
chapters: 10000,
chapterBytes: 8 * 1024 * 1024,
chapterTotalBytes: 512 * 1024 * 1024
});
function error(code) {
const value = new Error(code);
value.code = code;
return value;
}
function assertResponseSize(response, limit) {
const length = Number(response?.headers?.get?.("content-length"));
if (Number.isFinite(length) && length > limit) throw error("READER_RESOURCE_LIMIT");
return response;
}
function createByteBudget(limit = LIMITS.chapterTotalBytes) {
if (!Number.isSafeInteger(limit) || limit < 0) throw new RangeError("Invalid byte budget");
let used = 0;
function reserve(bytes) {
if (!Number.isSafeInteger(bytes) || bytes < 0) throw new RangeError("Invalid byte count");
if (bytes > limit - used) throw error("READER_RESOURCE_LIMIT");
used += bytes;
}
function release(bytes) {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > used)
throw new RangeError("Invalid byte release");
used -= bytes;
}
return Object.freeze({
reserve,
release,
get used() {
return used;
},
get remaining() {
return limit - used;
}
});
}
// Failed reads release their reservations; successful reads retain them until
// the caller releases discarded content. Share one budget across a book.
async function readBytes(response, limit = LIMITS.documentBytes, budget) {
let reader,
reserved = 0;
try {
assertResponseSize(response, limit);
if (!response.body?.getReader) {
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > limit) throw error("READER_RESOURCE_LIMIT");
budget?.reserve(bytes.byteLength);
return bytes;
}
reader = response.body.getReader();
const chunks = [];
let length = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value?.byteLength) continue;
length += value.byteLength;
if (length > limit) throw error("READER_RESOURCE_LIMIT");
budget?.reserve(value.byteLength);
reserved += value.byteLength;
chunks.push(value);
}
const output = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
} catch (reason) {
if (budget && reserved) budget.release(reserved);
try {
if (reader) await reader.cancel(reason);
else await response.body?.cancel?.(reason);
} catch (_) {}
throw reason;
} finally {
reader?.releaseLock?.();
}
}
async function readText(response, limit, encoding = "utf-8", budget) {
const decoder = new TextDecoder(encoding);
const bytes = await readBytes(response, limit, budget);
try {
return decoder.decode(bytes);
} catch (reason) {
budget?.release(bytes.byteLength);
throw reason;
}
}
function inspectZip(bytes, limits = LIMITS) {
bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
if (bytes.byteLength > limits.archiveCompressedBytes) throw error("READER_ARCHIVE_LIMIT");
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let end = -1;
// EOCD must end at EOF, including its optional comment. File contents are
// never searched for central-directory entries.
for (
let offset = bytes.byteLength - 22;
offset >= Math.max(0, bytes.byteLength - 22 - 65535);
offset--
) {
if (
view.getUint32(offset, true) === 0x06054b50 &&
offset + 22 + view.getUint16(offset + 20, true) === bytes.byteLength
) {
end = offset;
break;
}
}
if (end < 0) throw error("READER_ARCHIVE_INVALID");
const disk = view.getUint16(end + 4, true),
directoryDisk = view.getUint16(end + 6, true);
const diskEntries = view.getUint16(end + 8, true),
entries = view.getUint16(end + 10, true);
const directorySize = view.getUint32(end + 12, true),
directoryOffset = view.getUint32(end + 16, true);
if (
disk === 0xffff ||
directoryDisk === 0xffff ||
diskEntries === 0xffff ||
entries === 0xffff ||
directorySize === 0xffffffff ||
directoryOffset === 0xffffffff ||
(end >= 20 && view.getUint32(end - 20, true) === 0x07064b50)
) {
throw error("READER_ARCHIVE_ZIP64_UNSUPPORTED");
}
if (disk || directoryDisk || diskEntries !== entries)
throw error("READER_ARCHIVE_MULTIDISK_UNSUPPORTED");
if (!entries || directoryOffset + directorySize !== end) throw error("READER_ARCHIVE_INVALID");
if (entries > limits.archiveEntries) throw error("READER_ARCHIVE_LIMIT");
let offset = directoryOffset,
compressed = 0,
expanded = 0;
for (let index = 0; index < entries; index++) {
if (offset + 46 > end || view.getUint32(offset, true) !== 0x02014b50)
throw error("READER_ARCHIVE_INVALID");
const packed = view.getUint32(offset + 20, true),
unpacked = view.getUint32(offset + 24, true);
const nameLength = view.getUint16(offset + 28, true),
extraLength = view.getUint16(offset + 30, true),
commentLength = view.getUint16(offset + 32, true);
const startDisk = view.getUint16(offset + 34, true),
localOffset = view.getUint32(offset + 42, true);
if (
packed === 0xffffffff ||
unpacked === 0xffffffff ||
localOffset === 0xffffffff ||
startDisk === 0xffff
) {
throw error("READER_ARCHIVE_ZIP64_UNSUPPORTED");
}
if (startDisk) throw error("READER_ARCHIVE_MULTIDISK_UNSUPPORTED");
const next = offset + 46 + nameLength + extraLength + commentLength;
if (!nameLength || next > end) throw error("READER_ARCHIVE_INVALID");
const extraEnd = offset + 46 + nameLength + extraLength;
for (let extra = offset + 46 + nameLength; extra < extraEnd; ) {
if (extra + 4 > extraEnd) throw error("READER_ARCHIVE_INVALID");
if (view.getUint16(extra, true) === 1) throw error("READER_ARCHIVE_ZIP64_UNSUPPORTED");
extra += 4 + view.getUint16(extra + 2, true);
if (extra > extraEnd) throw error("READER_ARCHIVE_INVALID");
}
if (localOffset + 30 > directoryOffset || view.getUint32(localOffset, true) !== 0x04034b50)
throw error("READER_ARCHIVE_INVALID");
const dataOffset =
localOffset +
30 +
view.getUint16(localOffset + 26, true) +
view.getUint16(localOffset + 28, true);
if (dataOffset + packed > directoryOffset) throw error("READER_ARCHIVE_INVALID");
compressed += packed;
expanded += unpacked;
if (
expanded > limits.archiveExpandedBytes ||
(packed === 0 ? unpacked > 0 : unpacked / packed > limits.archiveRatio)
) {
throw error("READER_ARCHIVE_LIMIT");
}
offset = next;
}
if (offset !== end) throw error("READER_ARCHIVE_INVALID");
return Object.freeze({ entries, compressed, expanded });
}
function validateChapterManifest(manifest) {
if (
manifest?.version !== 1 ||
manifest?.kind !== "epub-chapters" ||
!Array.isArray(manifest.chapters) ||
!manifest.chapters.length
)
throw error("EPUB_INVALID");
if (manifest.chapters.length > LIMITS.chapters) throw error("READER_RESOURCE_LIMIT");
let declaredBytes = 0,
expectedIndex = 1;
for (const chapter of manifest.chapters) {
if (!chapter || !Number.isInteger(chapter.index) || chapter.index !== expectedIndex)
throw error("EPUB_INVALID");
expectedIndex += 1;
if (
typeof chapter.path !== "string" ||
!chapter.path.trim() ||
/^[a-z][a-z0-9+.-]*:/i.test(chapter.path) ||
chapter.path.startsWith("/") ||
chapter.path.includes("\\") ||
chapter.path.split("/").some((part) => part === ".." || part === "." || !part)
)
throw error("EPUB_INVALID");
const rawSize = chapter.bytes ?? chapter.size;
const size = rawSize == null ? 0 : Number(rawSize);
if (!Number.isFinite(size) || size < 0 || size > LIMITS.chapterBytes)
throw error("READER_RESOURCE_LIMIT");
declaredBytes += size;
}
if (declaredBytes > LIMITS.chapterTotalBytes) throw error("READER_RESOURCE_LIMIT");
if (manifest.toc !== undefined) {
if (!Array.isArray(manifest.toc)) throw error("EPUB_INVALID");
for (const item of manifest.toc)
if (!item || typeof item.title !== "string" || !item.title.trim() ||
!Number.isInteger(item.chapter) || item.chapter < 1 || item.chapter > manifest.chapters.length ||
!Number.isInteger(item.depth) || item.depth < 0 || item.depth > 64 ||
typeof item.fragment !== "string") throw error("EPUB_INVALID");
}
return manifest;
}
function validatePdfPageManifest(manifest) {
if (!manifest || manifest.version !== 2 || manifest.kind !== "pdf-pages")
throw error("PDF_MANIFEST_INVALID");
if (!Number.isInteger(manifest.page_count) || manifest.page_count < 1 || manifest.pages !== undefined)
throw error("PDF_MANIFEST_INVALID");
const pageCount = manifest.page_count;
if (pageCount > LIMITS.pdfPages) throw error("READER_RESOURCE_LIMIT");
if (manifest.toc !== undefined) {
if (!Array.isArray(manifest.toc) || manifest.toc.length > 2000)
throw error("PDF_MANIFEST_INVALID");
for (const item of manifest.toc) {
if (
!item ||
typeof item.title !== "string" ||
!item.title.trim() ||
item.title.length > 500 ||
!Number.isInteger(item.page) ||
item.page < 1 ||
item.page > pageCount ||
!Number.isInteger(item.depth) ||
item.depth < 0 ||
item.depth > 32
)
throw error("PDF_MANIFEST_INVALID");
}
}
if (manifest.ocr !== undefined) {
if (!Array.isArray(manifest.ocr) || manifest.ocr.length !== pageCount)
throw error("PDF_MANIFEST_INVALID");
for (const [index, page] of manifest.ocr.entries()) {
const expected = String(index + 1).padStart(6, "0"),
root = typeof page?.o === "string" ? PDF_OCR_ROOT.exec(page.o)?.[1] || "" : "";
if (!page || page.p !== index + 1 || !PDF_OCR_PATH.test(page.o) ||
page.o !== `${root}/ocr/page-${expected}.json.gz` ||
pdfSourceRoot(root) !== `objects/${manifest.source_sha256.slice(0, 2)}/${manifest.source_sha256}` ||
!/^[0-9a-f]{64}$/.test(page.os || "") ||
!Number.isSafeInteger(page.ob) || page.ob < 1)
throw error("PDF_MANIFEST_INVALID");
}
}
return manifest;
}
const PDF_OCR_PATH = /^objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[0-9a-f]{16}\/)?ocr\/(?:page-[0-9]{6}\.json\.gz|book-text\.json\.gz)$/;
const PDF_OCR_MANIFEST_PATH = /^objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[0-9a-f]{16}\/)?ocr-manifest\.json$/;
const PDF_OCR_PAGE_PATH = /^objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[0-9a-f]{16}\/)?pages\/page-[0-9]{6}\.(?:webp|jxl)$/;
const PDF_OCR_ROOT = /^(objects\/[0-9a-f]{2}\/[0-9a-f]{64}(?:\/[0-9a-f]{16})?)/;
function pdfSourceRoot(path) {
return typeof path === "string" ? path.split("/").slice(0, 3).join("/") : "";
}
function validatePdfOcrManifest(manifest) {
if (!manifest || manifest.version !== 1 || manifest.kind !== "pdf-ocr" ||
manifest.complete !== true || !/^[0-9a-f]{64}$/.test(manifest.source_sha256 || "") ||
!/layout-v\d+(?:-|$)/.test(String(manifest.profile || "")) ||
!Number.isInteger(manifest.page_count) || manifest.page_count < 1 ||
manifest.page_count > LIMITS.pdfPages || !Array.isArray(manifest.pages) ||
manifest.pages.length !== manifest.page_count)
throw error("PDF_OCR_MANIFEST_INVALID");
let root = "";
for (const [index, page] of manifest.pages.entries()) {
const expected = String(index + 1).padStart(6, "0");
const pageRoot = typeof page?.o === "string" ? PDF_OCR_ROOT.exec(page.o)?.[1] || "" : "";
if (!page || page.p !== index + 1 || !PDF_OCR_PATH.test(page.o) ||
page.o !== `${pageRoot}/ocr/page-${expected}.json.gz` ||
(page.w !== undefined && (typeof page.w !== "string" || !PDF_OCR_PAGE_PATH.test(page.w) ||
!page.w.endsWith(`/pages/page-${expected}.webp`) ||
pdfSourceRoot(page.w) !== pdfSourceRoot(pageRoot))) ||
(page.j !== undefined && (typeof page.j !== "string" || !PDF_OCR_PAGE_PATH.test(page.j) ||
!page.j.endsWith(`/pages/page-${expected}.jxl`) ||
pdfSourceRoot(page.j) !== pdfSourceRoot(pageRoot))) ||
pdfSourceRoot(pageRoot) !== `objects/${manifest.source_sha256.slice(0, 2)}/${manifest.source_sha256}` ||
(root && pdfSourceRoot(root) !== pdfSourceRoot(pageRoot)))
throw error("PDF_OCR_MANIFEST_INVALID");
root = pageRoot;
}
if (!manifest.book_text || typeof manifest.book_text.path !== "string" ||
!PDF_OCR_PATH.test(manifest.book_text.path) || !manifest.book_text.path.endsWith('/ocr/book-text.json.gz') ||
pdfSourceRoot(manifest.book_text.path) !== pdfSourceRoot(root) ||
!/^[0-9a-f]{64}$/.test(manifest.book_text.sha256 || "") ||
!Number.isSafeInteger(manifest.book_text.bytes) || manifest.book_text.bytes < 1 ||
(manifest.page_manifest !== undefined &&
(!manifest.page_manifest || typeof manifest.page_manifest.path !== "string" ||
!/^objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[0-9a-f]{16}\/)?page-manifest\.json$/.test(manifest.page_manifest.path) ||
pdfSourceRoot(manifest.page_manifest.path) !== pdfSourceRoot(root))))
throw error("PDF_OCR_MANIFEST_INVALID");
return manifest;
}
function validatePdfOcrPage(payload, pageNumber) {
if (!payload || payload.version !== 1 || payload.kind !== "pdf-ocr-page" ||
payload.page !== pageNumber || !Array.isArray(payload.blocks) || payload.blocks.length > 10000 ||
!Number.isFinite(Number(payload.width)) || !Number.isFinite(Number(payload.height)))
throw error("PDF_OCR_PAGE_INVALID");
for (const block of payload.blocks) {
const box = block?.b;
if (!block || typeof block.t !== "string" || block.t.length > 4096 ||
!Array.isArray(box) || box.length !== 4 || box.some((value) => !Number.isFinite(Number(value))) ||
box[0] < 0 || box[1] < 0 || box[2] > 1 || box[3] > 1 || box[2] < box[0] || box[3] < box[1])
throw error("PDF_OCR_PAGE_INVALID");
}
return payload;
}
function isZipContainer(extension, bytes) {
return (
["epub", "fbz"].includes(String(extension || "").toLowerCase()) ||
!!(bytes && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04)
);
}
root.VoiceOfMLReaderSecurity = Object.freeze({
LIMITS,
assertResponseSize,
createByteBudget,
readBytes,
readText,
inspectZip,
validateChapterManifest,
validatePdfPageManifest,
validatePdfOcrManifest,
validatePdfOcrPage,
isZipContainer
});
})(typeof self !== "undefined" ? self : globalThis);