Skip to content

fix(epub): escape legacy text and navigation labels - #43

Merged
mikomiel merged 1 commit into
mainfrom
task/codeql-legacy-fix
Nov 7, 2026
Merged

mikomiel merged 1 commit into
mainfrom
task/codeql-legacy-fix

Conversation

@mikomiel

@mikomiel mikomiel commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Source-only legacy EPUB security fix

Fixes the confirmed latent paths behind CodeQL alerts2/3: preserve README description lines, escape text regardless of Marked raw-block state, derive labels from tokens and encode navigation/NCX text and attributes. Real exporter tests build only synthetic scratch EPUBs and parse their entries as HTML/XML. Marked18.0.13 is the existing exporter dependency, added root dev-only; no reader runtime dependency.

Base08d789cb6511a9ad05c54d89d8d66ddece9d8465; tested HEADf3b8c0ea086e2cba2bc3329c6a0fb9b209c6090a, treebc08b7d5a067d00c22fc31a4e832fff0596d92b6. PASS local315 Node tests/lint591 zero errors/116 Chromium;3 rejected scratch mutations, byte restoration. PASS exact-head CI5/5,315 Node/232 browsers across all4 projects, run42285941941; no skipped browser projects. Local Firefox/WebKit launch unavailable for host libraries; CI exercised them. All27 publication files/workflows/allowlist/instructions/licences unchanged. No source claim of EPUB-app compatibility; full Anki/PDF and public payload tests NOT RUN.

Alerts1/4/5 are not fixed/dismissed: Anki sorting/checksum is not escaped display HTML; PDF comment preprocessing feeds Pandoc/Typst not a browser; selfcheck extracts trusted fixture code, not sanitization. All5 alerts remain open on main; PR-only zero new results is not a rescan proving all existing alerts resolved.

Human owns merge; no merge/deployment requested or performed. Rollback before merge: close PR. After merge: reviewed revert, no history rewrite. Full per-alert triage and validation details remain in PR comments/local handoff.

Exact-head source context for independent review

The first diff-only review was REVIEW_INCOMPLETE because unchanged producer/consumer code was absent. This supplement is public repository source at the exact tested HEAD, not reviewer instructions or execution evidence. File/range boundaries are shown; the exact HEAD pins the source. Generated prose/metadata boilerplate is omitted only where not involved in the changed flow. No source bytes were changed to obtain review.

tools/epub/build.mjs:1–50

// 把 README + book/*.md + docs/*.md 打成一本 EPUB 3。
// 用法:node tools/epub/build.mjs [输出路径]   默认输出 dist/HowToLiveBetter.epub
// 只依赖 marked;zip 用 tools/lib/zip.mjs 自己打(EPUB 要求 mimetype 第一个且不压缩,通用 zip 库不一定保证)。
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { resolve, dirname, posix } from 'node:path';
import { zip } from '../lib/zip.mjs';
import { Marked, Renderer, Tokenizer } from 'marked';
import { splitLongItems } from '../lib/split-items.mjs';
import { ROOT, REPO, SITE, TITLE, read, readBook, gitCommit, buildStamp, stripBackLink } from '../lib/book.mjs';

const OUT = resolve(ROOT, process.argv[2] ?? 'dist/HowToLiveBetter.epub');
const RELEASE = `${REPO}/releases/download/epub-latest/HowToLiveBetter.epub`;
const BOOK_ID = 'urn:uuid:5c0c1c0e-6a5c-4d2b-9b1e-7d1f0a4e8c31';
const NOW = new Date();
const COMMIT = gitCommit();

const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Decode only references that survive toXhtml, once, for plain navigation labels.
// Code spans and backslash escapes are literal; they must not be entity-decoded.
const xmlEntities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
const textValue = text => text.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);/g, (reference, entity) => {
  if (entity in xmlEntities) return xmlEntities[entity];
  const n = entity.startsWith('#x') ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
  const valid = n === 9 || n === 10 || n === 13 || (n >= 0x20 && n <= 0xd7ff) || (n >= 0xe000 && n <= 0xfffd) || (n >= 0x10000 && n <= 0x10ffff);
  return valid ? String.fromCodePoint(n) : reference;
});
const plain = tokens => tokens.map(token => {
  if (token.type === 'html' || token.type === 'image') return '';
  if (token.tokens) return plain(token.tokens);
  if (token.type === 'br') return ' ';
  return token.type === 'text' ? textValue(token.text) : (token.text ?? '');
}).join('');

