From 760593d4a02e9fffa56dc4d002eb52ab2ade1b49 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 13:37:13 -0500 Subject: [PATCH] feat(authoring): add portable document preflight --- README.md | 63 ++++- javascript/bin/portable-authoring.js | 90 ++++++ javascript/package.json | 18 +- javascript/portable-authoring.d.ts | 52 ++++ javascript/portable-authoring.js | 395 +++++++++++++++++++++++++++ javascript/test.js | 85 ++++++ package-lock.json | 3 + package.json | 18 +- 8 files changed, 718 insertions(+), 6 deletions(-) create mode 100755 javascript/bin/portable-authoring.js create mode 100644 javascript/portable-authoring.d.ts create mode 100644 javascript/portable-authoring.js diff --git a/README.md b/README.md index fe1795e..87260c4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ Status: `0.3.0` release candidate for `htmltrust-c14n-v1` Previous protocol release: `v0.2.2` (`79b0d52fecd958f8fc7ade713fe0799ca1e79626`) Readers: binding users and contributors +## Standalone prerequisites + +The Docker test path needs Git and Docker Engine with Compose. Running a +binding directly also needs the toolchain listed for that binding below. + ## Test a fresh checkout Docker is the shortest path to a complete result. This command installs each @@ -54,12 +59,64 @@ node --input-type=module -e \ 'import { normalizeText } from "./javascript/index.js"; console.log(normalizeText("A—B"))' ``` -During the `0.3.0` review, another project can install the current main branch: +For a reproducible install in another project, use a published release tag or +a full SHA that the project has reviewed. Do not install a moving branch. To +resolve a reviewed tag before its SHA is known, inspect it and pin the result: ```sh -npm install github:HTMLTrust/htmltrust-canonicalization#main +CANON_URL=https://github.com/HTMLTrust/htmltrust-canonicalization.git +CANON_REF=REPLACE_WITH_REVIEWED_TAG +CANON_SHA="$(git ls-remote "$CANON_URL" "refs/tags/$CANON_REF" | awk 'NR==1 {print $1}')" +test "$CANON_SHA" && test "${#CANON_SHA}" -eq 40 +npm install "github:HTMLTrust/htmltrust-canonicalization#$CANON_SHA" ``` +Review the resolved commit before release. A full reviewed SHA can be assigned +directly to `CANON_SHA`. + +#### Preflight a complete HTML document + +The portable-authoring module finds every `` in a complete +document, resolves the final response URL and first ``, then +runs the v1 fragment checks for each region. It returns JSON with a pass/fail +status, source offsets, canonical content, claims, and stable diagnostic codes. + +From a checkout: + +```sh +npm ci +node javascript/bin/portable-authoring.js \ + --url https://example.org/articles/example.html \ + article.html +``` + +The command exits `0` when at least one signed region is present and every +region passes. It exits `1` when a region fails or no region is found. Base +URL problems include a warning. A malformed, `data:`, or `javascript:` first +base falls back to the final response URL. Other first-base values remain the +document base, so a relative signed URL resolved to HTTP fails the HTMLTrust +URL profile. Later base elements are ignored. The JSON `hint`, +`context`, and `location` fields identify the source change needed by an +authoring tool. + +The same helper is available to JavaScript consumers: + +```js +import { + preflightPortableDocument, + wrapSignedSection, +} from "@htmltrust/canonicalization/portable-authoring"; + +const result = preflightPortableDocument(html, { + documentURL: "https://example.org/articles/example.html", +}); +const signedFragment = wrapSignedSection("

Ready to sign.

"); +``` + +`wrapSignedSection` accepts a well-formed fragment and verifies that wrapping +preserves canonical content and claims. It rejects document containers and an +existing signed section, because those inputs need an author decision. + ### Go ```sh @@ -179,6 +236,8 @@ behavior. Related repositories: - [HTMLTrust specification](https://github.com/HTMLTrust/htmltrust-spec) +- [Hugo integration](../htmltrust-hugo/) +- [Study 1 reproduction harness](../htmltrust-study1/) - [Reference server](https://github.com/HTMLTrust/htmltrust-server-reference) - [Reference browser extension](https://github.com/HTMLTrust/htmltrust-browser-reference) - [Reference CMS plugins](https://github.com/HTMLTrust/htmltrust-cms-reference) diff --git a/javascript/bin/portable-authoring.js b/javascript/bin/portable-authoring.js new file mode 100755 index 0000000..31f0b25 --- /dev/null +++ b/javascript/bin/portable-authoring.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { + preflightPortableDocument, + wrapSignedSection, +} from "../portable-authoring.js"; + +function usage() { + console.error("Usage: htmltrust-portable-preflight --url https://example.test/page.html [--wrap] [file|-]"); + console.error(" Reads UTF-8 HTML from file, or stdin when file is omitted or '-'."); +} + +const args = process.argv.slice(2); +let documentURL = null; +let inputPath = "-"; +let wrap = false; + +for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") { + usage(); + process.exit(0); + } + if (arg === "--wrap") { + wrap = true; + continue; + } + if (arg === "--url") { + documentURL = args[++index]; + continue; + } + if (arg.startsWith("--")) { + usage(); + process.exit(2); + } + if (inputPath !== "-") { + usage(); + process.exit(2); + } + inputPath = arg; +} + +if (!wrap && !documentURL) { + usage(); + process.exit(2); +} + +let html; +try { + html = inputPath === "-" ? readFileSync(0, "utf8") : readFileSync(inputPath, "utf8"); +} catch (error) { + console.log(JSON.stringify({ + ok: false, + diagnostics: [{ + code: "input-read-failed", + severity: "error", + message: String(error?.message || error), + hint: "Check the input path and read permissions.", + region: null, + context: { inputPath }, + }], + })); + process.exit(1); +} + +if (wrap) { + try { + console.log(JSON.stringify({ ok: true, html: wrapSignedSection(html) })); + process.exit(0); + } catch (error) { + const code = error?.code || "conversion-ambiguous"; + console.log(JSON.stringify({ + ok: false, + diagnostics: [{ + code, + severity: "error", + message: String(error?.message || code), + hint: "Use an unambiguous, well-formed fragment and preserve its original source.", + region: null, + context: error?.context || {}, + }], + })); + process.exit(1); + } +} + +const result = preflightPortableDocument(html, { documentURL }); +console.log(JSON.stringify(result)); +process.exit(result.ok ? 0 : 1); diff --git a/javascript/package.json b/javascript/package.json index e5d2d68..c685dd7 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -4,16 +4,30 @@ "description": "HTMLTrust canonical text normalization, signature verification, and key resolution for browsers and Node.js", "type": "module", "main": "index.js", + "scripts": { + "test": "node test.js", + "portable-preflight": "node bin/portable-authoring.js" + }, "engines": { "node": ">=22" }, "exports": { - ".": "./index.js" + ".": "./index.js", + "./portable-authoring": { + "types": "./portable-authoring.d.ts", + "import": "./portable-authoring.js" + } + }, + "bin": { + "htmltrust-portable-preflight": "bin/portable-authoring.js" }, "files": [ "index.js", "index.d.ts", - "entities.js" + "entities.js", + "portable-authoring.js", + "portable-authoring.d.ts", + "bin/portable-authoring.js" ], "keywords": [ "htmltrust", diff --git a/javascript/portable-authoring.d.ts b/javascript/portable-authoring.d.ts new file mode 100644 index 0000000..f282e36 --- /dev/null +++ b/javascript/portable-authoring.d.ts @@ -0,0 +1,52 @@ +export interface PortableAuthoringLocation { + startOffset: number; + endOffset: number | null; + startLine: number; + startColumn: number; + endLine: number | null; + endColumn: number | null; + innerStartOffset: number; + innerEndOffset: number | null; + hasEndTag: boolean; +} + +export interface PortableAuthoringDiagnostic { + code: string; + severity: "error" | "warning"; + message: string; + hint: string; + region: number | null; + context: Record; +} + +export interface PortableAuthoringRegion { + index: number; + status: "pass" | "fail"; + location: PortableAuthoringLocation | null; + baseURL?: string; + canonicalText?: string; + canonicalClaims?: string; + claims?: Record; + diagnostics: PortableAuthoringDiagnostic[]; +} + +export interface PortableAuthoringResult { + profile: "htmltrust-portable-authoring-v1"; + ok: boolean; + documentURL: string | null; + baseURL: string | null; + diagnostics: PortableAuthoringDiagnostic[]; + regions: PortableAuthoringRegion[]; +} + +/** Preflight every signed-section in a complete HTML document. */ +export function preflightPortableDocument( + html: string, + options: { documentURL: string }, +): PortableAuthoringResult; + +/** Wrap a well-formed fragment without changing its v1 canonical output. */ +export function wrapSignedSection( + html: string, + options?: { baseUrl?: string }, +): string; diff --git a/javascript/portable-authoring.js b/javascript/portable-authoring.js new file mode 100644 index 0000000..317ee3e --- /dev/null +++ b/javascript/portable-authoring.js @@ -0,0 +1,395 @@ +/** + * Portable authoring helpers for complete HTML documents. + * + * The canonicalizer intentionally accepts an HTML fragment and requires its + * caller to supply the resolved document base URL. This module is the small + * source-snapshot layer that connects those two jobs for authors and tools. + */ + +import * as parse5 from "parse5"; +import { + normalizeText, + extractCanonicalText, + extractClaimsFromSignedSection, + canonicalizeClaims, +} from "./index.js"; + +const MAX_RESOURCE_BYTES = 1024 * 1024; +const MAX_CLAIMS = 64; +const MAX_CLAIM_FIELD_BYTES = 4096; +const SAFE_URL_PROTOCOL = "https:"; + +const DIAGNOSTIC_HINTS = Object.freeze({ + "document-url-invalid": "Pass the final HTTPS response URL, without credentials.", + "base-invalid": "Fix the base href or remove it so the final response URL is used.", + "signed-section-not-found": "Add a signed-section element to the document.", + "signed-section-unclosed": "Close the signed-section element before signing.", + "parser-profile-unsupported": "Use well-formed HTML in the HTMLTrust v1 portable profile.", + "resource-limit-exceeded": "Reduce the document, region, or field size before signing.", + "url-policy-violation": "Use an HTTPS URL without credentials in signed URL attributes.", + "attribute-canonicalization-failed": "Fix the signed href or src attribute and its base URL.", + "claim-malformed": "Give every direct claim meta element both name and content attributes.", + "claim-duplicate": "Keep one direct claim meta element for each normalized claim name.", + "conversion-ambiguous": "Wrap a fragment without document containers or an existing signed-section.", + "conversion-lossy": "Keep the original source and resolve the canonicalization difference manually.", + "document-parser-recovered": "Inspect document-level markup outside the signed regions before publishing.", +}); + +const KNOWN_CODES = Object.keys(DIAGNOSTIC_HINTS); + +function utf8Length(value) { + return new TextEncoder().encode(value).byteLength; +} + +function fail(code, message, context = {}) { + const error = new Error(message || code); + error.code = code; + error.context = context; + return error; +} + +function safeURL(raw, code) { + if (typeof raw !== "string" || !raw) throw fail(code, "URL is required"); + if (/[\u0000-\u001F\u007F]/u.test(raw)) throw fail(code, "URL contains control characters"); + let url; + try { + url = new URL(raw); + } catch { + throw fail(code, "URL is malformed"); + } + if (url.protocol !== SAFE_URL_PROTOCOL || url.username || url.password) { + throw fail(code, "URL must be HTTPS and contain no credentials"); + } + return url.href; +} + +function attrsFor(node) { + const attrs = new Map(); + for (const attr of node.attrs || []) attrs.set(attr.name.toLowerCase(), attr.value); + return attrs; +} + +function walk(node, callback) { + for (const child of node.childNodes || []) { + callback(child); + walk(child, callback); + } +} + +function sourceLocation(node) { + const location = node.sourceCodeLocation; + if (!location?.startTag) return null; + const endTag = location.endTag; + return { + startOffset: location.startTag.startOffset, + endOffset: endTag?.endOffset ?? location.endOffset ?? null, + startLine: location.startTag.startLine, + startColumn: location.startTag.startCol, + endLine: endTag?.endLine ?? location.endLine ?? null, + endColumn: endTag?.endCol ?? location.endCol ?? null, + innerStartOffset: location.startTag.endOffset, + innerEndOffset: endTag?.startOffset ?? null, + hasEndTag: Boolean(endTag), + }; +} + +function resolveBase(document, finalURL) { + const candidates = []; + walk(document, (node) => { + if (node.tagName?.toLowerCase() !== "base") return; + const attrs = attrsFor(node); + if (!attrs.has("href")) return; + const href = attrs.get("href"); + const location = sourceLocation(node); + try { + candidates.push({ + href, + url: new URL(href, finalURL), + location, + }); + } catch { + candidates.push({ href, location, error: "malformed" }); + } + }); + + const first = candidates[0]; + if (!first) return { baseURL: finalURL, diagnostics: [] }; + + // The HTML Standard uses the first base element with an href in tree order. + // Later base elements never repair an invalid first one. A base URL that + // cannot be used falls back to the document URL. + if (first.error || /[\u0000-\u001F\u007F]/u.test(first.href)) { + return { + baseURL: finalURL, + diagnostics: [diagnostic("base-invalid", { + message: "The first base href could not be resolved; the final response URL is used.", + context: { href: first.href, location: first.location }, + })], + }; + } + if ( + first.url.protocol === "data:" || + first.url.protocol === "javascript:" + ) { + return { + baseURL: finalURL, + diagnostics: [diagnostic("base-invalid", { + message: "The first base href cannot become a document base; the final response URL is used.", + context: { href: first.href, location: first.location }, + })], + }; + } + const diagnostics = []; + if ( + first.url.protocol !== SAFE_URL_PROTOCOL || + first.url.username || + first.url.password + ) { + diagnostics.push(diagnostic("base-invalid", { + message: "The document base is outside the HTMLTrust HTTPS profile; relative signed URLs will fail preflight.", + context: { href: first.href, location: first.location }, + })); + } + return { baseURL: first.url.href, diagnostics }; +} + +function diagnostic(code, { message, region = null, context = {} } = {}) { + return { + code, + severity: "error", + message: message || code, + hint: DIAGNOSTIC_HINTS[code] || "Inspect the source at the reported location.", + region, + context, + }; +} + +function warningDiagnostic(code, args) { + return { ...diagnostic(code, args), severity: "warning" }; +} + +function errorCode(error) { + if (error?.code && KNOWN_CODES.includes(error.code)) return error.code; + const message = String(error?.message || error || ""); + return KNOWN_CODES.find((code) => message === code || message.startsWith(`${code}:`)) || + "parser-profile-unsupported"; +} + +function errorContext(error) { + const message = String(error?.message || ""); + const context = {}; + if (message.startsWith("attribute-canonicalization-failed:")) { + const [, attribute] = message.split(":", 2); + context.attribute = attribute; + } + if (message.startsWith("claim-duplicate:")) context.claimName = message.slice("claim-duplicate:".length).trim(); + if (error?.context) Object.assign(context, error.context); + return context; +} + +function parseDocument(html) { + const parseErrors = []; + const document = parse5.parse(html, { + sourceCodeLocationInfo: true, + onParseError(error) { parseErrors.push(error); }, + }); + return { document, parseErrors }; +} + +function signedSections(document) { + const sections = []; + walk(document, (node) => { + if (node.tagName?.toLowerCase() === "signed-section") sections.push(node); + }); + return sections; +} + +function regionResult(node, index, html, baseURL) { + const location = sourceLocation(node); + if (!location?.hasEndTag || location.innerEndOffset == null) { + return { + index, + status: "fail", + location, + diagnostics: [diagnostic("signed-section-unclosed", { + region: index, + context: { location }, + })], + }; + } + const innerHTML = html.slice(location.innerStartOffset, location.innerEndOffset); + try { + const canonicalText = extractCanonicalText(innerHTML, { baseUrl: baseURL }); + // The full-document AST already identifies this exact section. Extracting + // from innerHTML would select a nested section's claims for the outer + // region, so use only this node's direct children. + const claims = extractDirectClaimsFromParsedSection(node); + const canonicalClaims = canonicalizeClaims(claims); + return { + index, + status: "pass", + location, + baseURL, + canonicalText, + claims, + canonicalClaims, + diagnostics: [], + }; + } catch (error) { + const code = errorCode(error); + return { + index, + status: "fail", + location, + baseURL, + diagnostics: [diagnostic(code, { + region: index, + message: String(error?.message || code), + context: { ...errorContext(error), location }, + })], + }; + } +} + +function extractDirectClaimsFromParsedSection(section) { + const claims = {}; + const seen = new Set(); + for (const child of section.childNodes || []) { + if (child.tagName?.toLowerCase() !== "meta") continue; + const attrs = attrsFor(child); + if (!attrs.has("name") || !attrs.has("content")) throw new Error("claim-malformed"); + const claimName = normalizeText(attrs.get("name")).trim(); + const content = normalizeText(attrs.get("content")).trim(); + if (!claimName) throw new Error("claim-malformed"); + if (seen.size >= MAX_CLAIMS) throw new Error("resource-limit-exceeded"); + if (utf8Length(claimName) > MAX_CLAIM_FIELD_BYTES || utf8Length(content) > MAX_CLAIM_FIELD_BYTES) { + throw new Error("resource-limit-exceeded"); + } + if (seen.has(claimName)) throw new Error(`claim-duplicate: ${claimName}`); + seen.add(claimName); + claims[claimName] = content; + } + return claims; +} + +/** + * Discover and preflight every signed-section in a complete HTML document. + * + * `documentURL` is the final response URL. The first `` in tree + * order is used. Opaque or malformed first bases fall back to the final URL; + * later base elements are ignored. Unsafe HTTP/credential-bearing bases are + * retained and cause relative signed URLs to fail the v1 URL policy. + */ +export function preflightPortableDocument(html, { documentURL } = {}) { + if (typeof html !== "string") throw new TypeError("preflightPortableDocument expects a string"); + if (utf8Length(html) > MAX_RESOURCE_BYTES) { + return { + profile: "htmltrust-portable-authoring-v1", + ok: false, + documentURL: documentURL ?? null, + baseURL: null, + diagnostics: [diagnostic("resource-limit-exceeded")], + regions: [], + }; + } + + let finalURL; + try { + finalURL = safeURL(documentURL, "document-url-invalid"); + } catch (error) { + return { + profile: "htmltrust-portable-authoring-v1", + ok: false, + documentURL: documentURL ?? null, + baseURL: null, + diagnostics: [diagnostic(errorCode(error), { context: errorContext(error) })], + regions: [], + }; + } + + const { document, parseErrors } = parseDocument(html); + const base = resolveBase(document, finalURL); + const sections = signedSections(document); + const regions = sections.map((node, index) => regionResult(node, index, html, base.baseURL)); + const diagnostics = base.diagnostics.map((entry) => ({ ...entry, severity: "warning" })); + if (!regions.length) diagnostics.push(diagnostic("signed-section-not-found")); + // Full-document parser errors outside a region do not change the fragment + // bytes being signed. Keep them observable without making valid regions + // fail because of unrelated page chrome. + if (parseErrors.length) { + diagnostics.push(warningDiagnostic("document-parser-recovered", { + message: "The HTML parser recovered from one or more document-level issues outside signed regions.", + context: { count: parseErrors.length }, + })); + } + return { + profile: "htmltrust-portable-authoring-v1", + ok: regions.length > 0 && regions.every((region) => region.status === "pass"), + documentURL: finalURL, + baseURL: base.baseURL, + diagnostics, + regions, + }; +} + +function containsElement(fragment, names) { + let found = false; + walk(fragment, (node) => { + if (names.has(node.tagName?.toLowerCase())) found = true; + }); + return found; +} + +function hasExplicitDocumentContainer(source) { + // Text inside comments and excluded raw-text elements is data, not a + // document container. Remove those bodies before checking source that + // parseFragment intentionally treats as fragment content. + const markup = source + .replace(//g, "") + .replace(/<(?:script|style|iframe)\b[\s\S]*?<\/(?:script|style|iframe)\s*>/gi, ""); + return / MAX_RESOURCE_BYTES) throw fail("resource-limit-exceeded", "source exceeds the v1 limit"); + const parseErrors = []; + const fragment = parse5.parseFragment(html, { + sourceCodeLocationInfo: true, + onParseError(error) { parseErrors.push(error); }, + }); + if ( + parseErrors.length || + hasExplicitDocumentContainer(html) || + containsElement(fragment, new Set(["html", "head", "body", "signed-section"])) + ) { + throw fail("conversion-ambiguous", "fragment contains a document container or signed-section"); + } + let beforeText; + let beforeClaims; + try { + beforeText = extractCanonicalText(html, { baseUrl: options.baseUrl }); + beforeClaims = extractClaimsFromSignedSection(html); + } catch (error) { + const code = errorCode(error); + throw fail(code, String(error?.message || code), errorContext(error)); + } + const wrapped = `${html}`; + try { + const afterText = extractCanonicalText(wrapped, { baseUrl: options.baseUrl }); + const afterClaims = extractClaimsFromSignedSection(wrapped); + if (afterText !== beforeText || JSON.stringify(afterClaims) !== JSON.stringify(beforeClaims)) { + throw fail("conversion-lossy", "wrapping changed canonical content or claims"); + } + } catch (error) { + if (error.code === "conversion-lossy") throw error; + const code = errorCode(error); + throw fail(code, String(error?.message || code), errorContext(error)); + } + return wrapped; +} diff --git a/javascript/test.js b/javascript/test.js index 3910bff..51808a8 100644 --- a/javascript/test.js +++ b/javascript/test.js @@ -19,6 +19,10 @@ import { canonicalizeJson, canonicalizeJsonDocument, } from './index.js'; +import { + preflightPortableDocument, + wrapSignedSection, +} from './portable-authoring.js'; import * as nodeCrypto from 'node:crypto'; import { generateKeyPairSync, sign as nodeSign, createHash } from 'node:crypto'; import { createServer } from 'node:http'; @@ -810,6 +814,87 @@ await check('end-to-end test vector reproduces hashes, payload, and signature', ); }); +await check('portable authoring preflight discovers regions and follows first-base semantics', () => { + const result = preflightPortableDocument(` + + + + +

