feat: integrate XSLT Tools with toolbox portal

This commit is contained in:
2026-07-20 18:16:18 +02:00
parent 3193cc395c
commit 8f8a59e76c
36 changed files with 1737 additions and 243 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Layout from './components/Layout';
import { AppShell } from '@add-ideas/toolbox-shell-react';
import Toolbar from './components/Toolbar';
import EditorPanel from './components/EditorPanel';
import ActionDialog, {
@@ -19,8 +19,14 @@ import {
} from './validation/validationTypes';
import { runTransformation } from './transform/transformService';
import { createTransformationRun } from './transform/nativeXsltEngine';
import { createSha256Hash } from './transform/hash';
import {
isStylesheetExecutionTrusted,
type TrustedStylesheet,
} from './transform/stylesheetTrust';
import type { TransformEngineId } from './transform/transformTypes';
import { createApproximateTrace } from './transform/traceAnalyzer';
import { toolboxApp } from './toolboxApp';
interface DialogState {
title: string;
@@ -52,6 +58,19 @@ const App: React.FC = () => {
DiagnosticMessage[]
>([]);
const [validationTouched, setValidationTouched] = useState(false);
const [trustedStylesheet, setTrustedStylesheet] =
useState<TrustedStylesheet | null>(null);
const stylesheetRevisionRef = useRef(0);
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const invalidateStylesheetTrust = () => {
stylesheetRevisionRef.current += 1;
setTrustedStylesheet(null);
};
const xmlDiagnostics = useMemo(
() => validateXml(state.xmlInput.text, 'XML input'),
@@ -161,6 +180,7 @@ const App: React.FC = () => {
}
replaceDocument(kind, opened.text, opened.name, false);
if (kind === 'xsltCode') invalidateStylesheetTrust();
setStatusMessage(`Loaded ${opened.name}.`);
} catch (error) {
if (isAbortError(error)) return;
@@ -203,6 +223,16 @@ const App: React.FC = () => {
return;
}
if (
!trustedStylesheet ||
trustedStylesheet.revision !== stylesheetRevisionRef.current
) {
setStatusMessage(
'Review and trust this exact stylesheet before transforming.'
);
return;
}
if (state.options.askBeforeOverwritingOutput) {
const mayOverwriteOutput = await confirmOverwrite(
'xmlOutput',
@@ -213,13 +243,33 @@ const App: React.FC = () => {
setBusy(true);
try {
const currentRevision = stylesheetRevisionRef.current;
const currentState = stateRef.current;
const currentHash = await createSha256Hash(currentState.xsltCode.text);
if (
currentRevision !== stylesheetRevisionRef.current ||
!isStylesheetExecutionTrusted(
trustedStylesheet,
currentHash,
currentRevision
)
) {
setTrustedStylesheet(null);
setStatusMessage(
'The stylesheet changed. Review and trust it again before transforming.'
);
return;
}
const request = {
xmlText: state.xmlInput.text,
xsltText: state.xsltCode.text,
engine: state.selectedEngine,
xmlText: currentState.xmlInput.text,
xsltText: currentState.xsltCode.text,
engine: currentState.selectedEngine,
};
const result = await runTransformation(request);
const result = await runTransformation(request, {
trustedStylesheet,
currentRevision,
});
setRuntimeDiagnostics(result.diagnostics);
if (hasErrors(result.diagnostics)) {
@@ -229,11 +279,16 @@ const App: React.FC = () => {
return;
}
const output = state.options.prettifyOutputAfterTransform
const output = currentState.options.prettifyOutputAfterTransform
? tryFormatXml(result.output)
: result.output;
replaceDocument('xmlOutput', output, state.xmlOutput.fileName, true);
replaceDocument(
'xmlOutput',
output,
currentState.xmlOutput.fileName,
true
);
const run = await createTransformationRun(request, output);
setLastTransformation(run);
@@ -253,6 +308,66 @@ const App: React.FC = () => {
}
};
const handleTrustStylesheet = async () => {
const revision = stylesheetRevisionRef.current;
const stylesheetText = stateRef.current.xsltCode.text;
try {
const hash = await createSha256Hash(stylesheetText);
const confirmed = await askForConfirmation({
title: 'Trust this executable stylesheet?',
content: (
<>
<p>
XSLT is executable code. Both engines may access browser or
network resources. Only continue if you trust the source of this
exact stylesheet.
</p>
<p>
A trusted SaxonJS stylesheet may access data stored by other
Toolbox apps on this same site, including PDF workspaces and
portal preferences. It may send that data over the network or
navigate this page away.
</p>
<p>
Size limits and a best-effort asynchronous deadline reduce
accidental overload, but the engines do not provide complete
process or network isolation. Synchronous work cannot be forcibly
stopped. Any edit, opened replacement, formatting change, or reset
revokes trust.
</p>
</>
),
proceedLabel: 'Trust this exact stylesheet',
danger: true,
});
if (!confirmed) {
setStatusMessage('Stylesheet remains untrusted.');
return;
}
if (
revision !== stylesheetRevisionRef.current ||
stylesheetText !== stateRef.current.xsltCode.text
) {
setTrustedStylesheet(null);
setStatusMessage(
'The stylesheet changed during review. Review it again.'
);
return;
}
setTrustedStylesheet({ hash, revision });
setStatusMessage(
'This exact stylesheet is trusted for the current session.'
);
} catch (error) {
setTrustedStylesheet(null);
setStatusMessage(
error instanceof Error
? error.message
: 'Could not fingerprint the stylesheet.'
);
}
};
const handleMoveOutputToInput = async () => {
if (!state.xmlOutput.text.trim()) {
setStatusMessage('The output editor is empty. Nothing to move.');
@@ -292,6 +407,7 @@ const App: React.FC = () => {
if (!mayProceed) return;
replaceDocument(kind, formatted, documentState.fileName, true);
if (kind === 'xsltCode') invalidateStylesheetTrust();
setStatusMessage(`Formatted ${documentState.label}.`);
} catch (error) {
setStatusMessage(
@@ -336,6 +452,7 @@ const App: React.FC = () => {
if (!mayProceed) return;
resetWorkspace();
invalidateStylesheetTrust();
setRuntimeDiagnostics([]);
setValidationTouched(false);
setStatusMessage('Workspace reset.');
@@ -402,7 +519,8 @@ const App: React.FC = () => {
event.stopPropagation();
if (!busy) {
void handleApplyTransformation();
if (trustedStylesheet) void handleApplyTransformation();
else void handleTrustStylesheet();
}
}
};
@@ -412,10 +530,25 @@ const App: React.FC = () => {
return () => {
window.removeEventListener('keydown', handleGlobalKeyDown, true);
};
}, [busy, handleApplyTransformation, handleOpenHelp]);
});
return (
<Layout onOpenHelp={() => handleOpenHelp()}>
<AppShell
app={toolboxApp}
className="xslt-app-shell"
appActions={
<button
type="button"
className="app-help-button"
onClick={handleOpenHelp}
aria-haspopup="dialog"
title="Open help and keyboard shortcuts (F1)"
aria-label="Open help"
>
<i className="bi bi-question-circle" aria-hidden="true" /> Help
</button>
}
>
<div className="workspace-grid">
<EditorPanel
ref={xmlInputEditorRef}
@@ -443,7 +576,10 @@ const App: React.FC = () => {
fileName={state.xsltCode.fileName}
dirty={state.xsltCode.dirty}
diagnostics={xsltDiagnostics}
onChange={(value) => updateDocumentText('xsltCode', value)}
onChange={(value) => {
invalidateStylesheetTrust();
updateDocumentText('xsltCode', value);
}}
onOpen={() => void handleOpenFile('xsltCode')}
onSave={() => void handleSaveFile('xsltCode')}
onUndo={() => xsltEditorRef.current?.undo()}
@@ -487,10 +623,13 @@ const App: React.FC = () => {
selectedEngine={state.selectedEngine}
onEngineChange={handleEngineChange}
onApplyTransformation={() => void handleApplyTransformation()}
onTrustStylesheet={() => void handleTrustStylesheet()}
onMoveOutputToInput={() => void handleMoveOutputToInput()}
onValidate={handleValidate}
onReset={() => void handleReset()}
prettifyOutputAfterTransform={state.options.prettifyOutputAfterTransform}
prettifyOutputAfterTransform={
state.options.prettifyOutputAfterTransform
}
onPrettifyOutputAfterTransformChange={
handlePrettifyOutputAfterTransformChange
}
@@ -499,6 +638,7 @@ const App: React.FC = () => {
handleAskBeforeOverwritingOutputChange
}
busy={busy}
stylesheetTrusted={trustedStylesheet !== null}
/>
{statusMessage && <div className="status-banner">{statusMessage}</div>}
@@ -520,7 +660,6 @@ const App: React.FC = () => {
</section>
)}
<section className="trace-panel card">
<div className="trace-header">
<div>
@@ -586,13 +725,8 @@ const App: React.FC = () => {
{dialog?.content}
</ActionDialog>
{helpOpen && (
<HelpDialog
open={helpOpen}
onClose={handleCloseHelp}
/>
)}
</Layout>
{helpOpen && <HelpDialog open={helpOpen} onClose={handleCloseHelp} />}
</AppShell>
);
};

View File

@@ -14,7 +14,7 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<div className="help-dialog-header">
<div>
<h2>XSLT tools help</h2>
<p>Local-only XML/XSLT transformation workbench.</p>
<p>Browser XML/XSLT transformation workbench.</p>
</div>
<button type="button" className="icon-button" onClick={onClose}>
×
@@ -32,9 +32,25 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<h3>Transformation engine</h3>
<p>
The first version used the browser-native <code>XSLTProcessor</code>
, which is suitable for XSLT 1.0. This engine likely is to be retired
in late 2026. For XSLT 3.0, a SaxonJS engine was added that fully runs
in the browser (JS-only).
, which is suitable for XSLT 1.0. This engine likely is to be
retired in late 2026. For XSLT 3.0, a SaxonJS engine was added that
fully runs in the browser (JS-only).
</p>
<h3>Executable stylesheet safety</h3>
<p>
XSLT is executable code. Before either engine can run, you must
explicitly trust the SHA-256 fingerprint of the exact current
stylesheet. Every edit revokes that trust. Run only stylesheets from
sources you trust.
</p>
<p>
A trusted SaxonJS stylesheet may read same-site data belonging to
other Toolbox apps, including PDF workspaces and portal preferences,
then send it over the network or navigate this page away. Input and
output limits plus a best-effort asynchronous deadline reduce
accidental overload, but transformations are not isolated and
synchronous work cannot be forcibly stopped.
</p>
<h3>Keyboard shortcuts</h3>
@@ -43,8 +59,8 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Z</kbd>: undo
</li>
<li>
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd> or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>:
redo
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Z</kbd>{' '}
or <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Y</kbd>: redo
</li>
<li>
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>F</kbd>: find in the active
@@ -60,8 +76,12 @@ const HelpDialog: React.FC<HelpDialogProps> = ({ open, onClose }) => {
<h3>Privacy</h3>
<p>
No upload code is included. Files are read locally by the browser
and the workspace is stored in your browser storage.
The application itself does not intentionally upload opened files
and stores the workspace in browser storage. A stylesheet you choose
to trust and execute may nevertheless access same-site data, request
external resources, or navigate away, so processing is
conservatively classified as mixed rather than guaranteed
local-only.
</p>
</div>
</div>

View File

@@ -1,50 +0,0 @@
import React from 'react';
import { APP_VERSION } from '../version';
interface LayoutProps {
children: React.ReactNode;
onOpenHelp?: () => void;
}
const Layout: React.FC<LayoutProps> = ({ children, onOpenHelp }) => {
return (
<div className="app-root">
<header className="app-header">
<div className="app-header-content">
<div className="app-header-title">
<span className="app-logo" aria-hidden="true">
</span>
<div>
<h1>XSLT tools</h1>
<small>All transformations stay in your browser</small>
</div>
</div>
<div className="app-header-actions">
{onOpenHelp && (
<button
type="button"
className="app-help-button"
onClick={onOpenHelp}
aria-haspopup="dialog"
title="Open help and keyboard shortcuts (F1)"
aria-label="Open help"
>
<i className="bi bi-question-circle" aria-hidden="true" /> Help
</button>
)}
<div className="app-version" title={`Version ${APP_VERSION}`}>
v{APP_VERSION}
</div>
</div>
</div>
</header>
<main className="app-main">{children}</main>
</div>
);
};
export default Layout;

View File

@@ -6,6 +6,7 @@ interface ToolbarProps {
selectedEngine: TransformEngineId;
onEngineChange: (engine: TransformEngineId) => void;
onApplyTransformation: () => void;
onTrustStylesheet: () => void;
onMoveOutputToInput: () => void;
onValidate: () => void;
onReset: () => void;
@@ -14,12 +15,14 @@ interface ToolbarProps {
askBeforeOverwritingOutput: boolean;
onAskBeforeOverwritingOutputChange: (enabled: boolean) => void;
busy?: boolean;
stylesheetTrusted: boolean;
}
const Toolbar: React.FC<ToolbarProps> = ({
selectedEngine,
onEngineChange,
onApplyTransformation,
onTrustStylesheet,
onMoveOutputToInput,
onValidate,
onReset,
@@ -28,6 +31,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
askBeforeOverwritingOutput = true,
onAskBeforeOverwritingOutputChange,
busy = false,
stylesheetTrusted,
}) => {
return (
<section className="toolbar card" aria-label="Workbench actions">
@@ -36,11 +40,25 @@ const Toolbar: React.FC<ToolbarProps> = ({
type="button"
className="button primary"
onClick={onApplyTransformation}
disabled={busy}
title="Apply transformation (Ctrl/Cmd+Enter)"
disabled={busy || !stylesheetTrusted}
title={
stylesheetTrusted
? 'Apply transformation (Ctrl/Cmd+Enter)'
: 'Review and trust this exact stylesheet before running it'
}
>
{busy ? 'Transforming…' : 'Apply transformation'}
</button>
{!stylesheetTrusted && (
<button
type="button"
className="button danger-outline"
onClick={onTrustStylesheet}
disabled={busy}
>
Review &amp; trust stylesheet
</button>
)}
<button
type="button"
className="button secondary"
@@ -61,6 +79,21 @@ const Toolbar: React.FC<ToolbarProps> = ({
</button>
</div>
<div
className={`stylesheet-trust-status ${
stylesheetTrusted ? 'is-trusted' : 'is-untrusted'
}`}
role="status"
>
<strong>
{stylesheetTrusted
? 'This exact stylesheet is trusted for this session.'
: 'Transformation is disabled until this exact stylesheet is reviewed and trusted.'}
</strong>{' '}
Stylesheets are executable code and may access browser or network
resources. Any edit revokes trust.
</div>
<div className="toolbar-row toolbar-options">
<label className="toggle-option">
<input

View File

@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import 'bootstrap-icons/font/bootstrap-icons.css';
import '@add-ideas/toolbox-shell-react/styles.css';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(

View File

@@ -35,69 +35,28 @@ kbd {
monospace;
}
.app-root {
min-height: 100vh;
.xslt-app-shell {
color-scheme: light;
--toolbox-background: #f3f4f6;
--toolbox-surface: #ffffff;
--toolbox-text: #111827;
--toolbox-muted: #6b7280;
--toolbox-border: #d1d5db;
--toolbox-accent: #2563eb;
--toolbox-accent-contrast: #ffffff;
}
.app-header {
position: sticky;
top: 0;
z-index: 10;
background: #111827;
color: #e5e7eb;
padding: 0.65rem 1rem;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.4);
}
.app-header-content {
.xslt-app-shell > .toolbox-shell__main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.app-header-title {
display: flex;
align-items: center;
gap: 0.65rem;
}
.app-logo {
font-size: 1.35rem;
}
.app-header h1 {
font-size: 1.05rem;
margin: 0;
}
.app-header small {
color: #9ca3af;
font-size: 0.8rem;
}
.app-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-version {
border-radius: 999px;
background: #374151;
color: #d1d5db;
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
flex-direction: column;
gap: 0.75rem;
}
.app-help-button {
border: 1px solid #4b5563;
border: 1px solid var(--toolbox-border);
border-radius: 999px;
background: transparent;
color: #e5e7eb;
color: var(--toolbox-text);
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
font-weight: 600;
@@ -107,17 +66,10 @@ kbd {
.app-help-button:hover,
.app-help-button:focus-visible {
background: #374151;
background: var(--toolbox-background);
outline: none;
}
.app-main {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
width: 100%;
background: #ffffff;
@@ -207,6 +159,26 @@ kbd {
font-size: 0.85rem;
}
.stylesheet-trust-status {
padding: 0.65rem 0.75rem;
border: 1px solid;
border-radius: 0.55rem;
font-size: 0.82rem;
line-height: 1.45;
}
.stylesheet-trust-status.is-untrusted {
border-color: #fca5a5;
background: #fff1f2;
color: #881337;
}
.stylesheet-trust-status.is-trusted {
border-color: #86efac;
background: #f0fdf4;
color: #14532d;
}
.toggle-option {
display: inline-flex;
align-items: center;

49
src/toolboxApp.ts Normal file
View File

@@ -0,0 +1,49 @@
import { defineToolboxApp } from '@add-ideas/toolbox-contract';
import { APP_VERSION } from './version.ts';
export const toolboxAppDefinition = {
$schema:
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json',
schemaVersion: 1,
id: 'de.add-ideas.xslt-tools',
name: 'XSLT tools',
version: APP_VERSION,
description:
'Browser-based XML and XSLT transformations with executable-stylesheet trust controls.',
entry: './',
icon: './favicon.svg',
categories: ['documents', 'xml', 'xslt'],
tags: ['transform', 'validate', 'format', 'explain'],
actions: [
{
id: 'source',
label: 'Source',
url: 'https://git.add-ideas.de/zemion/xslt-tools/src/tag/v0.3.2',
},
],
assets: ['./vendor/saxon/SaxonJS2.js', './vendor/saxon/LICENSE.txt'],
integration: {
contextVersion: 1,
launchModes: ['navigate', 'new-tab'],
embedding: 'unsupported',
},
requirements: {
secureContext: true,
workers: false,
indexedDb: false,
crossOriginIsolated: false,
},
privacy: {
processing: 'mixed',
fileUploads: true,
telemetry: false,
label:
'Stylesheets are executable: after explicit trust they may read same-site app data, contact the network, or navigate away.',
},
source: {
repository: 'https://git.add-ideas.de/zemion/xslt-tools',
license: 'AGPL-3.0-only',
},
} as const;
export const toolboxApp = defineToolboxApp(toolboxAppDefinition);

View File

@@ -1,6 +1,8 @@
export async function createSha256Hash(text: string): Promise<string> {
if (!window.crypto?.subtle) {
return createFallbackHash(text);
throw new Error(
'SHA-256 is unavailable. Open this app in a secure browser context before trusting or running a stylesheet.'
);
}
const data = new TextEncoder().encode(text);
@@ -8,13 +10,3 @@ export async function createSha256Hash(text: string): Promise<string> {
const bytes = Array.from(new Uint8Array(digest));
return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function createFallbackHash(text: string): string {
let hash = 0;
for (let index = 0; index < text.length; index += 1) {
hash = (hash << 5) - hash + text.charCodeAt(index);
hash |= 0;
}
return `fallback-${Math.abs(hash).toString(16)}`;
}

View File

@@ -9,10 +9,16 @@ type SaxonXdmMap = {
inSituPut: (key: unknown, value: unknown[]) => void;
};
const SAXON_SCRIPT_URL = '/vendor/saxon/SaxonJS2.js';
let saxonLoadPromise: Promise<void> | null = null;
export function resolveSaxonScriptUrl(
baseUrl = import.meta.env.BASE_URL,
pageUrl = window.location.href
): string {
const appBase = new URL(baseUrl, pageUrl);
return new URL('vendor/saxon/SaxonJS2.js', appBase).href;
}
type SaxonPrivateRuntime = {
getPlatform?: () => {
resource?: (name: string) => unknown;
@@ -60,8 +66,9 @@ async function ensureSaxonLoaded(): Promise<void> {
if (window.SaxonJS) return;
saxonLoadPromise ??= new Promise<void>((resolve, reject) => {
const saxonScriptUrl = resolveSaxonScriptUrl();
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${SAXON_SCRIPT_URL}"]`
`script[src="${saxonScriptUrl}"]`
);
if (existingScript) {
@@ -75,7 +82,7 @@ async function ensureSaxonLoaded(): Promise<void> {
}
const script = document.createElement('script');
script.src = SAXON_SCRIPT_URL;
script.src = saxonScriptUrl;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load SaxonJS2.js.'));

View File

@@ -0,0 +1,16 @@
export interface TrustedStylesheet {
hash: string;
revision: number;
}
export function isStylesheetExecutionTrusted(
trust: TrustedStylesheet | null,
currentHash: string,
currentRevision: number
): boolean {
return (
trust !== null &&
trust.hash === currentHash &&
trust.revision === currentRevision
);
}

View File

@@ -0,0 +1,65 @@
import type { TransformRequest, TransformResult } from './transformTypes';
export const TRANSFORM_LIMITS = {
maxXmlBytes: 5 * 1024 * 1024,
maxXsltBytes: 2 * 1024 * 1024,
maxOutputBytes: 10 * 1024 * 1024,
timeoutMs: 20_000,
} as const;
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}
export function assertTransformInputWithinLimits(
request: TransformRequest
): void {
const xmlBytes = utf8Bytes(request.xmlText);
if (xmlBytes > TRANSFORM_LIMITS.maxXmlBytes) {
throw new Error(
`XML input exceeds the ${TRANSFORM_LIMITS.maxXmlBytes}-byte execution limit.`
);
}
const xsltBytes = utf8Bytes(request.xsltText);
if (xsltBytes > TRANSFORM_LIMITS.maxXsltBytes) {
throw new Error(
`XSLT stylesheet exceeds the ${TRANSFORM_LIMITS.maxXsltBytes}-byte execution limit.`
);
}
}
export function assertTransformOutputWithinLimits(
result: TransformResult
): void {
if (utf8Bytes(result.output) > TRANSFORM_LIMITS.maxOutputBytes) {
throw new Error(
`Transformation output exceeds the ${TRANSFORM_LIMITS.maxOutputBytes}-byte execution limit.`
);
}
}
export async function withTransformationTimeout<T>(
operation: () => Promise<T>,
timeoutMs: number = TRANSFORM_LIMITS.timeoutMs
): Promise<T> {
let timer: number | undefined;
try {
return await Promise.race([
Promise.resolve().then(operation),
new Promise<never>((_resolve, reject) => {
timer = window.setTimeout(
() =>
reject(
new Error(
`Transformation exceeded the ${timeoutMs}-millisecond execution limit.`
)
),
timeoutMs
);
}),
]);
} finally {
if (timer !== undefined) window.clearTimeout(timer);
}
}

View File

@@ -7,6 +7,21 @@ import type {
TransformRequest,
TransformResult,
} from './transformTypes';
import {
assertTransformInputWithinLimits,
assertTransformOutputWithinLimits,
withTransformationTimeout,
} from './transformLimits';
import { createSha256Hash } from './hash';
import {
isStylesheetExecutionTrusted,
type TrustedStylesheet,
} from './stylesheetTrust';
export interface TransformationAuthorization {
trustedStylesheet: TrustedStylesheet;
currentRevision: number;
}
const engines: Record<TransformEngineId, TransformEngine> = {
'saxon-js-dynamic': saxonJsDynamicEngine,
@@ -18,10 +33,30 @@ export function getTransformEngine(id: TransformEngineId): TransformEngine {
}
export async function runTransformation(
request: TransformRequest
request: TransformRequest,
authorization: TransformationAuthorization
): Promise<TransformResult> {
const stylesheetHash = await createSha256Hash(request.xsltText);
if (
!authorization ||
!isStylesheetExecutionTrusted(
authorization.trustedStylesheet,
stylesheetHash,
authorization.currentRevision
)
) {
throw new Error(
'Transformation refused: review and trust this exact stylesheet first.'
);
}
assertTransformInputWithinLimits(request);
const engine = getTransformEngine(request.engine);
return engine.transform(request);
const result = await withTransformationTimeout(() =>
engine.transform(request)
);
assertTransformOutputWithinLimits(result);
return result;
}
export const availableTransformEngines = Object.values(engines);