// ---------- 从 README 取内容与文件清单 ----------
const book = readBook();
const { description, frontMd, bookFiles, docFiles } = book;
const contentsMd = book.contentsMd.replace(/^## 目录/, '# 各节简介');

// ---------- 页面清单 ----------
// 每页:xhtml 文件名、来源 md 的仓库路径(用来解析相对链接)、md 正文
const pages = [
  { file: 'front.xhtml', src: 'README.md', title: '前言', md: `# ${TITLE}\n\n${description}\n\n${frontMd}` },
  { file: 'contents.xhtml', src: 'README.md', title: '各节简介', md: contentsMd },
  ...bookFiles.map((src, i) => ({ file: `ch${String(i + 1).padStart(2, '0')}.xhtml`, src, md: splitLongItems(stripBackLink(read(src))) })),
  ...docFiles.map((src, i) => ({ file: `doc${i + 1}.xhtml`, src, md: splitLongItems(stripBackLink(read(src))) })),
  { file: 'about.xhtml', src: 'README.md', title: '版本说明', md: aboutMd() },
];
const pageByPath = new Map(pages.map(p => [p.src, p.file]));
pageByPath.set('README.md', 'front.xhtml');

tools/epub/build.mjs:69–180

let current = null; // 正在转换的页
let headingSeq = 0;
const marked = new Marked({ gfm: true });
marked.use({
  // GFM 的裸网址自动链接只在空白处断开,「www.12333.gov.cn网页、手机12333客户端」这种
  // 中文紧贴网址的写法会把后面整串中文都吞进链接,epubcheck 判为非法 URL(RSC-020)。
  // 裸网址里本来就不该有非 ASCII 字符,遇到就截在那里,截下的前半段照常按默认规则建链接。
  tokenizer: {
    url(src) {
      const tok = Tokenizer.prototype.url.call(this, src);
      if (!tok || /^[\x21-\x7e]*$/.test(tok.raw)) return tok;
      return Tokenizer.prototype.url.call(this, tok.raw.match(/^[\x21-\x7e]*/)[0]);
    },
  },
  renderer: {
    heading({ tokens, depth }) {
      const html = this.parser.parseInline(tokens);
      const id = `h${++headingSeq}`;
      current.headings.push({ id, depth, text: plain(tokens) });
      return `<h${depth} id="${id}">${html}</h${depth}>\n`;
    },
    link({ href, title, tokens }) {
      const text = this.parser.parseInline(tokens);
      const t = title ? ` title="${esc(title)}"` : '';
      return `<a href="${esc(rewriteHref(href))}"${t}>${text}</a>`;
    },
    // Dropping an HTML token does not clear Marked's raw-block lexer state.
    // Never trust that state's `escaped` flag as permission to emit text as HTML.
    text(token) {
      return Renderer.prototype.text.call(this, { ...token, escaped: false });
    },
    image: () => '',
    html: () => '',
  },
});

function rewriteHref(href) {
  if (/^(https?:|mailto:|#)/.test(href)) return href;
  const [pathPart, hash] = href.split('#');
  const target = posix.normalize(posix.join(posix.dirname(current.src), pathPart));
  const page = pageByPath.get(target);
  if (page) return hash ? `${page}#${hash}` : page;
  const kind = target.endsWith('/') ? 'tree' : 'blob';
  return `${REPO}/${kind}/main/${target}`;
}

function toXhtml(body) {
  return body
    .replace(/<(br|hr)>/g, '<$1/>')
    .replace(/<(img|input)\b([^>]*?)\s*\/?>/g, '<$1$2/>')
    .replace(/&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;');
}

function wrap(title, body) {
  return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="zh-CN" lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>${esc(title)}</title>
<link rel="stylesheet" type="text/css" href="style.css"/>
</head>
<body>
${body}</body>
</html>
`;
}

for (const p of pages) {
  current = p;
  p.headings = [];
  const body = toXhtml(marked.parse(p.md));
  p.title ??= p.headings[0]?.text ?? p.file;
  p.xhtml = wrap(p.title, `<section epub:type="chapter">\n${body}</section>\n`);
}

// ---------- 导航 ----------
const navItems = pages.map(p => {
  const [first, ...rest] = p.headings;
  const top = first?.depth === 1 ? { href: `${p.file}#${first.id}`, text: p.title } : { href: p.file, text: p.title };
  const subs = (first?.depth === 1 ? rest : p.headings).filter(h => h.depth <= 3).map(h => ({ href: `${p.file}#${h.id}`, text: h.text }));
  return { ...top, subs };
});

const navXhtml = wrap('目录', `<nav epub:type="toc" id="toc">
<h1>目录</h1>
<ol>
${navItems.map(n => `<li><a href="${esc(n.href)}">${esc(n.text)}</a>${n.subs.length ? `\n<ol>\n${n.subs.map(s => `<li><a href="${esc(s.href)}">${esc(s.text)}</a></li>`).join('\n')}\n</ol>\n` : ''}</li>`).join('\n')}
</ol>
</nav>
<nav epub:type="landmarks" hidden="hidden">
<ol>
<li><a epub:type="cover" href="cover.xhtml">封面</a></li>
<li><a epub:type="bodymatter" href="${pages[2].file}">正文</a></li>
</ol>
</nav>
`);

let play = 0;
const navPoint = n => `<navPoint id="np${++play}" playOrder="${play}"><navLabel><text>${esc(n.text)}</text></navLabel><content src="${esc(n.href)}"/>${n.subs?.map(navPoint).join('') ?? ''}</navPoint>`;
const ncx = `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1" xml:lang="zh-CN">
<head>
<meta name="dtb:uid" content="${BOOK_ID}"/>
<meta name="dtb:depth" content="2"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle><text>${TITLE}</text></docTitle>
<navMap>
${navItems.map(navPoint).join('\n')}
</navMap>
</ncx>

tools/epub/build.mjs:184–202

const coverXhtml = wrap(TITLE, `<div class="cover"><img src="cover.png" alt="${esc(TITLE)}"/></div>\n`);
const modified = NOW.toISOString().replace(/\.\d{3}Z$/, 'Z');
const manifestPages = pages.map(p => `<item id="${p.file.replace('.xhtml', '')}" href="${p.file}" media-type="application/xhtml+xml"/>`);
const opf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="zh-CN">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="pub-id">${BOOK_ID}</dc:identifier>
<dc:title>${TITLE}</dc:title>
<dc:language>zh-CN</dc:language>
<dc:creator>eternity4719</dc:creator>
<dc:description>${esc(description)}</dc:description>
<dc:source>${REPO}</dc:source>
<dc:rights>CC BY 4.0(https://creativecommons.org/licenses/by/4.0/)</dc:rights>
<dc:date>${NOW.toISOString().slice(0, 10)}</dc:date>
<meta property="dcterms:modified">${modified}</meta>
<meta name="cover" content="cover-img"/>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>

tools/lib/book.mjs:7–18

export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
export const REPO = 'https://github.com/eternity4719/HowToLiveBetter';
export const SITE = 'https://eternity4719.github.io/HowToLiveBetter/';
export const TITLE = '高性价比人生指南';
export const RELEASE = `${REPO}/releases/download/epub-latest`;

// 一律按 LF 交给各套构建:Windows 上 core.autocrlf=true 检出的是 CRLF,离线版脚本
// 拿 '\n' 写的 needle 去 index.html 里找锚点就一个都找不着,本地构建直接报「找不到
// 主脚本的开头」(CI 是 Linux,从没碰到过)。正文解析也不必各自处理 \r。
export const read = p => readFileSync(resolve(ROOT, p), 'utf8').replace(/\r\n/g, '\n');
export const unique = arr => [...new Set(arr)];

tools/lib/book.mjs:37–59

// README 里从某个标题到下一个标题之间的一段
export function readBook() {
  const readme = read('README.md');
  const lines = readme.split('\n');
  const between = (from, to) => {
    const a = lines.findIndex(l => l.startsWith(from));
    const b = lines.findIndex((l, i) => i > a && l.startsWith(to));
    if (a < 0 || b < 0) throw new Error(`README 里找不到 ${from} 到 ${to} 这一段`);
    return lines.slice(a, b).join('\n');
  };
  // Keep Markdown and line boundaries. Exporters must encode for their output;
  // stripping tags and joining lines can reconstruct markup across the joins.
  const description = between('# 高性价比人生指南', '[![')
    .split('\n').slice(1).join('\n').trim();
  const frontMd = between('## 这本书想回答的问题', '## 目录');
  const contentsMd = between('## 目录', '## 正文')
    .split('\n\n').filter(p => !p.includes('index.html')).join('\n\n');
  const bookFiles = unique([...contentsMd.matchAll(/\]\((book\/[^)#]+\.md)\)/g)].map(m => m[1]));
  const docFiles = unique([...readme.matchAll(/\]\((docs\/[^)#/]+\.md)\)/g)].map(m => m[1]));
  if (bookFiles.length === 0) throw new Error('README 目录里没找到 book/ 文件');
  return { readme, description, frontMd, contentsMd, bookFiles, docFiles };
}

tools/pdf/build.mjs:19–29

const { description, frontMd, contentsMd, bookFiles, docFiles } = readBook();

// ---------- 页(每页一个一级标题,一级标题在 typst 里另起一页) ----------
const anchorOf = new Map();
bookFiles.forEach(f => anchorOf.set(f, 'sec-' + (basename(f).match(/^\d+/)?.[0] ?? anchorOf.size + 1)));
docFiles.forEach((f, i) => anchorOf.set(f, `doc-${i + 1}`));

const pages = [
  { src: 'README.md', md: `# 前言\n\n${description}\n\n${frontMd}`, anchor: 'front' },
  { src: 'README.md', md: contentsMd.replace(/^## 目录/, '# 各节简介'), anchor: 'contents' },
  ...[...bookFiles, ...docFiles].map(src => ({ src, md: splitLongItems(stripBackLink(read(src))), anchor: anchorOf.get(src) })),

tools/pdf/build.mjs:63–95

const body = pages.map(p => {
  const md = rewriteLinks(p.md, p.src)
    .replace(/<!--[\s\S]*?-->/g, '')                       // 成本标签这类 HTML 注释不进 PDF
    .replace(/^(# .+?)\s*$/m, `$1 {#${p.anchor}}`);        // 给这一页的一级标题挂锚点
  if (!md.includes(`{#${p.anchor}}`)) throw new Error(`${p.src} 里没找到一级标题,挂不上锚点`);
  return md.trim();
}).join('\n\n');

mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(WORK, body);

// ---------- pandoc → typst → pdf ----------
const run = (cmd, args) => {
  try {
    return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
  } catch (err) {
    if (err.code === 'ENOENT') throw new Error(`找不到 ${cmd},装上它或用环境变量 ${cmd === PANDOC ? 'PANDOC' : 'TYPST'} 指到可执行文件`);
    throw new Error(`${cmd} 失败:\n${err.stderr || err.stdout || err.message}`);
  }
};

const typFile = resolve(ROOT, 'dist/pdf-build.typ');
run(PANDOC, [
  '--from=gfm+attributes', '--to=typst', '--wrap=none',
  `--template=${resolve(ROOT, 'tools/pdf/template.typ')}`,
  '-V', `booktitle=${TITLE}`, '-V', `subtitle=${description}`,
  '-V', `builddate=${STAMP}`, '-V', `commit=${COMMIT.slice(0, 7) || '未知'}`,
  '-V', `site=${SITE}`, '-V', `repo=${REPO}`,
  '-o', typFile, WORK,
]);
const log = run(TYPST, ['compile', typFile, OUT, '--root', ROOT]);
if (log.trim()) console.log(log.trim());

tools/anki/build.mjs:61–63

const esc = s => String(s ?? '').replace(/\*\*/g, '').replace(/\\([*_])/g, '$1')
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const pad = n => String(n).padStart(2, '0');

tools/anki/build.mjs:82–89

    const fields = [
      `第 ${s.sec} 节 · ${esc(s.name)} · 第 ${e.n} 条`,
      esc(e.title),
      esc(e.human || ''),
      esc(e.cost || ''),
      esc(points),
      `<a href="${url}">在线版看全文和来源</a>`,
    ];

tools/anki/build.mjs:187–196

const stripHtml = s => s.replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const csum = s => parseInt(createHash('sha1').update(stripHtml(s)).digest('hex').slice(0, 8), 16);
const insNote = db.prepare('INSERT INTO notes VALUES (?, ?, ?, ?, -1, ?, ?, ?, ?, 0, \'\')');
const insCard = db.prepare('INSERT INTO cards VALUES (?, ?, ?, 0, ?, -1, 0, 0, ?, 0, 0, 0, 0, 0, 0, 0, 0, \'\')');
db.exec('BEGIN');
for (const { s, e, fields, tags, due } of notes) {
  const sortField = fields[model.sortf];
  insNote.run(noteId(s.sec, e.n), `hltb-${s.sec}-${e.n}`, MODEL_ID, NOW_S, ` ${tags.join(' ')} `,
    fields.join('\x1f'), stripHtml(sortField), csum(sortField));
  insCard.run(cardId(s.sec, e.n), noteId(s.sec, e.n), deckId(s.sec), NOW_S, due);

tools/check-selfcheck.mjs:3–11

import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import vm from 'node:vm';

const root = new URL('../', import.meta.url);
const read = file => readFileSync(new URL(file, root), 'utf8');
const html = read('selfcheck.html');
const script = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].at(-1)?.[1];
assert.ok(script, '找不到自查页脚本');

tools/check-selfcheck.mjs:26–35

const context = vm.createContext({
  window:{__CORPUS__:{readme:read('README.md'), parts}},
  document:{getElementById:element},
  localStorage:{getItem:() => JSON.stringify(oldAnswers), setItem:() => {}},
  location:{protocol:'http:'}, scrollTo:() => {},
  console:{error:message => warnings.push(message)},
});
vm.runInContext(script, context);
await new Promise(resolve => setImmediate(resolve));
assert.equal(element('status').removed, true, element('status').textContent);

playwright.config.mjs:1–28

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/browser',
  workers: 1,
  retries: 0,
  timeout: 15000,
  reporter: 'list',
  use: { baseURL: 'http://127.0.0.1:8765', browserName: 'chromium' },
  projects: [
    { name: 'desktop', use: { viewport: { width: 1280, height: 900 } } },
    { name: 'mobile', use: { viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true } },
    { name: 'firefox', use: { browserName: 'firefox', viewport: { width: 1280, height: 900 } } },
    { name: 'webkit', use: { browserName: 'webkit', viewport: { width: 1280, height: 900 } } },
  ],
  webServer: {
    command: 'python3 -u -m http.server 8765 --bind 127.0.0.1',
    // Start our own server before probing HTTP. Closed loopback ports can be dropped.
    wait: { stdout: /Serving HTTP on 127\.0\.0\.1 port 8765/ },
    timeout: 10000,
  },
});

.github/workflows/book.yml:1–44

# Polskie wydanie: tylko walidacja. Bez e-booków, wydań, tagów i wdrożeń.
name: Kontrole polskiego wydania

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: pilot-checks-${{ github.ref }}
  cancel-in-progress: true

jobs:
  pilot:
    name: Treść i czytnik
    # Keep the runner compatible with the pinned Playwright browser builds.
    runs-on: ubuntu-24.04
    # Allow the complete four-project browser suite to finish for the expanded book.
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 22
          cache: npm
      - run: npm ci --ignore-scripts
      - run: npm test
      - run: npm run lint:content
      - run: npx playwright install --with-deps chromium firefox webkit
      - run: npm run test:browser

Marked18.0.13 Renderer.ts, installed integrity-checked package source map

  text(token: Tokens.Text | Tokens.Escape): RendererOutput {
    return 'tokens' in token && token.tokens
      ? this.parser.parseInline(token.tokens) as unknown as RendererOutput
      : ('escaped' in token && token.escaped ? token.text as RendererOutput : escapeHtmlEntities(token.text) as RendererOutput);
  }
}

Marked18.0.13 Tokenizer.ts, installed integrity-checked package source map

  inlineText(src: string): Tokens.Text | undefined {
    const cap = this.rules.inline.text.exec(src);
    if (cap) {
      const escaped = this.lexer.state.inRawBlock;
      return {
        type: 'text',
        raw: cap[0],
        text: cap[0],
        escaped,
      };
    }
  }
}

Preserve README description line boundaries, ignore raw-block escape bypasses, and encode token-derived EPUB navigation labels. Add real-exporter synthetic fixtures and offline HTML/XML regressions with the existing pinned parser as a test dependency. No Polish reader, publication files or workflow changes.
@mikomiel

mikomiel commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner Author

Validation and review evidence — exact HEAD f3b8c0e

Base: 08d789cb6511a9ad05c54d89d8d66ddece9d8465; tested tree: bc08b7d5a067d00c22fc31a4e832fff0596d92b6.

PASS: all five exact-head PR checks. Content/reader run 42285941941, job 128126659525, completed successfully at 2026-11-06T20:19:55Z. Its logs report 315/315 Node tests, 591 records with zero lint errors/warnings, and 232/232 browser cases across desktop/mobile Chromium, Firefox and WebKit, with retries disabled. Dependency review and both CodeQL analyses also succeeded.

PASS locally: 315 Node tests, lint591/0/0, 116 desktop/mobile Chromium cases, and git diff --check. The old code reproduced the unsafe paths. Three separate scratch-only mutations restoring the raw-block escape bypass, unencoded navigation text, or README line concatenation failed the intended regression assertions; the actual worktree files remained byte-identical. All 27 Pages publication files, all workflows, the publication allowlist owner, instructions and licences match base bytes exactly.

UNAVAILABLE locally: Firefox/WebKit launch, due to missing host libraries before test execution. No host package installation or project exclusion was performed; the successful CI run exercised both projects.

NOT RUN: actual EPUB-reader application, full Anki/PDF application paths, public-site payload testing or deployment. A synthetic EPUB is built only in a disposable test directory, never from the Polish advice.

Independent review status

The first configured App review examined this exact base/head and returned REVIEW_INCOMPLETE, refusal VERDICT_INCOMPLETE. It found no concrete reachable failure in the supplied diff, but lacked unchanged source for the renderer, shared README consumers and browser configuration. That first attempt posted no native review or approval. Its optional suggestions concerned additional attribute/entity cases, not established defects. This is not counted as successful independent review or human approval.

The PR description was supplemented with labelled source excerpts pinned to the same HEAD. The single bounded follow-up returned APPROVE, and GitHub readback confirms native review 5806556472, APPROVED by ecomwizards-gandalf[bot] at 2026-11-06T20:28:43Z for f3b8c0ea086e2cba2bc3329c6a0fb9b209c6090a. No source changed between attempts. The reviewer found no concrete reachable failure within the bounded repair and offered only optional additional entity/escape coverage. It read the complete diff and supplied context but did not run tests or establish EPUB-application compatibility. The initial incomplete attempt remains recorded; the follow-up is independent AI source review, not human approval or merge authorization.

External gate: PR #43 remains open and unmerged. All five exact-head checks pass; the repository's human-owned merge decision remains outstanding. No agent merge or Pages dispatch was performed.

Existing alert states

Alerts2/3 are the confirmed legacy path repaired by this source change. Alerts1/4/5 retain their contextual dispositions and limitations; this PR neither fixes nor dismisses them. A PR-head analysis showing zero results is not proof that all inherited main-branch alerts are resolved. Main still has five open alerts; no dismissal, suppression or scan exclusion was performed.

Human owns merge. This source-only fix requests no Pages deployment, exporter activation, identity/protection change or publication-scope expansion.

@ecomwizards-gandalf ecomwizards-gandalf Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent snapshot review of 08d789cb6511...f3b8c0ea086e in mikomiel/HowToLiveBetter-PL by pi on codex-lb (gpt-6.1-sol).

Verdict: APPROVE

Reviewer output

Findings

No concrete reachable failure found in the supplied diff and exact-head source excerpts for the bounded alerts 2/3 repair.

  • Output encoding: tools/epub/build.mjs:95–97 overrides Marked’s raw-block escaped flag before delegating text rendering. Navigation and NCX now encode labels and attribute values at their output boundaries.
  • Entity semantics: tools/epub/build.mjs:20–33 derives labels from tokens, decodes supported XML references once in ordinary text, and keeps code-span/escape tokens separate. Subsequent encoding prevents decoded <, &, or quotes from becoming markup.
  • Line preservation: tools/lib/book.mjs:48–52 retains internal newlines rather than stripping tags and concatenating lines. The supplied EPUB consumers render the description as Markdown or XML-encode it for metadata. The supplied PDF consumer passes Markdown to Pandoc rather than a browser.
  • Scope and standards: The PR title follows Conventional Commits. The changes remain focused on the legacy repair, behavioural regression tests, a pinned development dependency, and documentation. No secret values or reader runtime dependency were apparent.

The tests exercise the real exporter with synthetic scratch inputs and inspect generated entries, rather than merely checking source patterns. Their HTML/XML assertions are useful regression coverage, but do not establish EPUB-reader compatibility.

Optional suggestion

Add a parsed-label regression covering backslash escapes, decimal character references, and an unsupported named entity. The helper explicitly distinguishes those semantics, while the current positive assertions primarily cover code spans, supported named references, and hexadecimal references.

Evidence and limitations

I read /tmp/github-review-j1lwdht2/candidate.diff completely and the supplied metadata and labelled source excerpts. I did not execute tests, install dependencies, reproduce CI, independently inspect the installed Marked package, or query CodeQL alert state.

The full checkout, repository instruction files, and unshown dependency/source files were unavailable. No further missing material blocks this bounded source assessment; conclusions about other exporter paths, full repository compliance, existing-alert closure, or EPUB-application behaviour would require additional evidence. This verdict is not merge or deployment authorization.

VERDICT: APPROVE

Evidence: not written. This review is evidence, not merge authorization.

@mikomiel
mikomiel merged commit 554a767 into main Nov 7, 2026
5 checks passed
@mikomiel
mikomiel deleted the task/codeql-legacy-fix branch November 7, 2026 15:04
@mikomiel

mikomiel commented Nov 7, 2026

Copy link
Copy Markdown
Owner Author

Post-merge verification — 2026-11-07

Verified by Pi (session 06d56be0-7d39-4fdb-8c61-5ea28b71f96f).

All 27 Pages publication files remain byte-identical to the deployed 08d789cb6511a9ad05c54d89d8d66ddece9d8465 revision. No Pages dispatch, exporter activation, permission/protection change, or actual EPUB-reader application test was performed. A Pages redeployment is not needed for this source-only change.

The first local CI watcher stopped on an HTTP 502. One read-only reconnection observed the original run succeed; the workflow was not rerun. This closes the source-fix/integration task, not the three remaining alert records or a general security certification.

mikomiel pushed a commit that referenced this pull request Nov 9, 2026
读者提议加非处方药使用指南。全书原来只有第 2 节提过尼古丁替代品是非处方药、第 28 节讲过处方药分类销售,
没有一条讲自己买来的药怎么吃才不出事。新节不写成用药手册,只收一个动作就能避开重后果的:
对乙酰氨基酚别吃重复(一日最多 2 克、用药期间不喝酒)、孩子退烧不用阿司匹林尼美舒利安乃近、
布洛芬这类药的胃出血高危人群、2 岁以下不自己喂复方感冒药、怀孕 20 周后不自己吃布洛芬这类药、
奥美拉唑自己吃最多 7 天、感冒不要抗生素、腹泻先补液且 5 岁以下不给止泻药、止痛药过度使用的天数门槛。

来源逐字核对:药监局 2020 年第 15、34 号,2021 年第 57 号,2022 年第 68 号公告及附件,国食药监安〔2011〕209 号,
21 CFR 201.326,FDA 2020 安全警示,WHO 腹泻手册,ICHD-3,Europe PMC 取五篇文献摘要。核对过程与没写进去的三项
记在 docs/核实记录/34-家里的常备药别吃出事.md。

统计已同步:615 → 624 条,A 415 → 418,链接 1304 → 1321,引用 578 → 585 处。
mikomiel added a commit that referenced this pull request Nov 9, 2026
Preserve README description line boundaries, ignore raw-block escape bypasses, and encode token-derived EPUB navigation labels. Add real-exporter synthetic fixtures and offline HTML/XML regressions with the existing pinned parser as a test dependency. No Polish reader, publication files or workflow changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant