// Proves that each important check in check-epub.js rejects a deliberately // broken archive. A non-zero exit alone is not enough: every case must print // the particular FAIL line it was designed to exercise. // // node check-epub-negative.js [out/fixture.epub] const childProcess = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); const vm = require('vm'); const fixture = process.argv[2] || path.join(__dirname, 'out', 'fixture.epub'); const checker = path.join(__dirname, 'check-epub.js'); const builder = path.join(__dirname, 'build-epub.js'); const mimetype = 'application/epub+zip'; const sandbox = {console, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, Promise, Blob, Uint8Array, ArrayBuffer, Date, Math, JSON}; vm.createContext(sandbox); vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'web-extension', 'libs', 'jszip.js'), 'utf8'), sandbox, {filename: 'jszip.js'}); const JSZip = sandbox.JSZip; let failures = 0; function checkerResult(file) { return childProcess.spawnSync(process.execPath, [checker, file], { encoding: 'utf8', maxBuffer: 1024 * 1024 }); } function pass(name) { console.log('PASS ' + name); } function fail(name, detail) { failures++; console.log('FAIL ' + name + ' -- ' + detail); } function replace(text, before, after) { if (text.indexOf(before) < 0) { throw new Error('fixture no longer contains ' + JSON.stringify(before)); } return text.replace(before, after); } async function rewrite(zip, name, change) { const original = await zip.file(name).async('string'); zip.file(name, change(original)); } // The builder names the chapter files, deriving each one from the chapter's // position in the book rather than from anything the fixture states, so the // mutations below ask the package which file a chapter landed in instead of // spelling the name out and going stale the next time the rule changes. async function chapterHref(zip, index) { const opf = await zip.file('OEBPS/content.opf').async('string'); const match = opf.match(new RegExp(']*href="([^"]+)"')); if (!match) { throw new Error('the fixture has no chapter ' + index); } return match[1].replace(/^pages\//, ''); } async function rewriteChapter(zip, index, change) { return rewrite(zip, 'OEBPS/pages/' + await chapterHref(zip, index), change); } async function mutatedArchive(raw, mutate) { const zip = await JSZip.loadAsync(raw); await mutate(zip); // Loading and regenerating a zip loses the per-entry compression setting. // Set this explicitly so mutations unrelated to OCF stay isolated. zip.file('mimetype', mimetype, {compression: 'STORE'}); return zip.generateAsync({type: 'uint8array', compression: 'DEFLATE'}); } async function compressedMimetype(raw) { const zip = await JSZip.loadAsync(raw); zip.file('mimetype', mimetype, {compression: 'DEFLATE'}); return zip.generateAsync({type: 'uint8array', compression: 'DEFLATE'}); } async function misplacedMimetype(raw) { const source = await JSZip.loadAsync(raw); const zip = new JSZip(); const names = Object.keys(source.files).filter((name) => name !== 'mimetype'); for (const name of names) { const entry = source.files[name]; if (!entry.dir) { zip.file(name, await entry.async('uint8array'), {binary: true}); } } zip.file('mimetype', mimetype, {compression: 'STORE'}); return zip.generateAsync({type: 'uint8array', compression: 'DEFLATE'}); } async function runCase(tempDir, raw, test) { const file = path.join(tempDir, test.slug + '.epub'); let archive; try { archive = test.archive ? await test.archive(raw) : await mutatedArchive(raw, test.mutate); } catch (error) { fail(test.name, 'could not build corrupt fixture: ' + error.message); return; } fs.writeFileSync(file, archive); const result = checkerResult(file); const output = (result.stdout || '') + (result.stderr || ''); const expected = 'FAIL ' + test.expected; if (result.status === 1 && output.indexOf(expected) > -1) { pass(test.name); } else { fail(test.name, 'wanted exit 1 and ' + JSON.stringify(expected) + ', got exit ' + result.status + '\n' + output.trim()); } } (async () => { if (!fs.existsSync(fixture)) { const built = childProcess.spawnSync(process.execPath, [builder, fixture], { encoding: 'utf8', maxBuffer: 1024 * 1024 }); if (built.status !== 0) { console.error((built.stdout || '') + (built.stderr || '')); process.exit(1); } } const valid = checkerResult(fixture); if (valid.status !== 0) { console.error('The negative-test baseline is not valid:\n' + (valid.stdout || '') + (valid.stderr || '')); process.exit(1); } pass('uncorrupted baseline passes before negative checks'); const raw = fs.readFileSync(fixture); // the names the three fixture chapters were built under, for the mutations // that reach into one chapter to break a link to another const fixtureZip = await JSZip.loadAsync(raw); const first = await chapterHref(fixtureZip, 0); const second = await chapterHref(fixtureZip, 1); const third = await chapterHref(fixtureZip, 2); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'save-as-ebook-check-')); const tests = [ { name: 'compressed mimetype is rejected', slug: 'compressed-mimetype', expected: 'mimetype is the first entry and uncompressed', archive: compressedMimetype }, { name: 'misplaced mimetype is rejected', slug: 'misplaced-mimetype', expected: 'mimetype is the first entry and uncompressed', archive: misplacedMimetype }, { name: 'a manifest entry whose file is missing is rejected', slug: 'missing-manifest-file', expected: 'every manifest href exists in the archive', mutate: (zip) => zip.remove('OEBPS/ebook.css') }, { name: 'duplicate manifest IDs are rejected', slug: 'duplicate-manifest-id', expected: 'manifest ids are unique', mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) => replace(opf, 'id="style1"', 'id="style0"')) }, { name: 'two manifest items naming one file are rejected', slug: 'shared-manifest-href', expected: 'no two manifest items name the same file', mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) => replace(opf, 'href="pages/' + second + '"', 'href="pages/' + first + '"')) }, { name: 'a dangling spine idref is rejected', slug: 'dangling-spine', expected: 'every spine itemref resolves', mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) => replace(opf, 'idref="ebook1"', 'idref="missing-chapter"')) }, { name: 'a dangling image source is rejected', slug: 'dangling-image', expected: 'every in the content resolves to a file in the archive', mutate: (zip) => rewriteChapter(zip, 0, (xhtml) => replace(xhtml, '../images/img-1.png', '../images/missing.png')) }, { name: 'a dangling navigation file is rejected', slug: 'dangling-navigation', expected: 'every navigation target resolves to a file and an id in it', mutate: (zip) => rewrite(zip, 'OEBPS/toc.xhtml', (nav) => replace(nav, 'href="pages/' + first + '"', 'href="pages/missing.xhtml"')) }, { name: 'a dangling content fragment is rejected', slug: 'dangling-fragment', expected: 'every link into a chapter resolves to an id in that same chapter', mutate: (zip) => rewriteChapter(zip, 0, (xhtml) => replace(xhtml, 'href="#results"', 'href="#missing-fragment"')) }, { name: 'a link to a chapter the book does not hold is rejected', slug: 'dangling-chapter-link', expected: 'every link between chapters resolves to a file in the book and an id in it', mutate: (zip) => rewriteChapter(zip, 0, (xhtml) => replace(xhtml, 'href="' + second + '"', 'href="missing.xhtml"')) }, { name: 'a link to a chapter naming an id it does not have is rejected', slug: 'dangling-chapter-fragment', expected: 'every link between chapters resolves to a file in the book and an id in it', mutate: (zip) => rewriteChapter(zip, 0, (xhtml) => replace(xhtml, 'href="' + third + '#summary"', 'href="' + third + '#missing"')) }, { name: 'a right to left book that pages left to right is rejected', slug: 'direction-disagrees', expected: 'page progression direction agrees with the chapters that read right to left', mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) => replace(opf, ' page-progression-direction="rtl"', '')) }, { name: 'an incorrect manifest media type is rejected', slug: 'incorrect-media-type', expected: 'manifest media types match their files', mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) => replace(opf, 'media-type="image/png"', 'media-type="image/jpeg"')) }, { name: 'MathML without a mathml manifest property is rejected', slug: 'mathml-undeclared', expected: 'properties="mathml" is declared for exactly the files that contain MathML', mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) => replace(opf, ' properties="mathml" media-type="application/xhtml+xml"', ' media-type="application/xhtml+xml"')) }, { name: 'a false mathml manifest property is rejected', slug: 'mathml-false-property', expected: 'properties="mathml" is declared for exactly the files that contain MathML', mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) => replace(opf, 'id="ebook0" href=', 'id="ebook0" properties="mathml" href=')) }, { name: 'a false alternativeText accessibility claim is rejected', slug: 'false-accessibility-claim', expected: 'alternativeText is claimed only when every image is accounted for', mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) => replace(opf, 'tableOfContents', 'tableOfContents\n' + 'alternativeText')) }, { name: 'mismatched OPF and NCX identifiers are rejected', slug: 'mismatched-identifiers', expected: 'ncx dtb:uid matches dc:identifier', mutate: (zip) => rewrite(zip, 'OEBPS/toc.ncx', (ncx) => ncx.replace(/content="urn:uuid:[^"]+"/, 'content="urn:uuid:00000000-0000-4000-8000-000000000000"')) } ]; try { for (const test of tests) { await runCase(tempDir, raw, test); } } finally { fs.rmSync(tempDir, {recursive: true, force: true}); } console.log(failures === 0 ? '\nnegative EPUB checks OK' : '\n' + failures + ' negative EPUB check(s) failed'); process.exit(failures === 0 ? 0 : 1); })().catch((error) => { console.error('FAILED to run negative EPUB checks:', error); process.exit(1); });