Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Przeczytaj [AGENTS.md](AGENTS.md). Edytuj Markdown, nie kopię porady w kodzie.

## Kontrole

Potrzebujesz Node.js 22 lub nowszego. Playwright pozostaje jedyną nową zależnością, wyłącznie testową. Czytnik nie ma zależności uruchomieniowych. Na Linuksie przeglądarki wymagają też bibliotek systemowych; samo pobranie binarek nie wystarcza. CI instaluje je przez --with-deps w jednorazowym runnerze. Instalacja pakietów na hoście roboczym wymaga zgody jego właściciela.
Potrzebujesz Node.js 22 lub nowszego. Playwright służy do testów przeglądarkowych. Testy regresji odziedziczonego eksportera EPUB używają Marked w tej samej przypiętej wersji co eksporter; w głównym package.json jest to zależność wyłącznie testowa. Czytnik nie ma zależności uruchomieniowych. Na Linuksie przeglądarki wymagają też bibliotek systemowych; samo pobranie binarek nie wystarcza. CI instaluje je przez --with-deps w jednorazowym runnerze. Instalacja pakietów na hoście roboczym wymaga zgody jego właściciela.

```bash
npm ci --ignore-scripts
Expand All @@ -130,7 +130,7 @@ npx playwright install chromium firefox webkit
npm run test:browser
```

Testy obejmują wszystkie decyzje źródłowe, stany, pochodzenie, liczby, odsyłacze i workflow. Przeglądarki wczytują rzeczywiste pliki i sprawdzają rozdziały, wyszukiwanie, filtry, klawiaturę, mały ekran i błędy. Playwright uruchamia Chromium na komputerze i z emulacją telefonu oraz Firefox i WebKit na komputerze. WebKit na Linuksie nie jest testem rzeczywistego Safari. Nie zastępuje to fizycznych urządzeń ani audytu technologii asystujących. Zielony CI nie potwierdza sam naukowej lub prawnej poprawności. Osobny przegląd musi dotyczyć dokładnego opublikowanego HEAD.
Testy obejmują wszystkie decyzje źródłowe, stany, pochodzenie, liczby, odsyłacze i workflow. Osobne testy budują syntetyczny EPUB w katalogu tymczasowym, bez publikowania e-booka ani uruchamiania eksportera na polskiej treści. Sprawdzają granice wierszy opisu, kodowanie tekstu i etykiet nawigacji oraz wynik parsowania HTML i XML. Przeglądarki wczytują rzeczywiste pliki i sprawdzają rozdziały, wyszukiwanie, filtry, klawiaturę, mały ekran i błędy. Playwright uruchamia Chromium na komputerze i z emulacją telefonu oraz Firefox i WebKit na komputerze. WebKit na Linuksie nie jest testem rzeczywistego Safari. Nie zastępuje to fizycznych urządzeń ani audytu technologii asystujących. Zielony CI nie potwierdza sam naukowej lub prawnej poprawności. Osobny przegląd musi dotyczyć dokładnego opublikowanego HEAD.

[Dependency Review](.github/workflows/dependency-review.yml) sprawdza zmieniane zależności, także deweloperskie, i kończy się błędem po wykryciu nowych znanych podatności high lub critical. Nie ocenia prawdziwości porad. [Dependabot](.github/dependabot.yml) proponuje cotygodniowe, grupowane aktualizacje npm i GitHub Actions; sam ich nie scala. CodeQL działa w zarządzanym trybie GitHuba dla JavaScript i Actions. Jego kanoniczną konfiguracją są ustawienia repozytorium, nie drugi workflow. Alerty wymagają oceny; poprawne wykonanie analizy nie oznacza braku podatności.

Expand Down
16 changes: 15 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test:browser": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.60.0"
"@playwright/test": "1.60.0",
"marked": "18.0.13"
}
}
55 changes: 55 additions & 0 deletions tests/browser/legacy-epub.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test';
import { attackChapter, attackDescription, buildLegacyEpub } from '../helpers/legacy-epub.mjs';

let files;
test.beforeAll(() => { files = buildLegacyEpub({ chapter: attackChapter, description: attackDescription }); });

