diff --git a/.gitignore b/.gitignore index ebdc0e8..1327fd6 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,7 @@ out # Nuxt.js build / generate output .nuxt dist +release/ # Gatsby files .cache/ @@ -137,4 +138,4 @@ dist .pnp.* .DS_Store -chatgpt_continuation_chat.md \ No newline at end of file +chatgpt_continuation_chat.md diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..90e6041 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a3ebd..1ac5d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,44 +1,66 @@ # Changelog +## 0.3.2 + +### Added + +- Added a typed Toolbox Contract v1 application definition and generated `toolbox-app.json` manifest. +- Added the shared React application shell while retaining the XSLT-specific Help action. +- Added standalone and toolbox-mode integration coverage, including nested-path static deployment checks. +- Added deterministic, cross-time-zone release ZIP and SHA-256 generation with complete production dependency licence files. +- Added a session-only SHA-256/revision trust gate that disables both transformation engines until the exact stylesheet is reviewed and confirmed. +- Added conservative transformation input/output limits and a best-effort asynchronous deadline. +- Added a prominent Source action and corresponding-source notice for the 0.3.2 tag. + +### Changed + +- Made Vite build assets relocatable with a relative deployment base. +- Resolved the lazily loaded SaxonJS runtime relative to the deployed application path. +- Documented that the vendored SaxonJS 2.7 browser distribution is free to use under separate, non-open-source terms and is not part of the application's AGPL licensing. +- Classified stylesheet processing conservatively and documented that executable stylesheets may access browser or network resources. +- Documented that trusted SaxonJS can access same-site Toolbox data, send data over the network, or navigate away; the recommended portal CSP is defense in depth rather than containment. +- Switched Toolbox SDK dependencies from local workspace links to the public token-free package registry. +- Release packaging now refuses to overwrite same-version artifacts unless `--force` is explicit. + ## 0.3.1 ### Added -* Added global keyboard shortcut `Shift + Enter` to apply the current transformation. -* Added global keyboard shortcut `F1` to open the help dialog. -* Added shortcut hints for transformation and help actions. +- Added global keyboard shortcut `Shift + Enter` to apply the current transformation. +- Added global keyboard shortcut `F1` to open the help dialog. +- Added shortcut hints for transformation and help actions. ### Changed -* Bumped application version to `0.3.1`. +- Bumped application version to `0.3.1`. ## 0.3.0 ### Added -* Added browser-side SaxonJS dynamic compilation support. -* Added support for raw XSLT 1.0, 2.0, and 3.0 transformations through the SaxonJS dynamic engine. -* Added dynamic XSLT-to-SEF compilation in the browser using the full `SaxonJS2.js` browser artifact. -* Added lazy loading for the SaxonJS browser compiler/runtime. -* Added tests covering XSLT 1.0, XSLT 2.0, and XSLT 3.0 transformation scenarios. -* Added project quality commands for type checking, linting, formatting, testing, building, and combined checks. +- Added browser-side SaxonJS dynamic compilation support. +- Added support for raw XSLT 1.0, 2.0, and 3.0 transformations through the SaxonJS dynamic engine. +- Added dynamic XSLT-to-SEF compilation in the browser using the full `SaxonJS2.js` browser artifact. +- Added lazy loading for the SaxonJS browser compiler/runtime. +- Added tests covering XSLT 1.0, XSLT 2.0, and XSLT 3.0 transformation scenarios. +- Added project quality commands for type checking, linting, formatting, testing, building, and combined checks. ### Changed -* Made `saxon-js-dynamic` the primary SaxonJS-based engine for raw XML + raw XSLT workflows. -* Removed the earlier failed npm-bundled SaxonJS approach. -* Removed the SEF-only SaxonJS placeholder from the main workflow. -* Excluded vendored SaxonJS files from linting and formatting. -* Cleaned up debug-only code and unused declarations. -* Improved transformation engine registry and test coverage. +- Made `saxon-js-dynamic` the primary SaxonJS-based engine for raw XML + raw XSLT workflows. +- Removed the earlier failed npm-bundled SaxonJS approach. +- Removed the SEF-only SaxonJS placeholder from the main workflow. +- Excluded vendored SaxonJS files from linting and formatting. +- Cleaned up debug-only code and unused declarations. +- Improved transformation engine registry and test coverage. ### Fixed -* Fixed SaxonJS browser integration by using the full `SaxonJS2.js` artifact instead of `SaxonJS2.rt.js`. -* Fixed dynamic compiler setup by providing a synthetic stylesheet base URI. -* Fixed SaxonJS static parameter handling by passing an `XdmMap` instead of a plain JavaScript object. -* Fixed browser compiler result extraction by reading `checkedOptions.principalResult`. -* Removed duplicate XML output replacement after transformations. +- Fixed SaxonJS browser integration by using the full `SaxonJS2.js` artifact instead of `SaxonJS2.rt.js`. +- Fixed dynamic compiler setup by providing a synthetic stylesheet base URI. +- Fixed SaxonJS static parameter handling by passing an `XdmMap` instead of a plain JavaScript object. +- Fixed browser compiler result extraction by reading `checkedOptions.principalResult`. +- Removed duplicate XML output replacement after transformations. ## 0.2.0 diff --git a/README.md b/README.md index 2cf5a6b..1f0e10a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # XSLT tools -Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded; the app uses browser APIs, LocalStorage, and local file open/save dialogs where available. +Browser-based MVP for testing XML/XSLT transformations. The application itself +does not intentionally upload opened files, and it uses LocalStorage plus local +file dialogs where available. XSLT is executable code, however: a trusted +stylesheet may access browser or network resources, so processing is not +described as guaranteed local-only. + +The application is independently deployable and also implements Toolbox Contract v1. When launched with a same-origin `?toolbox=` context, the shared application shell adds toolbox navigation and an application switcher; without that context, the same build runs standalone. ## Features in this MVP @@ -17,6 +23,9 @@ Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded - Transformation engines: - SaxonJS 2.7 as the default engine for XSLT 1.0/2.0/3.0-style workflows - Browser-native `XSLTProcessor` as a lightweight XSLT 1.0 fallback +- A mandatory SHA-256 trust confirmation for the exact executable stylesheet; + every edit, replacement, format, or reset revokes trust for both engines +- Defense-in-depth input/output limits and a best-effort asynchronous deadline - XML and XSLT well-formedness checks - Basic XSLT root/version checks - Snippet toolbox for common XSLT constructs @@ -27,12 +36,34 @@ Browser-only MVP for testing local XML/XSLT transformations. Nothing is uploaded - Confirmation dialog before destructive overwrites - LocalStorage persistence - Approximate “explain transformation” table after a run +- Shared toolbox shell with standalone fallback, application switching, and the XSLT-specific Help action +- Relocatable static output for deployment at root or nested paths ## SaxonJS note -SaxonJS is bundled through the `saxon-js` npm package and loaded lazily when the SaxonJS engine runs. The browser build may show Vite warnings about Node modules such as `fs`, `path`, or `stream` being externalized. Those warnings come from the package containing Node.js support as well; the app does not use file-based SaxonJS APIs in the browser. +SaxonJS 2.7 is vendored as the unmodified browser distribution file at `public/vendor/saxon/SaxonJS2.js` and loaded lazily when the SaxonJS engine runs. It is not bundled from the npm package during the application build. -SaxonJS itself is free to use but not open source. Check `node_modules/saxon-js/LICENSE.txt` after installation before distributing a packaged version. +SaxonJS is available free of charge but is not open source. Its separate redistribution terms are preserved at `public/vendor/saxon/LICENSE.txt`. Release archives include that notice as `LICENSES/SaxonJS-LICENSE.txt` alongside `LICENSES/xslt-tools-LICENSE.txt` for the AGPL-3.0-only application; do not describe the complete archive as AGPL-only. + +## Executable stylesheet security + +Treat every stylesheet like executable code. Transformation is disabled until +the user reviews a warning and trusts the SHA-256 fingerprint of the exact +current stylesheet. Trust is session-only and tied to both the content hash and +an edit revision; changing and then reverting text still requires another +confirmation. The same gate covers SaxonJS and native `XSLTProcessor`. + +The application enforces conservative input/output limits and a best-effort +asynchronous deadline, but transformations are not isolated from the browser or +network and synchronous CPU exhaustion cannot be forcibly stopped. A trusted +SaxonJS stylesheet may read data stored by other Toolbox apps on the same site, +including PDF workspaces and portal preferences, then send it over the network +or navigate the page away. + +When served with the recommended portal CSP, cross-origin fetches and dynamic +JavaScript evaluation are restricted without granting `unsafe-eval`. CSP is +defense in depth, not complete containment. Only execute stylesheets from trusted +sources. ## Run locally @@ -50,6 +81,45 @@ npm run build npm run preview ``` +Vite uses `base: './'`, so a single build can be hosted at an unknown nested path such as `/apps/xslt/`. The SaxonJS runtime is resolved from that application base as well. + +## Toolbox manifest + +`src/toolboxApp.ts` is the typed source for both the shared shell and the published `public/toolbox-app.json` manifest. Keep the generated file current with: + +```bash +npm run manifest:generate +npm run manifest:check +``` + +Toolbox mode is contextual rather than a separate build: + +```text +/apps/xslt/ standalone +/apps/xslt/?toolbox=/toolbox.catalog.json toolbox mode +``` + +An unavailable or invalid catalogue falls back to standalone operation. + +## Release archive + +After a successful build, create the deterministic application ZIP and SHA-256 sidecar with: + +```bash +npm run release +``` + +The command refuses to overwrite a same-version archive. Use +`npm run release -- --force` only when intentionally replacing it. + +The archive contains the static app and `toolbox-app.json`, plus `CHANGELOG.md`, +`SOURCE.md` with the corresponding tagged source link, +`THIRD_PARTY_NOTICES.md`, the application licence at +`LICENSES/xslt-tools-LICENSE.txt`, the separate vendored SaxonJS licence, and +licence/notice files for every installed production npm dependency below +`LICENSES/npm/`. Generated artifacts are written below `release/` and are not +committed. + ## Suggested next steps 1. Add parameter handling for `xsl:param`. @@ -63,12 +133,13 @@ npm run preview ```text src/ -├── components/ # Layout, toolbar, editor panels, dialogs +├── components/ # Toolbar, editor panels, dialogs, and app-specific actions ├── editor/ # CodeMirror integration and XSLT snippets ├── file/ # Local file open/save helpers ├── transform/ # Transform engine abstraction, SaxonJS, native engine ├── validation/ # XML/XSLT validation helpers -└── workspace/ # LocalStorage-backed workbench state +├── workspace/ # LocalStorage-backed workbench state +└── toolboxApp.ts # Toolbox manifest and shared-shell application definition ``` ## Development checks @@ -89,6 +160,9 @@ npm run format:check # Prettier check npm run format # Prettier write npm run test # Vitest/jsdom test suite npm run build # Production build +npm run smoke:static # Nested-path static build smoke test +npm run smoke:toolbox # Contract, asset, standalone, and contextual checks +npm run release # Deterministic ZIP and SHA-256 release artifacts npm run clean # Remove generated build/cache files ``` @@ -98,7 +172,8 @@ The vendored SaxonJS browser file is intentionally excluded from ESLint and Pret The app currently exposes two engines: -- **SaxonJS 2 dynamic XSLT 3.0**: default engine. It lazy-loads `public/vendor/saxon/SaxonJS2.js`, compiles raw XSLT text to SEF in the browser, then executes the generated SEF locally. +- **SaxonJS 2 dynamic XSLT 3.0**: default engine. It loads the vendored `public/vendor/saxon/SaxonJS2.js`, compiles raw XSLT text to SEF in the browser, then executes the generated SEF after explicit trust confirmation. - **Native browser XSLTProcessor**: XSLT 1.0 fallback using the browser implementation. -The SaxonJS runtime is loaded only when a Saxon transformation is requested, so the initial app shell does not block on the large compiler/runtime file. +The SaxonJS runtime is loaded only for a trusted Saxon transformation, so the +initial app shell does not block on the large compiler/runtime file. diff --git a/SOURCE.md b/SOURCE.md new file mode 100644 index 0000000..a2502c2 --- /dev/null +++ b/SOURCE.md @@ -0,0 +1,10 @@ +# Corresponding source + +The corresponding application source for XSLT tools 0.3.2 is available at: + + + +The XSLT tools application source is licensed under AGPL-3.0-only. This release +also contains third-party components under separate terms. See +`THIRD_PARTY_NOTICES.md` and the complete notices below `LICENSES/` in the +release archive, including the separate SaxonJS terms. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..6dbb529 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,15 @@ +# Third-party notices + +This application includes third-party software under licences that are separate +from the XSLT tools application's AGPL-3.0-only licence. + +- The vendored SaxonJS browser distribution is proprietary software available + under Saxonica's free-of-charge terms. Its complete supplied licence is at + `LICENSES/SaxonJS-LICENSE.txt` in release archives. +- Runtime npm dependencies retain their own licences. Release archives include + the installed licence and notice files for every production dependency below + `LICENSES/npm/`, including the Apache-2.0 Toolbox Contract and React shell + packages. + +The dependency set and exact versions are fixed by `package-lock.json`. Review +the accompanying licence files before redistributing the complete archive. diff --git a/eslint.config.mjs b/eslint.config.mjs index afee868..f429fc9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,12 @@ export default tseslint.config( { ignores: ['dist', 'public/vendor/**'] }, js.configs.recommended, ...tseslint.configs.recommended, + { + files: ['scripts/**/*.{js,mjs,cjs}'], + languageOptions: { + globals: globals.node, + }, + }, { files: ['**/*.{ts,tsx}'], languageOptions: { diff --git a/index.html b/index.html index 01cc53f..43c08ce 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ diff --git a/package-lock.json b/package-lock.json index 8e741ef..49cb72b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "xsl-tools", - "version": "0.2.0", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xsl-tools", - "version": "0.2.0", + "version": "0.3.2", + "license": "AGPL-3.0-only", "dependencies": { + "@add-ideas/toolbox-contract": "^0.1.0", + "@add-ideas/toolbox-shell-react": "^0.1.0", "@codemirror/commands": "^6.8.1", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lint": "^6.8.5", @@ -20,6 +23,7 @@ "react-dom": "^19.2.6" }, "devDependencies": { + "@add-ideas/toolbox-testkit": "^0.1.0", "@eslint/js": "^10.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -40,6 +44,38 @@ "vitest": "^4.1.6" } }, + "node_modules/@add-ideas/toolbox-contract": { + "version": "0.1.1", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.1.1/toolbox-contract-0.1.1.tgz", + "integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==", + "license": "Apache-2.0" + }, + "node_modules/@add-ideas/toolbox-shell-react": { + "version": "0.1.1", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.1.1/toolbox-shell-react-0.1.1.tgz", + "integrity": "sha512-Di2mu1BoJrWJg4/v5jDExROXDpvLXorlUgqcsBQV8ea/LklKoBFTAfgTkk/23HZ2BzixyMIjrhl9kD507s+iPQ==", + "license": "Apache-2.0", + "dependencies": { + "@add-ideas/toolbox-contract": "0.1.1" + }, + "peerDependencies": { + "react": ">=18 <20", + "react-dom": ">=18 <20" + } + }, + "node_modules/@add-ideas/toolbox-testkit": { + "version": "0.1.1", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.1.1/toolbox-testkit-0.1.1.tgz", + "integrity": "sha512-o+H7LfM7ipdgmYz18S0lLuN6JsXUR+8ubAbcjTWTk8rSqNfp1JyeoRHOPbJVEHXrQwIFe/OB86UzRpex2gnKUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@add-ideas/toolbox-contract": "0.1.1" + }, + "bin": { + "toolbox-check": "dist/cli.js" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -3647,9 +3683,9 @@ } }, "node_modules/undici": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", - "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index df93ba5..a3fa9bd 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,12 @@ "version": "0.3.2", "private": true, "type": "module", - "description": "Browser-only XSLT workbench for local XML/XSLT transformations.", + "license": "AGPL-3.0-only", + "description": "Browser-based XSLT workbench with explicit executable-stylesheet trust controls.", "scripts": { + "predev": "npm run manifest:generate", "dev": "vite", + "prebuild": "npm run manifest:generate", "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", @@ -14,11 +17,19 @@ "format:check": "prettier --check .", "test": "vitest run --environment jsdom", "test:watch": "vitest --environment jsdom", - "check": "npm run typecheck && npm run lint && npm run test && npm run build", + "manifest:generate": "node --experimental-strip-types scripts/generate-toolbox-manifest.mjs", + "manifest:check": "node --experimental-strip-types scripts/generate-toolbox-manifest.mjs --check", + "smoke:static": "node scripts/smoke-static-build.mjs", + "smoke:toolbox": "toolbox-check dist", + "release:artifact": "npm run check && node scripts/package-release.mjs", + "release": "npm run release:artifact", + "check": "npm run manifest:check && npm run typecheck && npm run lint && npm run test && npm run build && npm run smoke:static && npm run smoke:toolbox", "lint:fix": "eslint . --fix", "clean": "rm -rf dist node_modules/.vite coverage" }, "dependencies": { + "@add-ideas/toolbox-contract": "^0.1.0", + "@add-ideas/toolbox-shell-react": "^0.1.0", "@codemirror/commands": "^6.8.1", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lint": "^6.8.5", @@ -31,6 +42,7 @@ "react-dom": "^19.2.6" }, "devDependencies": { + "@add-ideas/toolbox-testkit": "^0.1.0", "@eslint/js": "^10.0.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/public/site.webmanifest b/public/site.webmanifest index 2c6ac31..6221b38 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -3,13 +3,13 @@ "short_name": "XSLTools", "icons": [ { - "src": "/web-app-manifest-192x192.png", + "src": "./web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/web-app-manifest-512x512.png", + "src": "./web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" @@ -18,4 +18,4 @@ "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" -} \ No newline at end of file +} diff --git a/public/toolbox-app.json b/public/toolbox-app.json new file mode 100644 index 0000000..906f76d --- /dev/null +++ b/public/toolbox-app.json @@ -0,0 +1,41 @@ +{ + "$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": "0.3.2", + "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" + } +} diff --git a/scripts/generate-toolbox-manifest.mjs b/scripts/generate-toolbox-manifest.mjs new file mode 100644 index 0000000..01dd405 --- /dev/null +++ b/scripts/generate-toolbox-manifest.mjs @@ -0,0 +1,33 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { format } from 'prettier'; +import { toolboxAppDefinition } from '../src/toolboxApp.ts'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + await readFile(join(repositoryRoot, 'package.json'), 'utf8') +); +const manifestPath = join(repositoryRoot, 'public', 'toolbox-app.json'); + +if (toolboxAppDefinition.version !== packageJson.version) { + throw new Error( + `Version mismatch: package.json is ${packageJson.version}, while src/toolboxApp.ts is ${toolboxAppDefinition.version}.` + ); +} + +const output = await format(JSON.stringify(toolboxAppDefinition), { + parser: 'json', +}); + +if (process.argv.includes('--check')) { + const existing = await readFile(manifestPath, 'utf8').catch(() => ''); + if (existing !== output) { + throw new Error( + 'public/toolbox-app.json is out of date. Run `npm run manifest:generate`.' + ); + } +} else { + await writeFile(manifestPath, output); +} diff --git a/scripts/package-release.d.mts b/scripts/package-release.d.mts new file mode 100644 index 0000000..be5df07 --- /dev/null +++ b/scripts/package-release.d.mts @@ -0,0 +1,26 @@ +export interface ReleaseEntry { + name: string; + data: Uint8Array; +} + +export interface DosDateTime { + time: number; + date: number; +} + +export function createDeterministicZip( + entries: ReleaseEntry[] +): Buffer; + +export function collectReleaseEntries(): ReleaseEntry[]; +export function collectRuntimeDependencyLicenseEntries(): ReleaseEntry[]; +export function listFiles(directory: string): string[]; +export function toDosDateTime(date: Date): DosDateTime; +export function writeReleaseFiles(options: { + archive: Uint8Array; + archiveTarget: string; + checksum: string; + checksumTarget: string; + force?: boolean; +}): void; +export function packageRelease(options?: { force?: boolean }): void; diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs new file mode 100644 index 0000000..f92374a --- /dev/null +++ b/scripts/package-release.mjs @@ -0,0 +1,346 @@ +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const distDirectory = join(repositoryRoot, 'dist'); +const releaseDirectory = join(repositoryRoot, 'release'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8') +); +const artifactName = `xslt-tools-${packageJson.version}`; +const archivePath = join(releaseDirectory, `${artifactName}.zip`); +const checksumPath = join(releaseDirectory, `${artifactName}.sha256`); +const zipEpoch = new Date(1980, 0, 1, 0, 0, 0); + +export function listFiles(directory) { + return readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => compareText(left.name, right.name)) + .flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return listFiles(path); + if (entry.isFile()) return [path]; + throw new Error( + `Refusing to package symlink or special filesystem entry: ${path}` + ); + }); +} + +function zipPath(path) { + return path.split(sep).join('/'); +} + +export function collectReleaseEntries() { + if (!existsSync(join(distDirectory, 'index.html'))) { + throw new Error('dist/index.html is missing. Run `npm run build` first.'); + } + + if (!existsSync(join(distDirectory, 'toolbox-app.json'))) { + throw new Error( + 'dist/toolbox-app.json is missing. Generate and build the toolbox manifest first.' + ); + } + + const entries = listFiles(distDirectory).map((path) => ({ + name: zipPath(relative(distDirectory, path)), + data: readFileSync(path), + })); + + entries.push( + { + name: 'CHANGELOG.md', + data: readFileSync(join(repositoryRoot, 'CHANGELOG.md')), + }, + { + name: 'LICENSE', + data: readFileSync(join(repositoryRoot, 'LICENSE')), + }, + { + name: 'LICENSES/xslt-tools-LICENSE.txt', + data: readFileSync(join(repositoryRoot, 'LICENSE')), + }, + { + name: 'LICENSES/SaxonJS-LICENSE.txt', + data: readFileSync( + join(repositoryRoot, 'public/vendor/saxon/LICENSE.txt') + ), + }, + { + name: 'THIRD_PARTY_NOTICES.md', + data: readFileSync(join(repositoryRoot, 'THIRD_PARTY_NOTICES.md')), + }, + { + name: 'SOURCE.md', + data: readFileSync(join(repositoryRoot, 'SOURCE.md')), + } + ); + + entries.push(...collectRuntimeDependencyLicenseEntries()); + + return entries.sort((left, right) => compareText(left.name, right.name)); +} + +export function collectRuntimeDependencyLicenseEntries() { + const packages = collectRuntimePackages(); + const entries = []; + + for (const runtimePackage of packages) { + const legalFiles = readdirSync(runtimePackage.directory, { + withFileTypes: true, + }) + .filter( + (entry) => + entry.isFile() && + /^(?:licen[cs]e|copying|notice)(?:\.|$)/i.test(entry.name) + ) + .sort((left, right) => compareText(left.name, right.name)); + + if (legalFiles.length === 0) { + throw new Error( + `Runtime dependency ${runtimePackage.name}@${runtimePackage.version} has no licence file.` + ); + } + + const directoryName = `${safePackageName(runtimePackage.name)}-${runtimePackage.version}`; + for (const legalFile of legalFiles) { + entries.push({ + name: `LICENSES/npm/${directoryName}/${basename(legalFile.name)}`, + data: readFileSync(join(runtimePackage.directory, legalFile.name)), + }); + } + } + + return entries.sort((left, right) => compareText(left.name, right.name)); +} + +function collectRuntimePackages() { + const queue = Object.keys(packageJson.dependencies ?? {}).map((name) => ({ + name, + fromDirectory: repositoryRoot, + })); + const visited = new Set(); + const packages = []; + + while (queue.length > 0) { + const dependency = queue.shift(); + const directory = findInstalledPackageDirectory( + dependency.name, + dependency.fromDirectory + ); + const manifest = JSON.parse( + readFileSync(join(directory, 'package.json'), 'utf8') + ); + const identity = `${manifest.name}@${manifest.version}`; + if (visited.has(identity)) continue; + + visited.add(identity); + packages.push({ + name: manifest.name, + version: manifest.version, + directory, + }); + + const childNames = new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ]); + for (const name of [...childNames].sort(compareText)) { + const childDirectory = tryFindInstalledPackageDirectory(name, directory); + if (childDirectory) { + queue.push({ name, fromDirectory: directory }); + } + } + } + + return packages.sort((left, right) => + compareText( + `${left.name}@${left.version}`, + `${right.name}@${right.version}` + ) + ); +} + +function findInstalledPackageDirectory(name, fromDirectory) { + const directory = tryFindInstalledPackageDirectory(name, fromDirectory); + if (!directory) { + throw new Error(`Runtime dependency ${name} is not installed.`); + } + return directory; +} + +function tryFindInstalledPackageDirectory(name, fromDirectory) { + let current = resolve(fromDirectory); + const packageSegments = name.split('/'); + + while (true) { + const candidate = join(current, 'node_modules', ...packageSegments); + if (existsSync(join(candidate, 'package.json'))) { + return realpathSync(candidate); + } + + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function safePackageName(name) { + return name.replace(/^@/, '').replaceAll('/', '--'); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +const crcTable = Array.from({ length: 256 }, (_, value) => { + let result = value; + for (let bit = 0; bit < 8; bit += 1) { + result = (result & 1) !== 0 ? 0xedb88320 ^ (result >>> 1) : result >>> 1; + } + return result >>> 0; +}); + +function crc32(data) { + let result = 0xffffffff; + for (const byte of data) { + result = crcTable[(result ^ byte) & 0xff] ^ (result >>> 8); + } + return (result ^ 0xffffffff) >>> 0; +} + +export function createDeterministicZip(entries) { + const localRecords = []; + const centralRecords = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name, 'utf8'); + const data = Buffer.from(entry.data); + const checksum = crc32(data); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(0x0800, 6); + localHeader.writeUInt16LE(0, 8); + localHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 10); + localHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 12); + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(data.length, 18); + localHeader.writeUInt32LE(data.length, 22); + localHeader.writeUInt16LE(name.length, 26); + localHeader.writeUInt16LE(0, 28); + + localRecords.push(localHeader, name, data); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(20, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(0x0800, 8); + centralHeader.writeUInt16LE(0, 10); + centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 12); + centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 14); + centralHeader.writeUInt32LE(checksum, 16); + centralHeader.writeUInt32LE(data.length, 20); + centralHeader.writeUInt32LE(data.length, 24); + centralHeader.writeUInt16LE(name.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE(0, 38); + centralHeader.writeUInt32LE(offset, 42); + + centralRecords.push(centralHeader, name); + offset += localHeader.length + name.length + data.length; + } + + const centralDirectory = Buffer.concat(centralRecords); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(centralDirectory.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localRecords, centralDirectory, end]); +} + +export function toDosDateTime(date) { + const year = date.getFullYear(); + if (year < 1980 || year > 2107) { + throw new RangeError('ZIP timestamps must be between 1980 and 2107.'); + } + + return { + time: + (date.getHours() << 11) | + (date.getMinutes() << 5) | + Math.floor(date.getSeconds() / 2), + date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), + }; +} + +export function writeReleaseFiles({ + archive, + archiveTarget, + checksum, + checksumTarget, + force = false, +}) { + mkdirSync(dirname(archiveTarget), { recursive: true }); + mkdirSync(dirname(checksumTarget), { recursive: true }); + + const existingTargets = [archiveTarget, checksumTarget].filter(existsSync); + if (!force && existingTargets.length > 0) { + throw new Error( + `Refusing to overwrite existing release file${existingTargets.length === 1 ? '' : 's'}: ${existingTargets.join(', ')}. Pass --force to replace the same-version artifact.` + ); + } + + const writeOptions = force ? undefined : { flag: 'wx' }; + writeFileSync(archiveTarget, archive, writeOptions); + writeFileSync(checksumTarget, checksum, { + encoding: 'utf8', + ...(writeOptions ?? {}), + }); +} + +export function packageRelease({ force = false } = {}) { + const archive = createDeterministicZip(collectReleaseEntries()); + const checksum = createHash('sha256').update(archive).digest('hex'); + + writeReleaseFiles({ + archive, + archiveTarget: archivePath, + checksum: `${checksum} ${artifactName}.zip\n`, + checksumTarget: checksumPath, + force, + }); + + console.log(relative(repositoryRoot, archivePath)); + console.log(relative(repositoryRoot, checksumPath)); +} + +if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) { + const arguments_ = process.argv.slice(2); + const unknownArguments = arguments_.filter( + (argument) => argument !== '--force' + ); + if (unknownArguments.length > 0) { + throw new Error(`Unknown release argument: ${unknownArguments.join(', ')}`); + } + packageRelease({ force: arguments_.includes('--force') }); +} diff --git a/scripts/smoke-static-build.mjs b/scripts/smoke-static-build.mjs new file mode 100644 index 0000000..d9054ca --- /dev/null +++ b/scripts/smoke-static-build.mjs @@ -0,0 +1,151 @@ +import { createServer } from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import { dirname, extname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const distDirectory = join(repositoryRoot, 'dist'); +const nestedPath = '/deep/nested/app/'; +const mimeTypes = { + '.css': 'text/css', + '.html': 'text/html', + '.ico': 'image/x-icon', + '.js': 'text/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', +}; + +const server = createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url ?? '/', 'http://localhost'); + + if (requestUrl.pathname === '/toolbox.catalog.json') { + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + schemaVersion: 1, + id: 'nested-smoke-test', + name: 'Nested smoke test', + home: './', + theme: { + mode: 'system', + brand: 'Nested smoke test', + }, + apps: [ + { + manifest: `${nestedPath}toolbox-app.json`, + enabled: true, + }, + ], + }) + ); + return; + } + + if (!requestUrl.pathname.startsWith(nestedPath)) { + response.writeHead(404).end(); + return; + } + + const requestedPath = decodeURIComponent( + requestUrl.pathname.slice(nestedPath.length) + ); + const filePath = resolve( + distDirectory, + requestedPath === '' ? 'index.html' : requestedPath + ); + + if ( + filePath !== distDirectory && + !filePath.startsWith(`${distDirectory}${sep}`) + ) { + response.writeHead(403).end(); + return; + } + + if (!(await stat(filePath)).isFile()) { + response.writeHead(404).end(); + return; + } + + response.setHeader( + 'content-type', + mimeTypes[extname(filePath)] ?? 'application/octet-stream' + ); + response.end(await readFile(filePath)); + } catch { + response.writeHead(404).end(); + } +}); + +await new Promise((resolveListening) => { + server.listen(0, '127.0.0.1', resolveListening); +}); + +try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Static smoke server did not expose a TCP port.'); + } + + const appUrl = `http://127.0.0.1:${address.port}${nestedPath}`; + const standaloneHtml = await fetchText(appUrl); + const toolboxHtml = await fetchText( + `${appUrl}?toolbox=${encodeURIComponent('/toolbox.catalog.json')}` + ); + + if (toolboxHtml !== standaloneHtml) { + throw new Error('Toolbox mode did not serve the same static app artifact.'); + } + + const assetReferences = Array.from( + standaloneHtml.matchAll(/(?:href|src)="([^"]+)"/g), + (match) => match[1] + ).filter( + (reference) => + !reference.startsWith('#') && + !reference.startsWith('data:') && + !reference.startsWith('http:') && + !reference.startsWith('https:') + ); + + for (const reference of assetReferences) { + const resolved = new URL(reference, appUrl); + if (!resolved.pathname.startsWith(nestedPath)) { + throw new Error( + `Built asset escaped the nested app path: ${reference} -> ${resolved.pathname}` + ); + } + await fetchOk(resolved); + } + + const manifestUrl = new URL('toolbox-app.json', appUrl); + const manifest = JSON.parse(await fetchText(manifestUrl)); + await fetchOk(new URL(manifest.entry, appUrl)); + await fetchOk(new URL(manifest.icon, manifestUrl)); + await fetchOk(new URL('vendor/saxon/SaxonJS2.js', appUrl)); + await fetchOk(`http://127.0.0.1:${address.port}/toolbox.catalog.json`); + + console.log( + `Static smoke test passed at ${nestedPath} in standalone and toolbox modes.` + ); +} finally { + await new Promise((resolveClosed, reject) => { + server.close((error) => (error ? reject(error) : resolveClosed())); + }); +} + +async function fetchOk(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Expected ${url} to be available, received HTTP ${response.status}.` + ); + } + return response; +} + +async function fetchText(url) { + return (await fetchOk(url)).text(); +} diff --git a/src/App.tsx b/src/App.tsx index 07c13f3..73bd33a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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: ( + <> +

+ XSLT is executable code. Both engines may access browser or + network resources. Only continue if you trust the source of this + exact stylesheet. +

+

+ 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. +

+

+ 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. +

+ + ), + 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 ( - handleOpenHelp()}> + +