Hello

+

Second

+ + `, { documentURL: 'https://example.org/articles/page.html' }); + assert(result.ok, 'valid regions should pass'); + assertEq(result.baseURL, 'https://example.org/assets/'); + assertEq(result.regions.length, 2); + assertEq(result.regions[0].canonicalText, '@attr:a:href:https://example.org/assets/story\nHello'); + assertEq(result.regions[0].canonicalClaims, 'author:Ada\n'); + assertEq(result.regions[1].canonicalText, 'Second'); + assertEq(result.diagnostics.length, 0); +}); + +await check('portable authoring does not skip an unsafe first base', () => { + const result = preflightPortableDocument(` + + + Hello + `, { documentURL: 'https://example.org/articles/page.html' }); + assert(!result.ok, 'relative signed URL under an HTTP document base must fail'); + assertEq(result.baseURL, 'http://unsafe.example/'); + assertEq(result.diagnostics[0].code, 'base-invalid'); + assertEq(result.diagnostics[0].severity, 'warning'); + assertEq(result.regions[0].diagnostics[0].code, 'url-policy-violation'); +}); + +await check('portable authoring keeps direct claims with their own nested section', () => { + const result = preflightPortableDocument( + '

inner

', + { documentURL: 'https://example.org/page.html' }, + ); + assert(result.ok, 'nested sections should be independently preflightable'); + assertEq(result.regions.length, 2); + assertEq(result.regions[0].canonicalClaims, 'author:outer\nlicense:CC-BY\n'); + assertEq(result.regions[1].canonicalClaims, 'author:inner\n'); +}); + +await check('portable authoring enforces the complete-document byte ceiling', () => { + const opening = ''; + const section = '

x

'; + const closing = ''; + const exact = opening + section + 'x'.repeat(1024 * 1024 - new TextEncoder().encode(opening + section + closing).byteLength) + closing; + assertEq(new TextEncoder().encode(exact).byteLength, 1024 * 1024); + assert(preflightPortableDocument(exact, { documentURL: 'https://example.org/page.html' }).ok, 'exact-limit document should pass'); + const over = opening + section + 'x'.repeat(1024 * 1024 - new TextEncoder().encode(opening + section + closing).byteLength + 1) + closing; + const rejected = preflightPortableDocument(over, { documentURL: 'https://example.org/page.html' }); + assert(!rejected.ok, 'over-limit document must fail'); + assertEq(rejected.diagnostics[0].code, 'resource-limit-exceeded'); +}); + +await check('portable authoring keeps region failures isolated and actionable', () => { + const result = preflightPortableDocument( + '

bad base

good

', + { documentURL: 'https://example.org/page.html' }, + ); + assert(!result.ok, 'a failing region must fail the document'); + assertEq(result.regions[0].status, 'fail'); + assertEq(result.regions[0].diagnostics[0].code, 'url-policy-violation'); + assert(result.regions[0].diagnostics[0].context.location, 'source location context is required'); + assertEq(result.regions[1].status, 'pass'); +}); + +await check('portable authoring wraps only equivalent fragments', () => { + const fragment = '

Hello & goodbye

'; + assertEq(wrapSignedSection(fragment), `${fragment}`); + assertEq(wrapSignedSection('

'), '

'); + for (const ambiguous of ['text', '

text

']) { + let threw = false; + try { wrapSignedSection(ambiguous); } catch (error) { + threw = error.code === 'conversion-ambiguous'; + } + assert(threw, 'ambiguous wrapping input must be rejected'); + } +}); + await new Promise((r) => fixtureServer.close(r)); console.log(`\n${passed} passed, ${failed} failed\n`); diff --git a/package-lock.json b/package-lock.json index c510ae2..56edb31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,9 @@ "dependencies": { "parse5": "7.3.0" }, + "bin": { + "htmltrust-portable-preflight": "javascript/bin/portable-authoring.js" + }, "engines": { "node": ">=22" } diff --git a/package.json b/package.json index e552ab6..6e461c8 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,30 @@ "type": "module", "main": "javascript/index.js", "exports": { - ".": "./javascript/index.js" + ".": "./javascript/index.js", + "./portable-authoring": { + "types": "./javascript/portable-authoring.d.ts", + "import": "./javascript/portable-authoring.js" + } }, "types": "javascript/index.d.ts", + "scripts": { + "test": "node javascript/test.js", + "portable-preflight": "node javascript/bin/portable-authoring.js" + }, + "bin": { + "htmltrust-portable-preflight": "javascript/bin/portable-authoring.js" + }, "engines": { "node": ">=22" }, "files": [ "javascript/index.js", "javascript/index.d.ts", - "javascript/entities.js" + "javascript/entities.js", + "javascript/portable-authoring.js", + "javascript/portable-authoring.d.ts", + "javascript/bin/portable-authoring.js" ], "keywords": [ "htmltrust",