test('legacy EPUB output stays inert under HTML parsing and well-formed under XML parsing', async ({ page }) => {
// No exporter is served or published. The synthetic archive is parsed in a blank,
// offline browser; any attacker-controlled request is aborted even on a regression.
await page.context().setOffline(true);
await page.route('**/*', route => route.abort());
for (const [name, text] of files) {
if (!/\.(?:xhtml|ncx|opf)$/.test(name)) continue;
const parsed = await page.evaluate(text => {
const xml = new DOMParser().parseFromString(text, 'application/xml');
return {
errors: [...xml.querySelectorAll('parsererror')].map(n => n.textContent),
scripts: xml.getElementsByTagName('script').length,
eventAttributes: [...xml.querySelectorAll('*')].flatMap(n => [...n.attributes].filter(a => /^on/i.test(a.name)).map(a => a.name)),
};
}, text);
expect(parsed, name).toEqual({ errors: [], scripts: 0, eventAttributes: [] });
await page.setContent(text);
await expect(page.locator('script, [onerror], [onload]'), name).toHaveCount(0);
if (!name.endsWith('cover.xhtml')) await expect(page.locator('img'), name).toHaveCount(0);
expect(await page.evaluate(() => globalThis.__legacyEpubProbe), name).toBeUndefined();
}
});

test('EPUB labels preserve nested formatting text, entities and working internal targets', async ({ page }) => {
await page.context().setOffline(true);
await page.route('**/*', route => route.abort());
const labels = ['Bold link and <code>', 'A & B < C 🙂', '&lt;literal&gt;'];
for (const name of ['OEBPS/nav.xhtml', 'OEBPS/toc.ncx']) {
const result = await page.evaluate(({ text, isNav }) => {
const xml = new DOMParser().parseFromString(text, 'application/xml');
const nodes = [...xml.querySelectorAll(isNav ? 'a' : 'navLabel text')];
return nodes.map(n => n.textContent);
}, { text: files.get(name), isNav: name.endsWith('.xhtml') });
for (const label of labels) expect(result, `${name}: ${label}`).toContain(label);
}
const targets = await page.evaluate(text => {
const xml = new DOMParser().parseFromString(text, 'application/xml');
return [...xml.querySelectorAll('a[href*="#"]')].map(a => a.getAttribute('href'));
}, files.get('OEBPS/nav.xhtml'));
expect(targets.length).toBeGreaterThan(2);
for (const href of targets) {
const [name, id] = href.split('#');
expect(files.has('OEBPS/' + name), href).toBe(true);
expect(await page.evaluate(({ text, id }) => {
const xml = new DOMParser().parseFromString(text, 'application/xml');
return [...xml.querySelectorAll('[id]')].some(n => n.getAttribute('id') === id);
}, { text: files.get('OEBPS/' + name), id }), href).toBe(true);
}
});
77 changes: 77 additions & 0 deletions tests/helpers/legacy-epub.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { inflateRawSync } from 'node:zlib';

const root = fileURLToPath(new URL('../../', import.meta.url));
export const marker = 'globalThis.__legacyEpubProbe=1';
export const truncatedImage = `<img src="https://example.invalid/probe" onerror="${marker}"`;
export const attackChapter = `# <script><img src=x onerror="${marker}"

## <pre>${truncatedImage}

## <SCRIPT><img src=x onerror="${marker}"

## **Bold** [link](../README.md) and \`<code>\`

## A &amp; B &lt; C &#x1F642;

## \`&lt;literal&gt;\`

Paragraph <script>${truncatedImage}

- List <pre>${truncatedImage}
`;
export const attackDescription = `Description first line.
# <scr
ipt>${truncatedImage}

## <pre
>${truncatedImage}`;

