From 0a50e573d23855be0cd9ae50bb5f757fd77398cb Mon Sep 17 00:00:00 2001 From: Jack Steam Date: Wed, 11 Nov 2026 19:29:05 -0600 Subject: [PATCH 1/2] fix: generate distribution attribution during builds --- .gitattributes | 2 + .github/workflows/full-data.yml | 1 + .github/workflows/verify.yml | 6 +- README.md | 3 + THIRD_PARTY_NOTICES.md | 3 +- docs/worker-runbook.md | 7 +- examples/cloudflare-worker/build.ts | 5 +- examples/configurable-chart/README.md | 7 +- examples/configurable-chart/build-wasm.ts | 4 + examples/configurable-chart/index.html | 3 +- tools/README.md | 3 + tools/ci/changes.ts | 2 +- tools/ci/tsconfig.json | 3 +- tools/notices/README.md | 21 + tools/notices/about.toml | 28 + tools/notices/collect.ts | 114 + tools/notices/core.ts | 56 + tools/notices/generate.test.ts | 33 + tools/notices/generate.ts | 58 + tools/notices/reviewed-files.json | 44 + tools/notices/rust-library.json | 5 + tools/notices/supplements.json | 63 + tools/notices/supplements/anise-AUTHORS.txt | 5 + tools/notices/supplements/anise-LICENSE.txt | 373 +++ .../supplements/astronomy-engine-MIT.txt | 22 + tools/notices/supplements/vite-LICENSE.txt | 2120 +++++++++++++++++ .../supplements/wasm-bindgen-cli-LICENSE.txt | 25 + .../supplements/worker-build-LICENSE.txt | 176 ++ 28 files changed, 3182 insertions(+), 10 deletions(-) create mode 100644 .gitattributes create mode 100644 tools/notices/README.md create mode 100644 tools/notices/about.toml create mode 100644 tools/notices/collect.ts create mode 100644 tools/notices/core.ts create mode 100644 tools/notices/generate.test.ts create mode 100644 tools/notices/generate.ts create mode 100644 tools/notices/reviewed-files.json create mode 100644 tools/notices/rust-library.json create mode 100644 tools/notices/supplements.json create mode 100644 tools/notices/supplements/anise-AUTHORS.txt create mode 100644 tools/notices/supplements/anise-LICENSE.txt create mode 100644 tools/notices/supplements/astronomy-engine-MIT.txt create mode 100644 tools/notices/supplements/vite-LICENSE.txt create mode 100644 tools/notices/supplements/wasm-bindgen-cli-LICENSE.txt create mode 100644 tools/notices/supplements/worker-build-LICENSE.txt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b4b71e3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve reviewed upstream license bytes. +tools/notices/supplements/*.txt -whitespace diff --git a/.github/workflows/full-data.yml b/.github/workflows/full-data.yml index ac78638..4cfa21c 100644 --- a/.github/workflows/full-data.yml +++ b/.github/workflows/full-data.yml @@ -31,6 +31,7 @@ jobs: with: bun-version: '1.3.14' - run: rustup toolchain install 1.96.0 --profile minimal --target wasm32-unknown-unknown + - run: cargo install cargo-about --version 0.9.2 --features cli --locked - run: cargo install worker-build --version 0.8.3 --locked - run: bun install --frozen-lockfile && bun run build:verification working-directory: examples/cloudflare-worker diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5eff222..18385a1 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -24,7 +24,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' - - run: bun test tools/ci + - run: bun test tools/ci tools/notices - run: bun install --frozen-lockfile working-directory: examples/configurable-chart - run: bun examples/configurable-chart/node_modules/typescript/bin/tsc --project tools/ci/tsconfig.json @@ -56,6 +56,7 @@ jobs: - uses: ./.github/actions/ci-tools with: group: browser + - run: cargo install cargo-about --version 0.9.2 --features cli --locked - uses: ./.github/actions/bun-cache - run: cargo fmt --manifest-path examples/configurable-chart/wasm/Cargo.toml -- --check - run: cargo clippy --locked --manifest-path examples/configurable-chart/wasm/Cargo.toml --target wasm32-unknown-unknown -- -D warnings @@ -63,6 +64,7 @@ jobs: working-directory: examples/configurable-chart - run: bun run build && bun run check working-directory: examples/configurable-chart + - run: test -s examples/configurable-chart/build/THIRD_PARTY_NOTICES.html && test -s examples/configurable-chart/build/RUST_LIBRARY_NOTICES.html && test -z "$(git ls-files notices)" native: needs: changes if: needs.changes.outputs.native == 'true' @@ -119,6 +121,7 @@ jobs: - uses: ./.github/actions/ci-tools with: group: worker + - run: cargo install cargo-about --version 0.9.2 --features cli --locked - uses: ./.github/actions/bun-cache - run: cargo fmt --manifest-path examples/cloudflare-worker/Cargo.toml -- --check - run: cargo clippy --locked --manifest-path examples/cloudflare-worker/Cargo.toml --target wasm32-unknown-unknown --features verification -- -D warnings @@ -126,6 +129,7 @@ jobs: working-directory: examples/cloudflare-worker - run: bun run build:verification working-directory: examples/cloudflare-worker + - run: test -s examples/cloudflare-worker/build/worker/THIRD_PARTY_NOTICES.txt && test -s examples/cloudflare-worker/build/worker/RUST_LIBRARY_NOTICES.html && test -z "$(git ls-files notices)" - run: node examples/cloudflare-worker/verify.mjs verify: if: always() diff --git a/README.md b/README.md index f825a8a..635d27e 100644 --- a/README.md +++ b/README.md @@ -144,3 +144,6 @@ This project relies on the following build-time crates licensed under the Mozill * **[ANISE](https://crates.io/crates/anise)** — Precise orbit and navigation library ([MPL-2.0](https://www.mozilla.org/en-US/MPL/2.0/)) Your use and modification of this repository's source code are governed by the MIT License. If you distribute compiled binaries or packaged builds that include these statically linked crates, the redistribution must satisfy the notice and source-availability requirements of the MPL-2.0 for those components. + +Build-time attribution requires `cargo-about` 0.9.2. Generated bundles are ignored +and copied into browser/Worker outputs; see [attribution setup](tools/notices/README.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f62be22..0d515a9 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,8 @@ Other project source uses the root MIT LICENSE. Dependencies are downloaded separately and retain their own licenses. Before redistributing compiled binaries, browser Wasm, generated JavaScript or bundled dependencies, include the licenses/attributions required by the components -actually shipped. Local builds do not assemble a distribution license bundle. +actually shipped. Browser and Worker build wrappers generate distribution attribution from locked +dependencies; native distributors can use `tools/notices/generate.ts`. MPL-covered hifitime 4.3.0 and native-builder dependency ANISE 0.10.1 additionally require informing recipients where to obtain corresponding MPL source: diff --git a/docs/worker-runbook.md b/docs/worker-runbook.md index e2f3426..5fb77a8 100644 --- a/docs/worker-runbook.md +++ b/docs/worker-runbook.md @@ -546,6 +546,9 @@ reuse them. Removing this demo does not cancel an account's R2 subscription. ## Distribution -Builds produce local development artifacts. Before redistributing compiled -artifacts, include applicable dependency licenses and source-access notices; +Builds generate attribution alongside compiled outputs. Preserve these files +when redistributing artifacts; see [third-party attribution](../THIRD_PARTY_NOTICES.md). + +Build-time attribution requires `cargo-about` 0.9.2. Generated bundles are ignored +and copied into browser/Worker outputs; see [attribution setup](../tools/notices/README.md). diff --git a/examples/cloudflare-worker/build.ts b/examples/cloudflare-worker/build.ts index 6d998e4..3ff2264 100644 --- a/examples/cloudflare-worker/build.ts +++ b/examples/cloudflare-worker/build.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: MIT import { execFileSync } from 'node:child_process'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { generate } from '../../tools/notices/generate.ts'; import { workerBuildVersion } from '../../tools/ci/versions.ts'; const directory = dirname(fileURLToPath(import.meta.url)); @@ -12,3 +13,5 @@ if (flags.length > 1 || flags.some(flag => flag !== '--verification')) throw new const version = execFileSync('worker-build', ['--version'], { encoding: 'utf8' }).trim().split(/\s+/).at(-1); if (version !== workerBuildVersion) throw new Error(`Expected worker-build ${workerBuildVersion}, got ${version}`); execFileSync('worker-build', ['--release', ...(flags.length ? ['--', '--features', 'verification'] : [])], { cwd: directory, stdio: 'inherit' }); + +await generate(resolve(directory, '../..'), 'worker', ['examples/cloudflare-worker/build/', 'examples/cloudflare-worker/build/worker/']); diff --git a/examples/configurable-chart/README.md b/examples/configurable-chart/README.md index b35781d..3ae28fa 100644 --- a/examples/configurable-chart/README.md +++ b/examples/configurable-chart/README.md @@ -77,8 +77,8 @@ serialization and all house methods also receive Wasm smoke checks. ## Distribution -Builds produce local development artifacts. Before redistributing them, include -the applicable dependency licenses and source-access notices described in +Builds generate attribution alongside compiled outputs. Keep those files with +redistributed artifacts; see [third-party attribution](../../THIRD_PARTY_NOTICES.md). ## Optional two-year browser downloads @@ -114,3 +114,6 @@ This is same-engine Wasm parity, not independent astronomical validation. Coefficient bytes are unchanged; arbitrary fractional block origins may still introduce evaluation rounding when rebased. Real-data parity is checked at 1e-12 degrees, with exact design crossing ET equality. + +Build-time attribution requires `cargo-about` 0.9.2. Generated bundles are ignored +and copied into browser/Worker outputs; see [attribution setup](../../tools/notices/README.md). diff --git a/examples/configurable-chart/build-wasm.ts b/examples/configurable-chart/build-wasm.ts index 0e1bf1f..5f423fd 100644 --- a/examples/configurable-chart/build-wasm.ts +++ b/examples/configurable-chart/build-wasm.ts @@ -1,6 +1,8 @@ // Copyright (c) Jack Asher // SPDX-License-Identifier: MIT +import { generate } from '../../tools/notices/generate.ts'; +import { resolve } from 'node:path'; import { wasmBindgenVersion } from '../../tools/ci/versions.ts'; const run = (cmd: string[]) => { @@ -12,3 +14,5 @@ const version = Bun.spawnSync([bindgen, '--version']); if (version.exitCode !== 0 || version.stdout.toString().trim() !== `wasm-bindgen ${wasmBindgenVersion}`) throw new Error(`Expected wasm-bindgen ${wasmBindgenVersion}`); run(['cargo', 'build', '--locked', '--release', '--target', 'wasm32-unknown-unknown', '--manifest-path', 'wasm/Cargo.toml']); run([bindgen, 'wasm/target/wasm32-unknown-unknown/release/configurable_chart_example.wasm', '--target', 'web', '--out-dir', 'engine', '--out-name', 'chart']); + +await generate(resolve(import.meta.dir, '../..'), 'browser', ['examples/configurable-chart/engine/', 'examples/configurable-chart/public/']); diff --git a/examples/configurable-chart/index.html b/examples/configurable-chart/index.html index 9608126..084b275 100644 --- a/examples/configurable-chart/index.html +++ b/examples/configurable-chart/index.html @@ -13,4 +13,5 @@

Your chart will appear here.
- +

Third-party attribution

+ diff --git a/tools/README.md b/tools/README.md index 334c122..58fb145 100644 --- a/tools/README.md +++ b/tools/README.md @@ -220,3 +220,6 @@ adapter continue to accept the full original binary unchanged. Annual overlapping files are a host delivery policy, demonstrated by the [configurable-chart recipe](../examples/configurable-chart/README.md#optional-two-year-browser-downloads). + +Build-time attribution requires `cargo-about` 0.9.2. Generated bundles are ignored +and copied into browser/Worker outputs; see [attribution setup](notices/README.md). diff --git a/tools/ci/changes.ts b/tools/ci/changes.ts index 2c483fa..f8a417c 100644 --- a/tools/ci/changes.ts +++ b/tools/ci/changes.ts @@ -13,7 +13,7 @@ export const selectJobs = (paths: string[]): Selection => { for (const path of paths) { // Legal/build inputs take precedence over documentation exemptions. if (/^(LICENSE|THIRD_PARTY_NOTICES)(\.|$)/.test(path) || - /^(src\/|\.github\/|\.cargo\/|tools\/(ci|verification)\/)/.test(path)) return allJobs(); + /^(src\/|\.github\/|\.cargo\/|tools\/(ci|notices|verification)\/)/.test(path)) return allJobs(); const prose = /^(docs\/.*\.(md|rst|txt)|[^/]+\.md)$/i.test(path) || /(^|\/)(README|AGENTS)\.md$/.test(path); if (prose) continue; const affected: Job[] | undefined = path.startsWith('examples/cloudflare-worker/') ? ['worker'] diff --git a/tools/ci/tsconfig.json b/tools/ci/tsconfig.json index 62b1b91..4bde336 100644 --- a/tools/ci/tsconfig.json +++ b/tools/ci/tsconfig.json @@ -17,6 +17,7 @@ }, "include": [ "*.ts", - "../../examples/cloudflare-worker/build.ts" + "../../examples/cloudflare-worker/build.ts", + "../notices/*.ts" ] } diff --git a/tools/notices/README.md b/tools/notices/README.md new file mode 100644 index 0000000..7ab54e6 --- /dev/null +++ b/tools/notices/README.md @@ -0,0 +1,21 @@ +# Build-time attribution + +Generated `notices/` files are ignored and never committed. Browser and Worker +build wrappers generate attribution from their selected locked dependency graph +and copy it alongside build outputs. They fail if attribution cannot be resolved. +Install `cargo-about` 0.9.2 (`cargo install cargo-about --version 0.9.2 --features cli --locked`), +or set `CARGO_ABOUT` to that executable. Cargo fetches the selected locked graph; +license collection is offline after dependencies are available. + +Rust standard-library attribution is read from the pinned installed toolchain, +or downloaded from its versioned documentation URL, checked against the recorded +SHA-256 and cached under ignored `notices/`. A complete verified cache works offline. +The small supplemental license files here are reviewed source inputs for upstream +packages that omit material or emit JavaScript. They are not generated bundles. + +For native distributions, run `bun tools/notices/generate.ts builder` (or `runtime`) +and include `target/distribution/builder/` (or `runtime/`) with your artifact. +The generator runs anew each build, so no committed snapshot or freshness gate +is needed. Preserve emitted attribution and MPL source links when redistributing; +modified covered dependencies need their actual corresponding modified source. +No first-party source archive is generated or required by this MIT project. diff --git a/tools/notices/about.toml b/tools/notices/about.toml new file mode 100644 index 0000000..22cd40f --- /dev/null +++ b/tools/notices/about.toml @@ -0,0 +1,28 @@ +# Prefer MIT when offered; preserve every term in AND expressions. +accepted = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib", "BSL-1.0", "Unlicense"] +ignore-dev-dependencies = true +ignore-build-dependencies = false + +[hifitime] +accepted = ["MPL-2.0"] +[anise] +accepted = ["MPL-2.0"] + +# wide offers MIT OR Apache-2.0 OR Zlib, but packages only its Zlib text. +# Select that offered alternative rather than synthesizing missing MIT notices. +[wide.clarify] +license = "Zlib" +[[wide.clarify.files]] +path = "LICENSE-ZLIB.md" +license = "Zlib" +checksum = "c43b9a9b1387ed53d2c49263838261129a010e280e3a174a792242c3e2c98db9" + +# First-party source and adapters use MIT. +[astrology-engine] +accepted = ["MIT"] +[dataset-builder] +accepted = ["MIT"] +[astrology-worker-example] +accepted = ["MIT"] +[configurable-chart-example] +accepted = ["MIT"] diff --git a/tools/notices/collect.ts b/tools/notices/collect.ts new file mode 100644 index 0000000..9a9050b --- /dev/null +++ b/tools/notices/collect.ts @@ -0,0 +1,114 @@ +// Copyright (c) Jack Asher +// SPDX-License-Identifier: MIT + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { filesBelow, generatorVersion, profiles, read, sha256, supplements, type PackageNotice, type Profile } from './core.ts'; + +type Crate = { name: string; version: string; source: string | null; manifest_path: string; license: string | null }; +type License = { id: string; text: string; source_path: string | null; used_by: { crate: Crate }[] }; +type Collected = { crates: { package: Crate; license: string }[]; licenses: License[] }; +const sourceUrl = (p: Crate): string => `https://static.crates.io/crates/${p.name}/${p.name}-${p.version}.crate`; +const key = (p: { name: string; version: string }): string => `${p.name}@${p.version}`; +const legalFile = (name: string): boolean => /^(licen[cs]e|copying|notice|copyright|authors)([._-]|$)/i.test(name); +const legalFiles = (directory: string): string[] => { + const walk = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = join(dir, entry.name); + if (entry.isFile() && legalFile(entry.name)) return [path]; + if (entry.isDirectory() && !['.git', 'target', 'tests', 'benches', 'examples'].includes(entry.name)) return walk(path); + return []; + }); + return walk(directory).sort(); +}; +export const validateCollection = (data: Collected, reviewed: Set, firstPartyManifests = new Set()): void => { + for (const { package: p, license } of data.crates) { + if (!p.source) { + if (!firstPartyManifests.has(p.manifest_path) || p.license !== 'MIT') throw new Error(`Unreviewed path dependency: ${key(p)}`); + continue; + } + if (p.source !== 'registry+https://github.com/rust-lang/crates.io-index') throw new Error(`Review unsupported registry/source: ${key(p)}`); + const found = data.licenses.filter(l => l.used_by.some(u => key(u.crate) === key(p))); + if (!p.license || ['Unknown', 'Ignore'].includes(license) || found.length === 0 || found.some(l => !l.text.trim())) { + throw new Error(`Unresolved license: ${key(p)}`); + } + if (found.some(license => !license.source_path) && !reviewed.has(key(p))) { + throw new Error(`Unreviewed synthesized license text: ${key(p)}`); + } + } +}; +export const collect = (root: string, profile: Profile): { text: string; packages: PackageNotice[] } => { + const binary = process.env.CARGO_ABOUT ?? 'cargo-about'; + const version = execFileSync(binary, ['--version'], { encoding: 'utf8' }).trim(); + if (version !== `cargo-about ${generatorVersion}`) throw new Error(`Expected cargo-about ${generatorVersion}; got ${version}`); + const p = profiles[profile]; + const args = ['generate', '--locked', '--offline', '--fail', '--format', 'json', '--manifest-path', p.manifest, '--config', 'tools/notices/about.toml']; + if (p.target) args.push('--target', p.target); + const data: Collected = JSON.parse(execFileSync(binary, args, { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'inherit'] })); + const extra = supplements(root).filter(s => s.profiles.includes(profile)); + for (const entry of extra) { + if (entry.name === 'anise' && !data.crates.some(c => key(c.package) === key(entry))) throw new Error('Update ANISE supplemental attribution for the resolved version.'); + if (entry.name === 'wasm-bindgen-cli-support' && !data.crates.some(c => c.package.name === 'wasm-bindgen' && c.package.version === entry.version)) throw new Error('Update Wasm generator attribution for the resolved version.'); + } + const fileMap = new Map(data.crates.filter(c => c.package.source).map(c => [key(c.package), legalFiles(dirname(c.package.manifest_path))])); + const reviewed: { name: string; version: string; path: string; sha256: string }[] = JSON.parse(read(root, 'tools/notices/reviewed-files.json')); + const verified = new Set(extra.map(key)); + for (const item of reviewed) { + const entry = data.crates.find(c => key(c.package) === key(item)); + if (!entry) continue; + const text = readFileSync(join(dirname(entry.package.manifest_path), item.path), 'utf8'); + if (sha256(text) !== item.sha256) throw new Error(`Reviewed license changed: ${key(item)}/${item.path}`); + verified.add(key(item)); + } + const firstPartyManifests = new Set([ + 'Cargo.toml', 'tools/dataset-builder/Cargo.toml', + 'examples/cloudflare-worker/Cargo.toml', 'examples/configurable-chart/wasm/Cargo.toml', + ].map(path => join(root, path))); + validateCollection(data, verified, firstPartyManifests); + const packages = data.crates.filter(c => c.package.source).map(({ package: p }) => ({ name: p.name, version: p.version, license: p.license!, source: sourceUrl(p) })).sort((a, b) => key(a).localeCompare(key(b), 'en')); + const texts = new Map }>(); + const add = (text: string, label: string) => { + if (!text.trim()) throw new Error(`Empty license material: ${label}`); + const hash = sha256(text); + const current = texts.get(hash) ?? { text, labels: new Set() }; + current.labels.add(label); + texts.set(hash, current); + }; + for (const license of data.licenses) { + const users = license.used_by.filter(u => u.crate.source); + if (users.length) add(license.text, `${license.id}: ${users.map(u => key(u.crate)).sort().join(', ')}`); + } + for (const { package: p } of data.crates) { + if (!p.source) continue; + const dir = dirname(p.manifest_path); + for (const file of fileMap.get(key(p)) ?? []) add(readFileSync(file, 'utf8'), `${key(p)} — ${relative(dir, file)}`); + // libm carries per-routine grants; MPL crates carry copyright headers that + // their standalone license text does not contain. Retain the comment blocks. + if (['libm', 'hifitime', 'anise'].includes(p.name) && existsSync(join(dir, 'src'))) { + for (const file of filesBelow(dir, 'src').filter(f => f.endsWith('.rs'))) { + const source = readFileSync(join(dir, file), 'utf8'); + const blocks = source.match(/\/\*[\s\S]*?\*\/|(?:^[ \t]*\/\/[^\n]*(?:\n|$))+/gm) ?? []; + for (const block of blocks) if (/copyright|permission is hereby|permission to use/i.test(block)) add(block, `${key(p)} — ${file} source notice`); + } + } + } + for (const entry of extra) for (const file of entry.files) add(read(root, file.path), `${key(entry)} — ${file.path.split('/').at(-1)}\nSource: ${entry.source}`); + const header = [ + `Astrology Engine — ${profile} distribution notices`, + 'Generated by cargo-about 0.9.2 and tools/notices/generate.ts. Do not edit this bundle.', + 'This inventory conservatively includes build-time Rust dependencies as well as runtime dependencies; it does not claim every listed package contributes code to the executable.', + 'Astrology Engine and the local adapters are MIT licensed. Third-party components retain their own terms.', + 'MPL source access: obtain each unmodified covered dependency from its exact-version source URL below. If distributing modifications, provide their corresponding MPL source and preserve these notices. Keep source available to recipients.', + 'The project does not modify the dependency source listed here.', + profile === 'builder' ? 'SOFA attribution: through the unchanged sofars Rust port used by ANISE, this builder uses routines and computations derived from software provided by the IAU SOFA Board. The Rust port and this project are not software provided by or endorsed by SOFA. We use the port as a dependency and make no changes to its routines. Its complete upstream terms are reproduced below.' : '', + '\nProject license\n' + read(root, 'LICENSE'), + '\nCopied/adapted Astronomy Engine code\n' + read(root, 'tools/notices/supplements/astronomy-engine-MIT.txt'), + 'Rust 1.96.0 standard-library notices accompany this bundle in RUST_LIBRARY_NOTICES.html. Upstream: https://doc.rust-lang.org/1.96.0/COPYRIGHT-library.html', + '\nDependency source index', + ...packages.map(p => `${key(p)} — ${p.license}\n${p.source}`), + ...extra.map(p => `${key(p)} — additional attribution for upstream notices or emitted JavaScript\n${p.source}`), + '\nLicense and copyright texts (including upstream alternatives and notices)', + ].filter(Boolean).join('\n\n'); + const sections = [...texts.values()].map(t => ({ ...t, heading: [...t.labels].sort().join('\n') })).sort((a,b) => a.heading.localeCompare(b.heading, 'en')); + return { packages, text: header + '\n\n' + sections.map(s => `${'='.repeat(72)}\n${s.heading}\n${'='.repeat(72)}\n\n${s.text}\n`).join('\n') }; +}; diff --git a/tools/notices/core.ts b/tools/notices/core.ts new file mode 100644 index 0000000..badeec3 --- /dev/null +++ b/tools/notices/core.ts @@ -0,0 +1,56 @@ +// Copyright (c) Jack Asher +// SPDX-License-Identifier: MIT + +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; + +export const generatorVersion = '0.9.2'; +import { workerBuildVersion, wasmBindgenVersion } from '../ci/versions.ts'; +export const profiles = { + runtime: { manifest: 'Cargo.toml', target: undefined }, + builder: { manifest: 'tools/dataset-builder/Cargo.toml', target: undefined }, + worker: { manifest: 'examples/cloudflare-worker/Cargo.toml', target: 'wasm32-unknown-unknown' }, + browser: { manifest: 'examples/configurable-chart/wasm/Cargo.toml', target: 'wasm32-unknown-unknown' }, +} as const; +export type Profile = keyof typeof profiles; +export const profileNames = Object.keys(profiles) as Profile[]; +export const sha256 = (value: string | Uint8Array): string => createHash('sha256').update(value).digest('hex'); +export const read = (root: string, path: string): string => readFileSync(join(root, path), 'utf8'); +export const write = (root: string, path: string, value: string): void => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), value); +}; +export const filesBelow = (root: string, directory: string): string[] => + readdirSync(join(root, directory), { withFileTypes: true }).flatMap(entry => { + const path = join(directory, entry.name); + return entry.isDirectory() ? filesBelow(root, path) : entry.isFile() ? [path] : []; + }).sort(); + +export type Supplement = { + name: string; version: string; profiles: Profile[]; source: string; + files: { path: string; sha256: string }[]; +}; +export const supplements = (root: string): Supplement[] => { + const entries: Supplement[] = JSON.parse(read(root, 'tools/notices/supplements.json')); + const expected: Record = { + 'worker-build': workerBuildVersion, 'wasm-bindgen-cli-support': wasmBindgenVersion, + 'rust-standard-library': read(root, 'rust-toolchain.toml').match(/channel = "([^"]+)"/)?.[1] ?? '', + vite: JSON.parse(read(root, 'examples/configurable-chart/package.json')).devDependencies.vite, + }; + for (const entry of entries) { + if (expected[entry.name] && expected[entry.name] !== entry.version) throw new Error(`Update generator attribution: ${entry.name}`); + } + for (const entry of entries) for (const file of entry.files) { + if (sha256(read(root, file.path)) !== file.sha256) throw new Error(`License material changed: ${file.path}`); + } + return entries; +}; + +export const html = (text: string): string => { + const escaped = text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); + const linked = escaped.replace(/https:\/\/[^\s<>]+/g, url => `${url}`); + return `\nThird-party notices

Third-party notices

${linked}
\n`; +}; + +export type PackageNotice = { name: string; version: string; license: string; source: string }; diff --git a/tools/notices/generate.test.ts b/tools/notices/generate.test.ts new file mode 100644 index 0000000..c97330b --- /dev/null +++ b/tools/notices/generate.test.ts @@ -0,0 +1,33 @@ +import { test } from 'bun:test'; +import assert from 'node:assert/strict'; +import { html } from './core.ts'; +import { validateCollection } from './collect.ts'; +import { verifyRustNotice } from './generate.ts'; + +test('unknown and synthesized license results need explicit review', () => { + const p = { name: 'unreviewed', version: '1.0.0', license: 'MIT', manifest_path: '/unused/Cargo.toml', source: 'registry+https://github.com/rust-lang/crates.io-index' }; + const data = { crates: [{ package: p, license: 'MIT' }], licenses: [{ id: 'MIT', text: 'license', source_path: null, used_by: [{ crate: p }] }] }; + assert.throws(() => validateCollection(data, new Set()), /Unreviewed synthesized/); + assert.throws(() => validateCollection({ ...data, licenses: [] }, new Set()), /Unresolved/); +}); +test('HTML treats third-party text as text, not executable markup', () => { + assert.ok(!html('').includes('

Third-party attribution

- + diff --git a/examples/configurable-chart/package.json b/examples/configurable-chart/package.json index 6eb74dc..870e60a 100644 --- a/examples/configurable-chart/package.json +++ b/examples/configurable-chart/package.json @@ -7,7 +7,8 @@ "check": "tsc --noEmit", "dev": "vite --host 127.0.0.1", "build": "bun run build:wasm && vite build --outDir build", - "verify": "bun verify.ts" + "verify": "bun verify.ts", + "package": "bun ../../tools/packaging/browser.ts" }, "devDependencies": { "@types/bun": "1.3.14", diff --git a/examples/configurable-chart/vite.config.ts b/examples/configurable-chart/vite.config.ts index 0267770..70538b2 100644 --- a/examples/configurable-chart/vite.config.ts +++ b/examples/configurable-chart/vite.config.ts @@ -54,4 +54,4 @@ const localEphemeris = (): Plugin => { }; }; -export default defineConfig({ plugins: [localEphemeris()] }); +export default defineConfig(({ mode }) => ({ plugins: [localEphemeris()], publicDir: mode === 'distribution' ? false : 'public', build: { license: mode === 'distribution' ? { fileName: 'licenses/javascript.md' } : false } })); diff --git a/tools/README.md b/tools/README.md index 58fb145..334c122 100644 --- a/tools/README.md +++ b/tools/README.md @@ -220,6 +220,3 @@ adapter continue to accept the full original binary unchanged. Annual overlapping files are a host delivery policy, demonstrated by the [configurable-chart recipe](../examples/configurable-chart/README.md#optional-two-year-browser-downloads). - -Build-time attribution requires `cargo-about` 0.9.2. Generated bundles are ignored -and copied into browser/Worker outputs; see [attribution setup](notices/README.md). diff --git a/tools/ci/changes.ts b/tools/ci/changes.ts index f8a417c..2c483fa 100644 --- a/tools/ci/changes.ts +++ b/tools/ci/changes.ts @@ -13,7 +13,7 @@ export const selectJobs = (paths: string[]): Selection => { for (const path of paths) { // Legal/build inputs take precedence over documentation exemptions. if (/^(LICENSE|THIRD_PARTY_NOTICES)(\.|$)/.test(path) || - /^(src\/|\.github\/|\.cargo\/|tools\/(ci|notices|verification)\/)/.test(path)) return allJobs(); + /^(src\/|\.github\/|\.cargo\/|tools\/(ci|verification)\/)/.test(path)) return allJobs(); const prose = /^(docs\/.*\.(md|rst|txt)|[^/]+\.md)$/i.test(path) || /(^|\/)(README|AGENTS)\.md$/.test(path); if (prose) continue; const affected: Job[] | undefined = path.startsWith('examples/cloudflare-worker/') ? ['worker'] diff --git a/tools/ci/tsconfig.json b/tools/ci/tsconfig.json index 4bde336..62b1b91 100644 --- a/tools/ci/tsconfig.json +++ b/tools/ci/tsconfig.json @@ -17,7 +17,6 @@ }, "include": [ "*.ts", - "../../examples/cloudflare-worker/build.ts", - "../notices/*.ts" + "../../examples/cloudflare-worker/build.ts" ] } diff --git a/tools/notices/README.md b/tools/notices/README.md deleted file mode 100644 index 7ab54e6..0000000 --- a/tools/notices/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Build-time attribution - -Generated `notices/` files are ignored and never committed. Browser and Worker -build wrappers generate attribution from their selected locked dependency graph -and copy it alongside build outputs. They fail if attribution cannot be resolved. -Install `cargo-about` 0.9.2 (`cargo install cargo-about --version 0.9.2 --features cli --locked`), -or set `CARGO_ABOUT` to that executable. Cargo fetches the selected locked graph; -license collection is offline after dependencies are available. - -Rust standard-library attribution is read from the pinned installed toolchain, -or downloaded from its versioned documentation URL, checked against the recorded -SHA-256 and cached under ignored `notices/`. A complete verified cache works offline. -The small supplemental license files here are reviewed source inputs for upstream -packages that omit material or emit JavaScript. They are not generated bundles. - -For native distributions, run `bun tools/notices/generate.ts builder` (or `runtime`) -and include `target/distribution/builder/` (or `runtime/`) with your artifact. -The generator runs anew each build, so no committed snapshot or freshness gate -is needed. Preserve emitted attribution and MPL source links when redistributing; -modified covered dependencies need their actual corresponding modified source. -No first-party source archive is generated or required by this MIT project. diff --git a/tools/notices/about.toml b/tools/notices/about.toml deleted file mode 100644 index 22cd40f..0000000 --- a/tools/notices/about.toml +++ /dev/null @@ -1,28 +0,0 @@ -# Prefer MIT when offered; preserve every term in AND expressions. -accepted = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib", "BSL-1.0", "Unlicense"] -ignore-dev-dependencies = true -ignore-build-dependencies = false - -[hifitime] -accepted = ["MPL-2.0"] -[anise] -accepted = ["MPL-2.0"] - -# wide offers MIT OR Apache-2.0 OR Zlib, but packages only its Zlib text. -# Select that offered alternative rather than synthesizing missing MIT notices. -[wide.clarify] -license = "Zlib" -[[wide.clarify.files]] -path = "LICENSE-ZLIB.md" -license = "Zlib" -checksum = "c43b9a9b1387ed53d2c49263838261129a010e280e3a174a792242c3e2c98db9" - -# First-party source and adapters use MIT. -[astrology-engine] -accepted = ["MIT"] -[dataset-builder] -accepted = ["MIT"] -[astrology-worker-example] -accepted = ["MIT"] -[configurable-chart-example] -accepted = ["MIT"] diff --git a/tools/notices/collect.ts b/tools/notices/collect.ts deleted file mode 100644 index 9a9050b..0000000 --- a/tools/notices/collect.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Jack Asher -// SPDX-License-Identifier: MIT - -import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { dirname, join, relative } from 'node:path'; -import { filesBelow, generatorVersion, profiles, read, sha256, supplements, type PackageNotice, type Profile } from './core.ts'; - -type Crate = { name: string; version: string; source: string | null; manifest_path: string; license: string | null }; -type License = { id: string; text: string; source_path: string | null; used_by: { crate: Crate }[] }; -type Collected = { crates: { package: Crate; license: string }[]; licenses: License[] }; -const sourceUrl = (p: Crate): string => `https://static.crates.io/crates/${p.name}/${p.name}-${p.version}.crate`; -const key = (p: { name: string; version: string }): string => `${p.name}@${p.version}`; -const legalFile = (name: string): boolean => /^(licen[cs]e|copying|notice|copyright|authors)([._-]|$)/i.test(name); -const legalFiles = (directory: string): string[] => { - const walk = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap(entry => { - const path = join(dir, entry.name); - if (entry.isFile() && legalFile(entry.name)) return [path]; - if (entry.isDirectory() && !['.git', 'target', 'tests', 'benches', 'examples'].includes(entry.name)) return walk(path); - return []; - }); - return walk(directory).sort(); -}; -export const validateCollection = (data: Collected, reviewed: Set, firstPartyManifests = new Set()): void => { - for (const { package: p, license } of data.crates) { - if (!p.source) { - if (!firstPartyManifests.has(p.manifest_path) || p.license !== 'MIT') throw new Error(`Unreviewed path dependency: ${key(p)}`); - continue; - } - if (p.source !== 'registry+https://github.com/rust-lang/crates.io-index') throw new Error(`Review unsupported registry/source: ${key(p)}`); - const found = data.licenses.filter(l => l.used_by.some(u => key(u.crate) === key(p))); - if (!p.license || ['Unknown', 'Ignore'].includes(license) || found.length === 0 || found.some(l => !l.text.trim())) { - throw new Error(`Unresolved license: ${key(p)}`); - } - if (found.some(license => !license.source_path) && !reviewed.has(key(p))) { - throw new Error(`Unreviewed synthesized license text: ${key(p)}`); - } - } -}; -export const collect = (root: string, profile: Profile): { text: string; packages: PackageNotice[] } => { - const binary = process.env.CARGO_ABOUT ?? 'cargo-about'; - const version = execFileSync(binary, ['--version'], { encoding: 'utf8' }).trim(); - if (version !== `cargo-about ${generatorVersion}`) throw new Error(`Expected cargo-about ${generatorVersion}; got ${version}`); - const p = profiles[profile]; - const args = ['generate', '--locked', '--offline', '--fail', '--format', 'json', '--manifest-path', p.manifest, '--config', 'tools/notices/about.toml']; - if (p.target) args.push('--target', p.target); - const data: Collected = JSON.parse(execFileSync(binary, args, { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'inherit'] })); - const extra = supplements(root).filter(s => s.profiles.includes(profile)); - for (const entry of extra) { - if (entry.name === 'anise' && !data.crates.some(c => key(c.package) === key(entry))) throw new Error('Update ANISE supplemental attribution for the resolved version.'); - if (entry.name === 'wasm-bindgen-cli-support' && !data.crates.some(c => c.package.name === 'wasm-bindgen' && c.package.version === entry.version)) throw new Error('Update Wasm generator attribution for the resolved version.'); - } - const fileMap = new Map(data.crates.filter(c => c.package.source).map(c => [key(c.package), legalFiles(dirname(c.package.manifest_path))])); - const reviewed: { name: string; version: string; path: string; sha256: string }[] = JSON.parse(read(root, 'tools/notices/reviewed-files.json')); - const verified = new Set(extra.map(key)); - for (const item of reviewed) { - const entry = data.crates.find(c => key(c.package) === key(item)); - if (!entry) continue; - const text = readFileSync(join(dirname(entry.package.manifest_path), item.path), 'utf8'); - if (sha256(text) !== item.sha256) throw new Error(`Reviewed license changed: ${key(item)}/${item.path}`); - verified.add(key(item)); - } - const firstPartyManifests = new Set([ - 'Cargo.toml', 'tools/dataset-builder/Cargo.toml', - 'examples/cloudflare-worker/Cargo.toml', 'examples/configurable-chart/wasm/Cargo.toml', - ].map(path => join(root, path))); - validateCollection(data, verified, firstPartyManifests); - const packages = data.crates.filter(c => c.package.source).map(({ package: p }) => ({ name: p.name, version: p.version, license: p.license!, source: sourceUrl(p) })).sort((a, b) => key(a).localeCompare(key(b), 'en')); - const texts = new Map }>(); - const add = (text: string, label: string) => { - if (!text.trim()) throw new Error(`Empty license material: ${label}`); - const hash = sha256(text); - const current = texts.get(hash) ?? { text, labels: new Set() }; - current.labels.add(label); - texts.set(hash, current); - }; - for (const license of data.licenses) { - const users = license.used_by.filter(u => u.crate.source); - if (users.length) add(license.text, `${license.id}: ${users.map(u => key(u.crate)).sort().join(', ')}`); - } - for (const { package: p } of data.crates) { - if (!p.source) continue; - const dir = dirname(p.manifest_path); - for (const file of fileMap.get(key(p)) ?? []) add(readFileSync(file, 'utf8'), `${key(p)} — ${relative(dir, file)}`); - // libm carries per-routine grants; MPL crates carry copyright headers that - // their standalone license text does not contain. Retain the comment blocks. - if (['libm', 'hifitime', 'anise'].includes(p.name) && existsSync(join(dir, 'src'))) { - for (const file of filesBelow(dir, 'src').filter(f => f.endsWith('.rs'))) { - const source = readFileSync(join(dir, file), 'utf8'); - const blocks = source.match(/\/\*[\s\S]*?\*\/|(?:^[ \t]*\/\/[^\n]*(?:\n|$))+/gm) ?? []; - for (const block of blocks) if (/copyright|permission is hereby|permission to use/i.test(block)) add(block, `${key(p)} — ${file} source notice`); - } - } - } - for (const entry of extra) for (const file of entry.files) add(read(root, file.path), `${key(entry)} — ${file.path.split('/').at(-1)}\nSource: ${entry.source}`); - const header = [ - `Astrology Engine — ${profile} distribution notices`, - 'Generated by cargo-about 0.9.2 and tools/notices/generate.ts. Do not edit this bundle.', - 'This inventory conservatively includes build-time Rust dependencies as well as runtime dependencies; it does not claim every listed package contributes code to the executable.', - 'Astrology Engine and the local adapters are MIT licensed. Third-party components retain their own terms.', - 'MPL source access: obtain each unmodified covered dependency from its exact-version source URL below. If distributing modifications, provide their corresponding MPL source and preserve these notices. Keep source available to recipients.', - 'The project does not modify the dependency source listed here.', - profile === 'builder' ? 'SOFA attribution: through the unchanged sofars Rust port used by ANISE, this builder uses routines and computations derived from software provided by the IAU SOFA Board. The Rust port and this project are not software provided by or endorsed by SOFA. We use the port as a dependency and make no changes to its routines. Its complete upstream terms are reproduced below.' : '', - '\nProject license\n' + read(root, 'LICENSE'), - '\nCopied/adapted Astronomy Engine code\n' + read(root, 'tools/notices/supplements/astronomy-engine-MIT.txt'), - 'Rust 1.96.0 standard-library notices accompany this bundle in RUST_LIBRARY_NOTICES.html. Upstream: https://doc.rust-lang.org/1.96.0/COPYRIGHT-library.html', - '\nDependency source index', - ...packages.map(p => `${key(p)} — ${p.license}\n${p.source}`), - ...extra.map(p => `${key(p)} — additional attribution for upstream notices or emitted JavaScript\n${p.source}`), - '\nLicense and copyright texts (including upstream alternatives and notices)', - ].filter(Boolean).join('\n\n'); - const sections = [...texts.values()].map(t => ({ ...t, heading: [...t.labels].sort().join('\n') })).sort((a,b) => a.heading.localeCompare(b.heading, 'en')); - return { packages, text: header + '\n\n' + sections.map(s => `${'='.repeat(72)}\n${s.heading}\n${'='.repeat(72)}\n\n${s.text}\n`).join('\n') }; -}; diff --git a/tools/notices/core.ts b/tools/notices/core.ts deleted file mode 100644 index badeec3..0000000 --- a/tools/notices/core.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Jack Asher -// SPDX-License-Identifier: MIT - -import { createHash } from 'node:crypto'; -import { readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; - -export const generatorVersion = '0.9.2'; -import { workerBuildVersion, wasmBindgenVersion } from '../ci/versions.ts'; -export const profiles = { - runtime: { manifest: 'Cargo.toml', target: undefined }, - builder: { manifest: 'tools/dataset-builder/Cargo.toml', target: undefined }, - worker: { manifest: 'examples/cloudflare-worker/Cargo.toml', target: 'wasm32-unknown-unknown' }, - browser: { manifest: 'examples/configurable-chart/wasm/Cargo.toml', target: 'wasm32-unknown-unknown' }, -} as const; -export type Profile = keyof typeof profiles; -export const profileNames = Object.keys(profiles) as Profile[]; -export const sha256 = (value: string | Uint8Array): string => createHash('sha256').update(value).digest('hex'); -export const read = (root: string, path: string): string => readFileSync(join(root, path), 'utf8'); -export const write = (root: string, path: string, value: string): void => { - mkdirSync(dirname(join(root, path)), { recursive: true }); - writeFileSync(join(root, path), value); -}; -export const filesBelow = (root: string, directory: string): string[] => - readdirSync(join(root, directory), { withFileTypes: true }).flatMap(entry => { - const path = join(directory, entry.name); - return entry.isDirectory() ? filesBelow(root, path) : entry.isFile() ? [path] : []; - }).sort(); - -export type Supplement = { - name: string; version: string; profiles: Profile[]; source: string; - files: { path: string; sha256: string }[]; -}; -export const supplements = (root: string): Supplement[] => { - const entries: Supplement[] = JSON.parse(read(root, 'tools/notices/supplements.json')); - const expected: Record = { - 'worker-build': workerBuildVersion, 'wasm-bindgen-cli-support': wasmBindgenVersion, - 'rust-standard-library': read(root, 'rust-toolchain.toml').match(/channel = "([^"]+)"/)?.[1] ?? '', - vite: JSON.parse(read(root, 'examples/configurable-chart/package.json')).devDependencies.vite, - }; - for (const entry of entries) { - if (expected[entry.name] && expected[entry.name] !== entry.version) throw new Error(`Update generator attribution: ${entry.name}`); - } - for (const entry of entries) for (const file of entry.files) { - if (sha256(read(root, file.path)) !== file.sha256) throw new Error(`License material changed: ${file.path}`); - } - return entries; -}; - -export const html = (text: string): string => { - const escaped = text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); - const linked = escaped.replace(/https:\/\/[^\s<>]+/g, url => `${url}`); - return `\nThird-party notices

Third-party notices

${linked}
\n`; -}; - -export type PackageNotice = { name: string; version: string; license: string; source: string }; diff --git a/tools/notices/generate.test.ts b/tools/notices/generate.test.ts deleted file mode 100644 index c97330b..0000000 --- a/tools/notices/generate.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { test } from 'bun:test'; -import assert from 'node:assert/strict'; -import { html } from './core.ts'; -import { validateCollection } from './collect.ts'; -import { verifyRustNotice } from './generate.ts'; - -test('unknown and synthesized license results need explicit review', () => { - const p = { name: 'unreviewed', version: '1.0.0', license: 'MIT', manifest_path: '/unused/Cargo.toml', source: 'registry+https://github.com/rust-lang/crates.io-index' }; - const data = { crates: [{ package: p, license: 'MIT' }], licenses: [{ id: 'MIT', text: 'license', source_path: null, used_by: [{ crate: p }] }] }; - assert.throws(() => validateCollection(data, new Set()), /Unreviewed synthesized/); - assert.throws(() => validateCollection({ ...data, licenses: [] }, new Set()), /Unresolved/); -}); -test('HTML treats third-party text as text, not executable markup', () => { - assert.ok(!html('').includes('