From e3f9544378aae8d58e408c9452ece2297340a5fc Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 21:57:33 +0900 Subject: [PATCH] chore: harden v0.1 release candidate --- .github/workflows/ci.yml | 29 ++++++ .github/workflows/publish.yml | 52 ++++++++++ .gitignore | 1 + CHANGELOG.md | 2 + README.md | 8 +- SECURITY.md | 17 +++- docs/api-contract.md | 21 ++++ docs/releasing.md | 41 ++++++++ package-lock.json | 52 +++++++++- package.json | 30 +++++- scripts/browser-smoke.mjs | 65 ++++++++++++ scripts/release/audit-licenses.mjs | 65 ++++++++++++ scripts/release/audit-package.mjs | 116 ++++++++++++++++++++++ scripts/release/build-artifacts.mjs | 51 ++++++++++ scripts/release/check-rc.mjs | 27 +++++ scripts/release/check-reproducibility.mjs | 44 ++++++++ scripts/release/check-version.mjs | 16 +++ scripts/release/shared.mjs | 32 ++++++ scripts/release/verify-hashes.mjs | 23 +++++ tests/browser/smoke.html | 38 +++++++ tests/release/public-api-contract.test.ts | 39 ++++++++ tsconfig.json | 7 +- tsup.browser.config.ts | 14 +++ tsup.config.ts | 13 +++ 24 files changed, 790 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 docs/api-contract.md create mode 100644 docs/releasing.md create mode 100644 scripts/browser-smoke.mjs create mode 100644 scripts/release/audit-licenses.mjs create mode 100644 scripts/release/audit-package.mjs create mode 100644 scripts/release/build-artifacts.mjs create mode 100644 scripts/release/check-rc.mjs create mode 100644 scripts/release/check-reproducibility.mjs create mode 100644 scripts/release/check-version.mjs create mode 100644 scripts/release/shared.mjs create mode 100644 scripts/release/verify-hashes.mjs create mode 100644 tests/browser/smoke.html create mode 100644 tests/release/public-api-contract.test.ts create mode 100644 tsup.browser.config.ts create mode 100644 tsup.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 324782a..3c68d16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v4 @@ -25,6 +27,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Check formatting + run: npm run format:check + - name: Lint run: npm run lint @@ -39,3 +47,24 @@ jobs: - name: Build run: npm run build + + - name: Browser smoke + run: npm run browser:smoke + + - name: Audit package payload + run: npm run package:audit + + - name: Audit licenses + run: npm run license:audit + + - name: Audit vulnerabilities + run: npm audit --audit-level=high + + - name: Verify version consistency + run: npm run version:check + + - name: Verify reproducible artifacts + run: npm run release:repro + + - name: Verify release hashes + run: npm run release:verify diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..33c5f07 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,52 @@ +name: Publish npm + +on: + push: + tags: + - "v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm + steps: + - name: Check out tagged revision + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Match package version to tag + run: npm run version:check -- --tag "$GITHUB_REF_NAME" + + - name: Validate release candidate + run: | + npm run format:check + npm run lint + npm run typecheck + npm test + npm run fuzz:smoke + npm run build + npm run browser:smoke + npm run package:audit + npm run license:audit + npm audit --audit-level=high + + - name: Publish with provenance + run: npm publish --provenance --access public diff --git a/.gitignore b/.gitignore index d5b7ec9..a609f63 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ coverage/ *.log .DS_Store +/release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b932b..e617542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes will be documented here. The project intends to follow seman ## Unreleased +## 0.1.0 - Release candidate + ### Added - Reproducible fast-check properties for arbitrary-byte inspection, parser mutations, subview isolation, limits, cleaners, and fail-closed verification. diff --git a/README.md b/README.md index 7b1365a..0439340 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,11 @@ JPEG supports bounded inspection, common TIFF/EXIF field decoding, cleaning, and ## Installation -The package is not published. Installation instructions will be added for the first pre-release. +After publication, install from npm: + + npm install secure-metadata + +Node.js 20 or newer is required. Browser consumers may import the secure-metadata/browser entry through a bundler, or deploy the versioned standalone browser artifact from the release candidate on the same origin. The library never loads code from a CDN. ## Public API @@ -48,7 +52,7 @@ The deterministic corpus is supplemented by fixed-seed property tests and a fini ## Security philosophy -Every byte is untrusted. All offsets are interpreted within bounded views, traversal is iterative and limited, and malformed structures fail without unchecked access. PNG image data and compressed metadata are never inflated. Unknown JPEG APP segments, WebP chunks, and PNG ancillary chunks are preserved by default. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), [testing model](docs/testing.md), and [cleaning policy](docs/cleaning-policy.md). +Every byte is untrusted. All offsets are interpreted within bounded views, traversal is iterative and limited, and malformed structures fail without unchecked access. PNG image data and compressed metadata are never inflated. Unknown JPEG APP segments, WebP chunks, and PNG ancillary chunks are preserved by default. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), [testing model](docs/testing.md), [cleaning policy](docs/cleaning-policy.md), [v0.1 API contract](docs/api-contract.md), and [release process](docs/releasing.md). ## Non-goals diff --git a/SECURITY.md b/SECURITY.md index 2fde617..abd797c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,16 +2,23 @@ ## Supported versions -`secure-metadata` is pre-release software and currently has no supported release line. A supported-version table will be added before the first public release. +| Version | Support | +| ------- | ---------------------------------- | +| 0.1.x | Supported after public publication | +| < 0.1 | Not supported | + +The current repository may contain an unpublished release candidate. A candidate is not a supported npm release until publication completes. ## Reporting a vulnerability -Binary parser vulnerabilities should be coordinated privately before public disclosure. If GitHub private vulnerability reporting is enabled for this repository, use **Security → Report a vulnerability**. Do not include a malicious sample or parser details in a public issue. +Binary parser vulnerabilities should be coordinated privately before public disclosure. Use **Security → Report a vulnerability** in this GitHub repository when private vulnerability reporting is available. Do not include a malicious sample or parser details in a public issue. -If private vulnerability reporting is not available, there is not yet a dedicated reporting channel. Maintainers must configure one before the first public release; do not invent or guess a contact address. +If private vulnerability reporting is unavailable, do not guess a maintainer address or open a public report containing exploit details. Repository owners must configure a private channel before publication. -Please include the affected revision, impact, reproduction steps, and the smallest safe test case you can provide. Parser crashes, incorrect or out-of-bounds-style offset logic, unbounded traversal or allocation, and resource exhaustion are security-relevant. +Include the affected version and revision, impact, reproduction steps, and the smallest safe test case possible. Parser crashes, offset or bounds errors, unbounded traversal or allocation, resource exhaustion, cleaner verification failures, and unexpected network or filesystem behavior are security-relevant. ## Threat model -All input bytes are treated as malicious. The library is designed to inspect container and metadata structures without decoding pixels, touching the filesystem, or using the network. Hard limits and bounded reads are core defenses, while cleaner output must be independently parsed and verified. +All input bytes are malicious. The library inspects container and recognized metadata structures without decoding pixels, accessing the filesystem, or using the network. Bounded reads, hard traversal and allocation limits, deterministic reconstruction, and independent output verification are core defenses. + +The library does not decode image pixels, detect steganography or malware, prove provenance, or establish that an image is private. Unknown containers and opaque compressed metadata can remain. See [the security model](docs/security-model.md) and [format support](docs/format-support.md). diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..6c5bc6f --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,21 @@ +# v0.1 API contract + +The `0.1.x` line freezes the package entry points `secure-metadata` and `secure-metadata/browser`. Both expose the same API; the browser entry points to the standalone ESM browser artifact and reuses the package declarations. + +## Runtime exports + +- operations: `inspectMetadata`, `cleanMetadata`, `verifyMetadata`; +- defaults: `DEFAULT_PARSE_LIMITS`, `DEFAULT_CLEANING_POLICY`, and the JPEG, WebP, and PNG cleaning and verification defaults; +- errors: `SecureMetadataError`, `BinaryBoundsError`, `IncompleteJpegError`, `IncompleteWebPError`, `IncompletePngError`, `InputLimitExceededError`, `InvalidParseLimitError`, and `UnsupportedFormatError`. + +The exact 19-name runtime surface is enforced by `tests/release/public-api-contract.test.ts` and by installing the packed tarball into an isolated consumer. + +## Type exports + +The package exports the diagnostic, error-code, parsing-limit, binary-input, policy, result, report, metadata, rational, and verification types declared by `src/index.ts`. Type-only exports do not appear as JavaScript properties. + +## Compatibility policy + +Before `1.0.0`, minor releases may add API. Within `0.1.x`, removing or renaming an export, narrowing accepted inputs, changing documented result meaning, or changing default privacy policy requires a deliberate compatibility review and a version decision. Patch releases may fix incorrect behavior while preserving the documented contract. + +This contract does not promise exhaustive metadata discovery. Unknown containers and opaque compressed payloads remain subject to the documented format and security limitations. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..c31391e --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,41 @@ +# Release process + +This document defines the `v0.1.0` release-candidate process. It does not authorize publishing, tagging, or creating a GitHub release during development. + +## Candidate validation + +Start from a clean commit on the intended release revision, with Node.js 24 and Chromium installed for Playwright. Run: + +```sh +npm run release:check +``` + +The command performs a clean install; formatting, lint, type, unit/property, bounded fuzz, build, real-browser, package, license, and vulnerability checks; validates version consistency; then builds the release artifacts twice in detached clean worktrees and compares every output byte. + +Outputs are written to ignored `release/`: + +- `secure-metadata-0.1.0.tgz` — npm package; +- `secure-metadata-0.1.0.browser.js` — standalone browser ESM artifact; +- `SHA256SUMS` — version, source commit, filenames, and SHA-256 hashes. + +Verify a transferred artifact set with `npm run release:verify`. `npm run package:audit` separately verifies the exact npm payload and imports the packed package through both public entry points. + +The browser artifact is a same-origin deployment asset, not a CDN dependency. Pin it by filename and SHA-256, serve it with a JavaScript MIME type, and retain its source commit association from `SHA256SUMS`. + +## Licensing + +The published package has no runtime dependencies and the bundled JavaScript contains project source only. Dev tooling is audited by `npm run license:audit`; its accepted SPDX set is explicit in that script. No third-party NOTICE file is currently required. Re-run the audit and review bundled content whenever dependencies or build configuration change. + +## Trusted Publishing + +Before the first publication, an npm package owner must configure Trusted Publishing for this repository, the `publish.yml` workflow, and the `npm` GitHub environment. The workflow uses GitHub OIDC (`id-token: write`) and `npm publish --provenance`; it intentionally contains no long-lived npm token. + +After merging an approved release commit: + +1. confirm all required checks pass on the exact commit; +2. create the signed or annotated tag `v0.1.0` on that commit; +3. push the tag and review the publish workflow and npm provenance attestation; +4. create GitHub release notes from `CHANGELOG.md` and attach the independently verified files from `release/` if desired; +5. verify installation from npm and the same-origin browser artifact in a fresh consumer. + +For subsequent releases, update `package.json` and both lockfile version fields together exactly once, and ensure the tag is exactly `v`. diff --git a/package-lock.json b/package-lock.json index eda630e..a4625fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "secure-metadata", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "secure-metadata", - "version": "0.0.0", + "version": "0.1.0", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "eslint": "^10.9.0", "fast-check": "^4.9.0", + "playwright": "^1.62.1", "prettier": "^3.9.6", "tsup": "^8.5.1", "typescript": "5.9.3", @@ -3053,6 +3054,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/package.json b/package.json index f41fd66..4709432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "secure-metadata", - "version": "0.0.0", + "version": "0.1.0", "description": "Deterministic, security-conscious metadata tooling for binary image formats.", "license": "MIT", "type": "module", @@ -11,6 +11,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./browser": { + "types": "./dist/index.d.ts", + "import": "./dist/browser/secure-metadata.js" } }, "files": [ @@ -20,7 +24,7 @@ "CHANGELOG.md" ], "scripts": { - "build": "tsup src/index.ts --format esm --dts --clean --sourcemap", + "build": "tsup && tsup --config tsup.browser.config.ts", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", @@ -28,7 +32,15 @@ "test:watch": "vitest", "fuzz:smoke": "npm run build --silent && node scripts/fuzz.mjs --seed 20260825 --runs 250 --max-bytes 512", "fuzz": "npm run build --silent && node scripts/fuzz.mjs", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "browser:smoke": "node scripts/browser-smoke.mjs", + "package:audit": "node scripts/release/audit-package.mjs", + "license:audit": "node scripts/release/audit-licenses.mjs", + "version:check": "node scripts/release/check-version.mjs", + "release:build": "node scripts/release/build-artifacts.mjs", + "release:repro": "node scripts/release/check-reproducibility.mjs", + "release:verify": "node scripts/release/verify-hashes.mjs", + "release:check": "node scripts/release/check-rc.mjs" }, "engines": { "node": ">=20" @@ -38,6 +50,7 @@ "@types/node": "^24.13.3", "eslint": "^10.9.0", "fast-check": "^4.9.0", + "playwright": "^1.62.1", "prettier": "^3.9.6", "tsup": "^8.5.1", "typescript": "5.9.3", @@ -46,5 +59,16 @@ }, "overrides": { "esbuild": "0.28.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SecureToolsProject/Secure_Metadata.git" + }, + "homepage": "https://github.com/SecureToolsProject/Secure_Metadata#readme", + "bugs": { + "url": "https://github.com/SecureToolsProject/Secure_Metadata/issues" + }, + "publishConfig": { + "access": "public" } } diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs new file mode 100644 index 0000000..c149068 --- /dev/null +++ b/scripts/browser-smoke.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const artifactPath = path.join(root, "dist", "browser", "secure-metadata.js"); +const artifact = await readFile(artifactPath); + +const pageSource = await readFile(path.join(root, "tests/browser/smoke.html")); + +const server = createServer((request, response) => { + if (request.url === "/dist/browser/secure-metadata.js") { + response.writeHead(200, { + "content-type": "text/javascript; charset=utf-8", + }); + response.end(artifact); + return; + } + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(pageSource); +}); + +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); +}); + +const address = server.address(); +assert.notStrictEqual(address, null); +assert.notStrictEqual(typeof address, "string"); +const url = `http://127.0.0.1:${String(address.port)}/`; +const browser = await chromium.launch({ headless: true }); + +try { + const page = await browser.newPage(); + await page.goto(url); + await page.waitForFunction( + () => globalThis.__secureMetadataSmoke !== undefined, + ); + const result = await page.evaluate(() => globalThis.__secureMetadataSmoke); + assert.deepStrictEqual(result, { + ok: true, + format: "jpeg", + inspectionStatus: "container-inspected", + output: [0xff, 0xd8, 0xff, 0xd9], + valid: true, + }); + console.log( + JSON.stringify({ + status: "passed", + browser: "chromium", + artifact: "dist/browser/secure-metadata.js", + bytes: artifact.byteLength, + }), + ); +} finally { + await browser.close(); + await new Promise((resolve, reject) => + server.close((error) => (error === undefined ? resolve() : reject(error))), + ); +} diff --git a/scripts/release/audit-licenses.mjs b/scripts/release/audit-licenses.mjs new file mode 100644 index 0000000..77744c2 --- /dev/null +++ b/scripts/release/audit-licenses.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { readdir, readFile } from "node:fs/promises"; + +const allowed = new Set([ + "0BSD", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BlueOak-1.0.0", + "ISC", + "MIT", + "MPL-2.0", +]); +const rootPackage = JSON.parse(await readFile("package.json", "utf8")); +assert.equal(rootPackage.license, "MIT"); +assert.deepEqual(Object.keys(rootPackage.dependencies ?? {}), []); +assert.match(await readFile("LICENSE", "utf8"), /^MIT License/u); + +const manifests = []; +async function visit(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === ".bin") continue; + const child = path.join(directory, entry.name); + if (entry.name.startsWith("@")) { + await visit(child); + continue; + } + try { + manifests.push( + JSON.parse(await readFile(path.join(child, "package.json"), "utf8")), + ); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + try { + await visit(path.join(child, "node_modules")); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } +} +await visit("node_modules"); + +const unsupported = manifests.flatMap((manifest) => { + const expression = + typeof manifest.license === "string" ? manifest.license : "UNKNOWN"; + const alternatives = expression.replace(/[()]/gu, "").split(/\s+OR\s+/u); + return alternatives.some((license) => allowed.has(license.trim())) + ? [] + : [`${manifest.name}@${manifest.version}: ${expression}`]; +}); +assert.deepEqual( + unsupported, + [], + `unsupported dependency licenses:\n${unsupported.join("\n")}`, +); + +console.log( + JSON.stringify({ + status: "passed", + packages: manifests.length, + runtimeDependencies: 0, + }), +); diff --git a/scripts/release/audit-package.mjs b/scripts/release/audit-package.mjs new file mode 100644 index 0000000..642687d --- /dev/null +++ b/scripts/release/audit-package.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; + +import { npmCommand, run } from "./shared.mjs"; + +const packageJson = JSON.parse(await readFile("package.json", "utf8")); +assert.equal(packageJson.version, "0.1.0"); +assert.equal(packageJson.license, "MIT"); +assert.equal( + packageJson.repository.url, + "git+https://github.com/SecureToolsProject/Secure_Metadata.git", +); +assert.equal(packageJson.publishConfig.access, "public"); +assert.deepEqual(Object.keys(packageJson.dependencies ?? {}), []); + +const expected = [ + "CHANGELOG.md", + "LICENSE", + "README.md", + "dist/browser/secure-metadata.js", + "dist/browser/secure-metadata.js.map", + "dist/index.d.ts", + "dist/index.js", + "dist/index.js.map", + "package.json", +]; +const dryRun = JSON.parse( + run(npmCommand, ["pack", "--dry-run", "--json"], { capture: true }), +); +assert.deepEqual( + dryRun[0].files.map(({ path: file }) => file).sort(), + expected, +); + +const mainSource = await readFile("dist/index.js", "utf8"); +const browserSource = await readFile("dist/browser/secure-metadata.js", "utf8"); +for (const [name, source] of [ + ["main", mainSource], + ["browser", browserSource], +]) { + assert.doesNotMatch( + source, + /from\s+["']node:|require\s*\(|\bfetch\s*\(/u, + `${name} artifact has an external runtime dependency`, + ); +} + +const temp = await mkdtemp(path.join(os.tmpdir(), "secure-metadata-package-")); +try { + await writeFile( + path.join(temp, "package.json"), + JSON.stringify({ + name: "secure-metadata-consumer", + private: true, + type: "module", + }), + ); + const packed = JSON.parse( + run(npmCommand, ["pack", "--pack-destination", temp, "--json"], { + capture: true, + }), + ); + run( + npmCommand, + [ + "install", + "--ignore-scripts", + "--no-package-lock", + "--no-save", + path.join(temp, packed[0].filename), + ], + { cwd: temp }, + ); + const smoke = ` + const expected = ${JSON.stringify([ + "BinaryBoundsError", + "DEFAULT_CLEANING_POLICY", + "DEFAULT_JPEG_CLEANING_POLICY", + "DEFAULT_JPEG_VERIFICATION_POLICY", + "DEFAULT_PARSE_LIMITS", + "DEFAULT_PNG_CLEANING_POLICY", + "DEFAULT_PNG_VERIFICATION_POLICY", + "DEFAULT_WEBP_CLEANING_POLICY", + "DEFAULT_WEBP_VERIFICATION_POLICY", + "IncompleteJpegError", + "IncompletePngError", + "IncompleteWebPError", + "InputLimitExceededError", + "InvalidParseLimitError", + "SecureMetadataError", + "UnsupportedFormatError", + "cleanMetadata", + "inspectMetadata", + "verifyMetadata", + ])}; + for (const specifier of ["secure-metadata", "secure-metadata/browser"]) { + const api = await import(specifier); + if (JSON.stringify(Object.keys(api).sort()) !== JSON.stringify(expected.sort())) throw new Error(specifier); + } + `; + run(process.execPath, ["--input-type=module", "--eval", smoke], { + cwd: temp, + }); +} finally { + await rm(temp, { force: true, recursive: true }); +} + +console.log( + JSON.stringify({ + status: "passed", + files: expected.length, + runtimeDependencies: 0, + }), +); diff --git a/scripts/release/build-artifacts.mjs b/scripts/release/build-artifacts.mjs new file mode 100644 index 0000000..37e7017 --- /dev/null +++ b/scripts/release/build-artifacts.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; + +import { npmCommand, run, sha256 } from "./shared.mjs"; + +const root = process.cwd(); +const packageJson = JSON.parse( + await readFile(path.join(root, "package.json"), "utf8"), +); +const releaseDir = path.join(root, "release"); + +await rm(releaseDir, { force: true, recursive: true }); +await mkdir(releaseDir, { recursive: true }); +run(npmCommand, ["run", "build"]); + +const browserName = `secure-metadata-${packageJson.version}.browser.js`; +await cp( + path.join(root, "dist/browser/secure-metadata.js"), + path.join(releaseDir, browserName), +); +const packJson = run( + npmCommand, + ["pack", "--pack-destination", releaseDir, "--json"], + { capture: true }, +); +const packed = JSON.parse(packJson); +assert.equal(packed.length, 1); +const tarballName = packed[0].filename; +const commit = run("git", ["rev-parse", "HEAD"], { capture: true }); +const files = [browserName, tarballName]; +const hashes = await Promise.all( + files.map((name) => sha256(path.join(releaseDir, name))), +); +const manifest = [ + `# secure-metadata ${packageJson.version}`, + `# commit ${commit}`, + ...files.map((name, index) => `${hashes[index]} ${name}`), + "", +].join("\n"); +await writeFile(path.join(releaseDir, "SHA256SUMS"), manifest, "utf8"); +run(process.execPath, ["scripts/release/verify-hashes.mjs"]); + +console.log( + JSON.stringify({ + status: "passed", + version: packageJson.version, + commit, + files, + }), +); diff --git a/scripts/release/check-rc.mjs b/scripts/release/check-rc.mjs new file mode 100644 index 0000000..4ef3529 --- /dev/null +++ b/scripts/release/check-rc.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; + +import { npmCommand, run } from "./shared.mjs"; + +assert.equal( + run("git", ["status", "--porcelain"], { capture: true }), + "", + "release candidate checks require a clean worktree", +); +const commands = [ + [npmCommand, ["ci"]], + [npmCommand, ["run", "format:check"]], + [npmCommand, ["run", "lint"]], + [npmCommand, ["run", "typecheck"]], + [npmCommand, ["test"]], + [npmCommand, ["run", "fuzz:smoke"]], + [npmCommand, ["run", "build"]], + [npmCommand, ["run", "browser:smoke"]], + [npmCommand, ["run", "package:audit"]], + [npmCommand, ["run", "license:audit"]], + [npmCommand, ["audit", "--audit-level=high"]], + [npmCommand, ["run", "version:check"]], + [npmCommand, ["run", "release:repro"]], + [npmCommand, ["run", "release:verify"]], +]; +for (const [command, args] of commands) run(command, args); +console.log(JSON.stringify({ status: "passed", commands: commands.length })); diff --git a/scripts/release/check-reproducibility.mjs b/scripts/release/check-reproducibility.mjs new file mode 100644 index 0000000..4852ea1 --- /dev/null +++ b/scripts/release/check-reproducibility.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; + +import { npmCommand, run } from "./shared.mjs"; + +assert.equal( + run("git", ["status", "--porcelain"], { capture: true }), + "", + "reproducibility checks require a clean worktree", +); +const temp = await mkdtemp(path.join(os.tmpdir(), "secure-metadata-repro-")); +const trees = [path.join(temp, "a"), path.join(temp, "b")]; +try { + for (const tree of trees) { + run("git", ["worktree", "add", "--detach", tree, "HEAD"]); + run(npmCommand, ["ci"], { cwd: tree }); + run(npmCommand, ["run", "release:build"], { cwd: tree }); + } + const namesA = (await readdir(path.join(trees[0], "release"))).sort(); + const namesB = (await readdir(path.join(trees[1], "release"))).sort(); + assert.deepEqual(namesA, namesB); + for (const name of namesA) { + assert.deepEqual( + await readFile(path.join(trees[0], "release", name)), + await readFile(path.join(trees[1], "release", name)), + `${name} is not reproducible`, + ); + } + await rm("release", { force: true, recursive: true }); + await cp(path.join(trees[0], "release"), "release", { recursive: true }); + console.log(JSON.stringify({ status: "passed", builds: 2, files: namesA })); +} finally { + for (const tree of trees) { + try { + run("git", ["worktree", "remove", "--force", tree]); + } catch { + // Keep the original failure while still attempting all cleanup. + } + } + run("git", ["worktree", "prune"]); + await rm(temp, { force: true, recursive: true }); +} diff --git a/scripts/release/check-version.mjs b/scripts/release/check-version.mjs new file mode 100644 index 0000000..584959b --- /dev/null +++ b/scripts/release/check-version.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const packageJson = JSON.parse(await readFile("package.json", "utf8")); +const lock = JSON.parse(await readFile("package-lock.json", "utf8")); + +assert.equal(packageJson.version, "0.1.0"); +assert.equal(lock.version, packageJson.version); +assert.equal(lock.packages[""].version, packageJson.version); + +const tagIndex = process.argv.indexOf("--tag"); +if (tagIndex !== -1) { + assert.equal(process.argv[tagIndex + 1], `v${packageJson.version}`); +} + +console.log(JSON.stringify({ status: "passed", version: packageJson.version })); diff --git a/scripts/release/shared.mjs b/scripts/release/shared.mjs new file mode 100644 index 0000000..3e2c0eb --- /dev/null +++ b/scripts/release/shared.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +export const npmCommand = "npm"; + +export function run(command, args, options = {}) { + const isWindowsNpm = process.platform === "win32" && command === npmCommand; + const actualCommand = isWindowsNpm ? process.execPath : command; + const actualArgs = isWindowsNpm ? [process.env.npm_execpath, ...args] : args; + const result = spawnSync(actualCommand, actualArgs, { + encoding: "utf8", + stdio: options.capture ? "pipe" : "inherit", + ...options, + }); + if (result.status !== 0) { + if (options.capture) { + process.stderr.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + } + throw new Error( + `${command} ${args.join(" ")} failed with ${String(result.status)}`, + ); + } + return (result.stdout ?? "").trim(); +} + +export async function sha256(file) { + return createHash("sha256") + .update(await readFile(file)) + .digest("hex"); +} diff --git a/scripts/release/verify-hashes.mjs b/scripts/release/verify-hashes.mjs new file mode 100644 index 0000000..f06f3c3 --- /dev/null +++ b/scripts/release/verify-hashes.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { readFile } from "node:fs/promises"; + +import { sha256 } from "./shared.mjs"; + +const directory = path.resolve(process.argv[2] ?? "release"); +const manifest = await readFile(path.join(directory, "SHA256SUMS"), "utf8"); +const entries = manifest + .split(/\r?\n/u) + .filter((line) => line !== "" && !line.startsWith("#")); + +assert.ok( + entries.length >= 2, + "SHA256SUMS must contain both release artifacts", +); +for (const entry of entries) { + const match = /^([a-f0-9]{64}) ([^/\\]+)$/u.exec(entry); + assert.ok(match, `invalid checksum line: ${entry}`); + assert.equal(await sha256(path.join(directory, match[2])), match[1]); +} + +console.log(JSON.stringify({ status: "passed", files: entries.length })); diff --git a/tests/browser/smoke.html b/tests/browser/smoke.html new file mode 100644 index 0000000..5418c37 --- /dev/null +++ b/tests/browser/smoke.html @@ -0,0 +1,38 @@ + + + + + secure-metadata browser smoke + + +

RUNNING

+ + + diff --git a/tests/release/public-api-contract.test.ts b/tests/release/public-api-contract.test.ts new file mode 100644 index 0000000..916c091 --- /dev/null +++ b/tests/release/public-api-contract.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import * as api from "../../src/index.js"; + +const EXPECTED_RUNTIME_EXPORTS = [ + "BinaryBoundsError", + "DEFAULT_CLEANING_POLICY", + "DEFAULT_JPEG_CLEANING_POLICY", + "DEFAULT_JPEG_VERIFICATION_POLICY", + "DEFAULT_PARSE_LIMITS", + "DEFAULT_PNG_CLEANING_POLICY", + "DEFAULT_PNG_VERIFICATION_POLICY", + "DEFAULT_WEBP_CLEANING_POLICY", + "DEFAULT_WEBP_VERIFICATION_POLICY", + "IncompleteJpegError", + "IncompletePngError", + "IncompleteWebPError", + "InputLimitExceededError", + "InvalidParseLimitError", + "SecureMetadataError", + "UnsupportedFormatError", + "cleanMetadata", + "inspectMetadata", + "verifyMetadata", +] as const; + +describe("v0.1 public API contract", () => { + it("exports exactly the frozen runtime surface", () => { + expect(Object.keys(api).sort()).toEqual( + [...EXPECTED_RUNTIME_EXPORTS].sort(), + ); + }); + + it("keeps the three public operations callable", () => { + expect(api.inspectMetadata).toBeTypeOf("function"); + expect(api.cleanMetadata).toBeTypeOf("function"); + expect(api.verifyMetadata).toBeTypeOf("function"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index fe501f1..26ef876 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,5 +20,10 @@ "isolatedModules": true, "skipLibCheck": true }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "tsup.config.ts", + "tsup.browser.config.ts" + ] } diff --git a/tsup.browser.config.ts b/tsup.browser.config.ts new file mode 100644 index 0000000..25a42f1 --- /dev/null +++ b/tsup.browser.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { "secure-metadata": "src/index.ts" }, + outDir: "dist/browser", + format: ["esm"], + platform: "browser", + target: "es2022", + dts: false, + clean: false, + sourcemap: true, + splitting: false, + minify: false, +}); diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..5ae7530 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + platform: "neutral", + target: "es2022", + dts: true, + clean: true, + sourcemap: true, + splitting: false, + minify: false, +});