// Run the real legacy entry point with synthetic input, never the Polish README.
// Only the fixture has a node_modules link; no source or dependency file is rewritten.
export function buildLegacyEpub({ description = 'Fixture description.', chapter = '# Fixture chapter\n', front = 'Fixture front matter.' } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'legacy-epub-'));
try {
for (const path of ['tools/epub/build.mjs', 'tools/epub/style.css', 'tools/lib/book.mjs', 'tools/lib/zip.mjs', 'tools/lib/split-items.mjs']) {
mkdirSync(dirname(join(dir, path)), { recursive: true });
copyFileSync(join(root, path), join(dir, path));
}
symlinkSync(join(root, 'node_modules'), join(dir, 'node_modules'), 'junction');
mkdirSync(join(dir, 'book'));
writeFileSync(join(dir, 'book/01-fixture.md'), chapter);
writeFileSync(join(dir, 'README.md'), `# 高性价比人生指南\n\n${description}\n\n[![fixture](badge.png)](https://example.invalid/)\n\n## 这本书想回答的问题\n\n${front}\n\n## 目录\n\n[Fixture chapter](book/01-fixture.md)\n\n## 正文\n`);
// A one-pixel PNG keeps the fixture independent of the inherited cover image.
writeFileSync(join(dir, 'og.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jr1cAAAAASUVORK5CYII=', 'base64'));
const run = spawnSync(process.execPath, [join(dir, 'tools/epub/build.mjs'), 'fixture.epub'], {
cwd: dir, encoding: 'utf8', timeout: 60000,
});
assert.equal(run.error, undefined, String(run.error));
assert.equal(run.status, 0, run.stdout + run.stderr);
const archive = readFileSync(join(dir, 'fixture.epub'));
const files = new Map();
// Read local entries from this repository's ZIP writer (no data descriptors).
for (let offset = 0; archive.readUInt32LE(offset) === 0x04034b50;) {
const method = archive.readUInt16LE(offset + 8);
const size = archive.readUInt32LE(offset + 18);
const nameLength = archive.readUInt16LE(offset + 26);
const extraLength = archive.readUInt16LE(offset + 28);
const name = archive.subarray(offset + 30, offset + 30 + nameLength).toString();
const start = offset + 30 + nameLength + extraLength;
const packed = archive.subarray(start, start + size);
assert.ok(method === 0 || method === 8, `unexpected ZIP method ${method}`);
const data = method === 0 ? packed : inflateRawSync(packed);
assert.equal(data.length, archive.readUInt32LE(offset + 22), name);
files.set(name, data.toString());
offset = start + size;
}
assert.equal(files.get('mimetype'), 'application/epub+zip');
return files;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
54 changes: 54 additions & 0 deletions tests/legacy-epub.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readBook } from '../tools/lib/book.mjs';
import { siteFiles } from '../tools/pl/build-site.mjs';
import { attackChapter, attackDescription, buildLegacyEpub } from './helpers/legacy-epub.mjs';

const read = path => readFileSync(new URL('../' + path, import.meta.url), 'utf8');

test('the real legacy EPUB exporter escapes raw-block text in body and navigation', () => {
const files = buildLegacyEpub({ chapter: attackChapter });
for (const name of ['OEBPS/ch01.xhtml', 'OEBPS/nav.xhtml', 'OEBPS/toc.ncx']) {
const text = files.get(name);
assert.ok(text, name);
assert.doesNotMatch(text, /<img\b|<script\b|<pre\b/i, name);
assert.match(text, /&lt;img/, `${name} must preserve the malformed tag as text`);
assert.doesNotMatch(text, /&amp;amp;|&amp;lt; C/, `${name} must not double-escape normal entities`);
}
});

test('split README tags do not become HTML in the EPUB front matter or navigation', () => {
const files = buildLegacyEpub({ description: attackDescription });
for (const name of ['OEBPS/front.xhtml', 'OEBPS/nav.xhtml', 'OEBPS/toc.ncx', 'OEBPS/content.opf']) {
const text = files.get(name);
assert.ok(text, name);
assert.doesNotMatch(text, /<img\b|<script\b|<pre\b/i, name);
}
assert.match(files.get('OEBPS/content.opf'), /# &lt;scr\nipt&gt;/, 'metadata retains the line boundary and escapes it instead of reconstructing a tag');
});

test('legacy headings keep formatting and links but XML navigation contains only escaped text', () => {
const files = buildLegacyEpub({ chapter: '# A &amp; B &lt; C &#x1F642;\n\n## **Bold** [link](../README.md) and `<code>`\n\n## `&lt;literal&gt;`\n' });
const body = files.get('OEBPS/ch01.xhtml');
assert.match(body, /<strong>Bold<\/strong> <a href="front.xhtml">link<\/a> and <code>&lt;code&gt;<\/code>/);
for (const name of ['OEBPS/nav.xhtml', 'OEBPS/toc.ncx']) {
const text = files.get(name);
assert.doesNotMatch(text, /<strong>|<code>|<a href="front.xhtml">link/, name);
assert.match(text, /A &amp; B &lt; C 🙂/, name);
assert.match(text, /Bold link and &lt;code&gt;/, name);
assert.match(text, /&amp;lt;literal&amp;gt;/, `${name} preserves literal entities inside code`);
}
});

test('the legacy repair neither adapts the Polish README nor adds exports to Pages', () => {
assert.throws(() => readBook(), /README/, 'the upstream format guard remains in force');
assert.ok(siteFiles().every(path => !/^tools\/|selfcheck|\.epub$/.test(path)));
const rootPackage = JSON.parse(read('package.json'));
const legacyPackage = JSON.parse(read('tools/epub/package.json'));
assert.equal(rootPackage.devDependencies.marked, legacyPackage.dependencies.marked, 'regressions use the exporter parser version');
assert.equal(rootPackage.dependencies, undefined, 'no reader runtime dependency');
for (const name of ['book', 'pages', 'dependency-review']) {
assert.doesNotMatch(read(`.github/workflows/${name}.yml`), /tools\/(?:epub|anki|pdf)\/build/);
}
});
6 changes: 5 additions & 1 deletion tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ Rola nie jest ślepa z natury: pakiet jest ślepy, ale rola ma narzędzia Read,

Pozostałe skrypty w tools/ odziedziczono z chińskiego upstream. book.mjs, generatory EPUB/PDF/offline/Anki, sync-stats.mjs, updates.mjs i check-*.mjs zakładają chińskie nagłówki, pola, odsyłacze lub historyczny interfejs. Nie są zgodne z polskim README i czytnikiem. Nie uruchamiaj ich do aktualizacji polskiej treści ani statystyk.

Workflow book.yml wykonuje tylko walidację polskiej treści i czytnika. Nie buduje ani nie publikuje e-booków. Jedyną publikacją jest ręczny pages.yml, który wdraża stronę czytnika zbudowaną przez build-site.mjs. Przywrócenie wydawania wymaga osobnego, sprawdzonego zakresu i zgody właściciela.
Poprawka odziedziczonej ścieżki EPUB zachowuje granice wierszy opisu README zamiast usuwać znaczniki wyrażeniem regularnym i sklejać wiersze. Eksporter traktuje opis jako Markdown. Koduje tekst także wtedy, gdy parser oznaczył go jako fragment surowego HTML, pobiera etykiety nagłówków z tokenów zamiast usuwać znaczniki z wygenerowanego HTML i koduje etykiety oraz adresy w nawigacji XHTML i NCX. Opis w metadanych XML nadal jest kodowany dla XML. Testy `tests/legacy-epub.test.mjs` i `tests/browser/legacy-epub.spec.mjs` uruchamiają rzeczywisty eksporter z syntetycznym README i rozdziałem wyłącznie w katalogu tymczasowym. Sprawdzają archiwum, nieaktywny wynik HTML, poprawność składni XML, etykiety i odnośniki. Marked w głównym package.json służy wyłącznie tym testom i ma tę samą wersję co zależność eksportera. To nie jest przystosowanie eksportera do polskiego wydania ani potwierdzenie działania w aplikacji do czytania EPUB.

Pozostałe ocenione ostrzeżenia CodeQL mają inne konteksty: `tools/anki/build.mjs` przekształca tekst do sortowania i sumy kontrolnej, zachowując kodowane pola wyświetlania; `tools/pdf/build.mjs` usuwa komentarze przed przekazaniem Markdown do Pandoc/Typst, nie do przeglądarki; `tools/check-selfcheck.mjs` pobiera skrypt ze stałego lokalnego pliku do testu, nie oczyszcza HTML do publikacji. Żadna z tych funkcji nie jest sanitizatorem HTML. Nie używaj VM tego testu do wykonywania niezaufanego kodu. Ocena tych użyć nie potwierdza działania zewnętrznych aplikacji ani nie zamyka automatycznie alertów.

Workflow book.yml wykonuje walidację polskiej treści i czytnika oraz syntetyczne testy regresji narzędzi. Nie buduje ani nie publikuje e-bookowego wydania polskich porad. Jedyną publikacją jest ręczny pages.yml, który wdraża stronę czytnika zbudowaną przez build-site.mjs. Przywrócenie wydawania wymaga osobnego, sprawdzonego zakresu i zgody właściciela.
Loading
Loading