diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 594da243b..49ea4dbf0 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -5,6 +5,10 @@ name: Build & Push Docker Image (Prerelease) # :latest-pre, and tags the commit. Nothing moves :latest. Like the stable # release it refuses a commit whose ci-ok is not green. docker.yml with # bump=auto later finalizes the line once main carries it. +# +# A prerelease gets no registration dialect of its own; it carries the one of +# the last stable release, which dev gets by merging main into dev after that +# release. The build warns when that dialect is the original one or old. on: workflow_dispatch: @@ -89,6 +93,27 @@ jobs: echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "$STABLE to $NEW_VERSION" + # A prerelease registers new installations in the dialect of the last + # stable release, which reaches dev when main is merged into dev after + # the release. The issuer takes a dialect for 180 days, so this warns + # (without stopping the build) while dev still speaks the original + # registration or a dialect older than 150 days. + - name: Check the registration dialect's age + run: | + set -eu + file=server/src/nest/service-identity/registration-dialect.ts + id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$file")" + created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$file")" + if [ "$id" = 0 ]; then + message="This build registers in the original dialect, which the issuer accepts only for a transition period. Merge main into dev once a stable release carries a dialect of its own." + else + age=$(( ($(date -u +%s) - $(date -u -d "$created" +%s)) / 86400 )) + [ "$age" -gt 150 ] || { echo "This build registers in dialect $id from $created ($age days old)."; exit 0; } + message="This build registers in dialect $id from $created, $age days old; new installations of it stop registering on $(date -u -d "$created + 180 days" +%Y-%m-%d). Merge main into dev after the next stable release." + fi + echo "::warning::$message" + printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY" + build: runs-on: ${{ matrix.runner }} timeout-minutes: 120 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a57020e31..af2a69f45 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,17 +2,27 @@ name: Build & Push Docker Image # The stable release. Dispatched by hand on main, it: # 1. refuses to run unless ci-ok passed on the main commit it releases, -# 2. works out the version from the tags and the bump input, commits the -# bump to package.json, the lockfile and Chart.yaml, tags it and pushes, -# 3. builds both platforms from exactly that bump commit (never from +# 2. works out the version from the tags and the bump input, +# 3. asks the TREK services issuer for the registration dialect of that +# version and checks it, in a job without write access (a release without +# one ships the dialect already on main, with a warning), +# 4. commits the bump to package.json, the lockfile, Chart.yaml and the +# dialect, tags it and pushes, +# 5. builds both platforms from exactly that bump commit (never from # whatever main points to by then) and publishes :VERSION, # :MAJOR.MINOR, :MAJOR and :latest, -# 4. publishes the Helm chart from the same commit. +# 6. publishes the Helm chart from the same commit. # # Big or risky changes go out as a prerelease first (docker-dev.yml, tagged # :latest-pre and :-pre.N); bump=auto here then finalizes that line. # Without a prerelease in flight there is no default: the run stops until # someone picks patch, minor or major. +# +# Each stable release registers new installations in a dialect of its own, +# which the issuer accepts for 180 days; renewing a key needs none. dev and +# the prereleases built from it carry the dialect of the last stable release, +# so after a release, merge main into dev. docker-dev.yml warns when dev's +# dialect is the original one or getting old. on: workflow_dispatch: @@ -36,13 +46,16 @@ concurrency: cancel-in-progress: false jobs: - version-bump: + version: if: github.repository == 'liketrek/TREK' runs-on: ubuntu-24.04 - timeout-minutes: 120 + timeout-minutes: 30 + permissions: + contents: read + checks: read outputs: version: ${{ steps.bump.outputs.VERSION }} - sha: ${{ steps.bump.outputs.SHA }} + base: ${{ steps.base.outputs.SHA }} steps: - name: Release from main only if: github.ref != 'refs/heads/main' @@ -50,17 +63,17 @@ jobs: echo "::error::Dispatch this workflow on main, not on $GITHUB_REF." exit 1 - - uses: actions/create-github-app-token@v3 - id: app-token - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true - token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + # The bump job checks out exactly this commit, so what ci-ok was checked + # on is what gets released, however long the dialect takes. + - name: Note the commit being released + id: base + run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" # The dev push, the main push and any manual run each leave a ci-ok on # this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of @@ -77,14 +90,13 @@ jobs: gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs" node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha" - - name: Determine bump type and update version + - name: Determine bump type and version id: bump env: BUMP_INPUT: ${{ inputs.bump || 'auto' }} CONFIRM_MAJOR: ${{ inputs.confirm_major }} run: | set -eu - git fetch --tags # Derive version from git tags, no package.json dependency STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1) @@ -143,6 +155,149 @@ jobs: echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "$STABLE to $NEW_VERSION ($BUMP)" + # Fetches the registration dialect of this version from the TREK services + # issuer and checks it (scripts/ci/check-registration-dialect.mjs). The + # issuer's answer runs here, in a job that holds no write token and keeps no + # git credentials; only the checked file goes on, as an artifact. The job + # never fails the release: without a dialect, the release ships the one on + # main, which the issuer accepts for 180 days after it was made. + dialect: + needs: version + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + # Every step either cannot fail or is allowed to, so this job always + # succeeds and never holds up the bump. + - uses: actions/checkout@v6 + continue-on-error: true + with: + ref: ${{ needs.version.outputs.base }} + persist-credentials: false + + - uses: actions/setup-node@v6 + continue-on-error: true + with: + node-version-file: .nvmrc + + # The only step that sees the token. The URL is fixed; the answer is + # bounded in time and size and goes to a file nothing runs yet. + - name: Fetch the dialect from the issuer + id: fetch + env: + TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }} + VERSION: ${{ needs.version.outputs.version }} + run: | + set -uo pipefail + out="$RUNNER_TEMP/registration-dialect.ts" + if [ -z "${TREK_DIALECT_TOKEN:-}" ]; then + echo "::warning::TREK_DIALECT_TOKEN is not set; $VERSION ships the registration dialect already on main." + exit 0 + fi + status=$(curl --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --retry 2 --retry-delay 5 \ + --max-filesize 262144 -H "Authorization: Bearer $TREK_DIALECT_TOKEN" -H 'Content-Type: application/json' \ + --data "{\"version\":\"$VERSION\"}" -o "$out" -w '%{http_code}' https://places.liketrek.com/v1/dialects) \ + || status=000 + if [ "$status" != 200 ] && [ "$status" != 201 ]; then + echo "::warning::The issuer handed out no registration dialect for $VERSION (HTTP $status); it ships the one on main." + rm -f "$out" + exit 0 + fi + if [ "$(wc -c < "$out")" -gt 262144 ]; then + echo "::warning::The issuer's registration dialect for $VERSION is larger than a dialect; it ships the one on main." + rm -f "$out" + exit 0 + fi + echo "file=$out" >> "$GITHUB_OUTPUT" + + - name: Check the dialect + id: check + if: steps.fetch.outputs.file != '' + env: + FILE: ${{ steps.fetch.outputs.file }} + VERSION: ${{ needs.version.outputs.version }} + run: | + set -uo pipefail + if ! node scripts/ci/check-registration-dialect.mjs "$FILE" "$VERSION"; then + echo "::warning::The issuer's registration dialect for $VERSION failed its check; $VERSION ships the one on main." + exit 0 + fi + echo "What changes against the dialect on main:" + git diff --no-index -- server/src/nest/service-identity/registration-dialect.ts "$FILE" || true + echo "ok=true" >> "$GITHUB_OUTPUT" + + - name: Hand the dialect to the bump + if: steps.check.outputs.ok == 'true' + uses: actions/upload-artifact@v6 + continue-on-error: true + with: + name: registration-dialect + path: ${{ steps.fetch.outputs.file }} + if-no-files-found: error + retention-days: 1 + + version-bump: + needs: [version, dialect] + runs-on: ubuntu-24.04 + timeout-minutes: 120 + outputs: + sha: ${{ steps.commit.outputs.SHA }} + steps: + - uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + ref: ${{ needs.version.outputs.base }} + fetch-depth: 0 + fetch-tags: true + token: ${{ steps.app-token.outputs.token }} + + - name: Take over the checked dialect + id: take + uses: actions/download-artifact@v7 + continue-on-error: true + with: + name: registration-dialect + path: ${{ runner.temp }}/dialect + + - name: Place the dialect + env: + VERSION: ${{ needs.version.outputs.version }} + TAKEN: ${{ steps.take.outcome }} + run: | + set -eu + target=server/src/nest/service-identity/registration-dialect.ts + fetched="$RUNNER_TEMP/dialect/registration-dialect.ts" + if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then + cp "$fetched" "$target" + message="$VERSION registers in dialect $(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target"), written by the issuer for it." + echo "$message" + printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target")" + created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")" + if [ "$id" = 0 ]; then + until="the end of the transition period the issuer allows the original registration" + else + until="$(date -u -d "$created + 180 days" +%Y-%m-%d)" + fi + message="$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer accepts for new installations until $until. The issuer's operator can write the dialect by hand; it then ships with the next patch." + echo "::warning::$message" + printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY" + + - name: Update the version, commit, tag and push + id: commit + env: + NEW_VERSION: ${{ needs.version.outputs.version }} + run: | + set -eu + # Update all workspace + root package.json files and the root lockfile in one shot npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml @@ -153,7 +308,8 @@ jobs: # landed on main in the meantime. git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml + git add package.json package-lock.json server/package.json client/package.json shared/package.json \ + charts/trek/Chart.yaml server/src/nest/service-identity/registration-dialect.ts git commit -m "chore: bump version to $NEW_VERSION [skip ci]" git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION" git push origin HEAD:main --follow-tags @@ -164,7 +320,7 @@ jobs: build: runs-on: ${{ matrix.runner }} timeout-minutes: 120 - needs: version-bump + needs: [version, version-bump] strategy: fail-fast: false matrix: @@ -205,7 +361,7 @@ jobs: outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true no-cache: true build-args: | - APP_VERSION=${{ needs.version-bump.outputs.version }} + APP_VERSION=${{ needs.version.outputs.version }} labels: | org.opencontainers.image.revision=${{ steps.meta.outputs.revision }} org.opencontainers.image.created=${{ steps.meta.outputs.created }} @@ -227,7 +383,7 @@ jobs: merge: runs-on: ubuntu-24.04 timeout-minutes: 120 - needs: [version-bump, build] + needs: [version, version-bump, build] steps: - uses: actions/checkout@v6 with: @@ -252,7 +408,7 @@ jobs: working-directory: /tmp/digests run: | set -euo pipefail - VERSION="${{ needs.version-bump.outputs.version }}" + VERSION="${{ needs.version.outputs.version }}" MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)" # :MAJOR.MINOR takes the patches of one minor line and nothing else, # the conservative pin for an install that wants to wait out a diff --git a/scripts/ci/check-registration-dialect.mjs b/scripts/ci/check-registration-dialect.mjs new file mode 100644 index 000000000..8b94922d4 --- /dev/null +++ b/scripts/ci/check-registration-dialect.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +// Checks a registration dialect the issuer handed out, before a release ships it. +// +// docker.yml asks the TREK services issuer for the dialect of the version it +// releases and commits the answer as +// server/src/nest/service-identity/registration-dialect.ts. That file then +// runs inside every TREK server, so it is held to what a dialect is: a few +// hashes over the inputs it is given, nothing else. +// +// 1. Statically, before anything of it runs: the size, the header comment, +// imports from node:crypto, node:zlib and the types module only, none of +// the names that reach the process, the network, the file system, timers +// or code from strings, no computed property access and no backslash (so +// no escape can spell a forbidden name), and the version and id it must +// carry. The generated code needs none of these. +// 2. Then it is imported (Node strips the types) and run on the fixed inputs +// in its SELF_CHECK: the password, the salt, scrypt over them and the +// request must be what the issuer computed, and the request must pass +// the rules the issuer client applies (issuer.client.ts holds the same). +// +// Usage: +// node scripts/ci/check-registration-dialect.mjs +// +// Prints what it found and exits 0 only when the dialect may ship. The release +// job runs it without any secret in its environment. +import { createHash, scryptSync } from 'node:crypto'; +import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const MAX_BYTES = 64 * 1024; +const MAX_LINES = 1000; +const TYPES_MODULE = './registration-dialect.types'; +const RUNTIME_MODULES = new Set(['node:crypto', 'node:zlib']); +const IMPORT_LINE = /^import (type )?\{[\w\s,]+\} from '([^']+)';$/; + +// Each of these reaches past the hashes: the process and its environment, +// modules, the network, timers, or a way to run a string as code. +const FORBIDDEN = [ + /\bprocess\b/, + /\brequire\b/, + /\bimport\s*\(/, + /\bimport\s*\.\s*meta\b/, + /\beval\b/, + /\bFunction\b/, + /\bglobalThis\b/, + /\bglobal\b/, + /\bmodule\s*\./, + /\bexports\s*\./, + /\bfetch\b/, + /\bXMLHttpRequest\b/, + /\bWebSocket\b/, + /\bEventSource\b/, + /\bWorker\b/, + /\bWebAssembly\b/, + /\bchild_process\b/, + /node:(?!crypto'|zlib')/, + /\bset(Timeout|Interval|Immediate)\b/, + /\bqueueMicrotask\b/, + /\bconstructor\b/, + /\bprototype\b/, + /\b__proto__\b/, + /\bReflect\b/, + /\bProxy\b/, + /\bexport\s+default\b/, +]; +// x[...] and x?.[...]; a type annotation's T[] is all the generated code holds. +const COMPUTED_ACCESS = /([\w$)\]]\s*|\?\.\s*)\[(?!\])/; + +const CHALLENGE_PATH = /^\/v1\/register(\/[A-Za-z0-9_-]{12})?\/challenge$/; +const REGISTER_PATH = /^\/v1\/register(\/[A-Za-z0-9_-]{12})?$/; +const DIALECT_HEADER = /^X-TREK-[A-Za-z0-9-]{1,40}$/; +const RESERVED_HEADER = /^X-TREK-(Install|Tier|Legacy|Quota|Instance|Notice)(-|$)/i; +const HEADER_VALUE = /^[\x20-\x7e]{1,256}$/; +const MAX_REGISTRATION_BYTES = 2048; +const LARGEST_NONCE = 2 ** 40; + +const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// What is wrong with the source before it runs; empty when nothing is. +export function staticProblems(source, version) { + const problems = []; + if (Buffer.byteLength(source, 'utf8') > MAX_BYTES) problems.push(`it is larger than ${MAX_BYTES} bytes`); + const lines = source.split('\n'); + if (lines.length > MAX_LINES) problems.push(`it is longer than ${MAX_LINES} lines`); + if (!source.startsWith('/**\n')) problems.push('it does not start with its header comment'); + if (source.includes('\\')) problems.push('it holds a backslash'); + if (source.includes('\r')) problems.push('it holds a carriage return'); + for (const [index, line] of lines.entries()) { + if (!/^\s*(import|export\s+\*|export\s*\{)/.test(line) && !/\bfrom\s+'/.test(line)) continue; + const match = IMPORT_LINE.exec(line); + const allowed = match && (match[1] ? match[2] === TYPES_MODULE : RUNTIME_MODULES.has(match[2])); + if (!allowed) problems.push(`line ${index + 1} imports or re-exports what a dialect does not: ${line.trim()}`); + } + for (const pattern of FORBIDDEN) { + const line = lines.findIndex((text) => pattern.test(text)); + if (line >= 0) problems.push(`line ${line + 1} names what a dialect does not need (${pattern.source})`); + } + const computed = lines.findIndex((text) => COMPUTED_ACCESS.test(text)); + if (computed >= 0) problems.push(`line ${computed + 1} reads a property by a computed name`); + if (!new RegExp(`^ releasedFor: '${escapeRegExp(version)}',$`, 'm').test(source)) { + problems.push(`it is not written for ${version}`); + } + if (!/^const DIALECT = '[A-Za-z0-9_-]{12}';$/m.test(source)) problems.push('it does not name a dialect id'); + if (!/^export const registrationDialect: RegistrationDialect = \{$/m.test(source)) { + problems.push('it does not export registrationDialect'); + } + if (!/^export const SELF_CHECK: DialectSelfCheck = \{$/m.test(source)) problems.push('it does not export SELF_CHECK'); + return problems; +} + +const sha256 = (data) => createHash('sha256').update(data).digest('hex'); + +function requestProblems(path, request) { + const problems = []; + if (!REGISTER_PATH.test(path)) problems.push(`it registers at ${path}`); + for (const [name, value] of Object.entries(request.headers ?? {})) { + if ( + !DIALECT_HEADER.test(name) || + RESERVED_HEADER.test(name) || + typeof value !== 'string' || + !HEADER_VALUE.test(value) + ) { + problems.push(`it sends the header ${name}, which TREK does not send`); + } + } + const body = JSON.stringify(request.body); + if (typeof body !== 'string' || Buffer.byteLength(body, 'utf8') > MAX_REGISTRATION_BYTES) { + problems.push('its body is no registration'); + } + return problems; +} + +// What is wrong with the imported module; empty when nothing is. +export function runtimeProblems(mod, version) { + const dialect = mod.registrationDialect; + const check = mod.SELF_CHECK; + if (!dialect || !check) return ['it exports no registrationDialect or no SELF_CHECK']; + const problems = []; + if (dialect.releasedFor !== version) problems.push(`it is released for ${dialect.releasedFor}, not ${version}`); + if (!/^[A-Za-z0-9_-]{12}$/.test(dialect.id)) problems.push(`its id ${dialect.id} is not an issuer's`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(dialect.createdAt) || Number.isNaN(Date.parse(dialect.createdAt))) { + problems.push(`its date ${dialect.createdAt} is no date`); + } + if (!CHALLENGE_PATH.test(dialect.challengePath) || dialect.challengePath !== `/v1/register/${dialect.id}/challenge`) { + problems.push(`it asks for a challenge at ${dialect.challengePath}`); + } + if (dialect.registerPath !== `/v1/register/${dialect.id}`) problems.push(`it registers at ${dialect.registerPath}`); + + const inputs = { + challenge: check.challenge, + version: check.version, + random: Buffer.from(check.random, 'hex'), + nowSeconds: check.nowSeconds, + }; + const attempt = dialect.begin(inputs); + const { password, salt } = attempt.work(check.nonce); + if (sha256(password) !== check.passwordSha256) problems.push('its password is not the one the issuer computed'); + if (sha256(salt) !== check.saltSha256) problems.push('its salt is not the one the issuer computed'); + const { N, r, p } = check.challenge; + if (scryptSync(password, salt, 32, { N, r, p }).toString('hex') !== check.scrypt) { + problems.push('its proof of work is not the one the issuer computed'); + } + const request = attempt.request(check.nonce); + if (sha256(JSON.stringify({ body: request.body, headers: request.headers })) !== check.requestSha256) { + problems.push('its request is not the one the issuer computed'); + } + problems.push(...requestProblems(dialect.registerPath, request)); + problems.push(...requestProblems(dialect.registerPath, attempt.request(LARGEST_NONCE))); + return [...new Set(problems)]; +} + +// Runs a checked source: Node strips the types of an .mts file and imports it. +async function importDialect(file) { + const dir = mkdtempSync(join(tmpdir(), 'registration-dialect-')); + try { + const copy = join(dir, 'registration-dialect.mts'); + copyFileSync(file, copy); + return await import(pathToFileURL(copy).href); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +export async function checkDialect(file, version) { + const source = readFileSync(file, 'utf8'); + const found = staticProblems(source, version); + if (found.length > 0) return found; + try { + return runtimeProblems(await importDialect(file), version); + } catch (err) { + return [`it fails when run: ${err instanceof Error ? err.message : err}`]; + } +} + +async function main() { + const [file, version] = process.argv.slice(2); + if (!file || !version) throw new Error('usage: check-registration-dialect.mjs '); + const problems = await checkDialect(file, version); + for (const problem of problems) console.log(`The dialect for ${version} is refused: ${problem}.`); + if (problems.length > 0) process.exit(1); + console.log(`The dialect for ${version} computes what the issuer computed.`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + console.error(`::error::${err instanceof Error ? err.message : err}`); + process.exit(1); + }); +} diff --git a/scripts/ci/check-registration-dialect.test.mjs b/scripts/ci/check-registration-dialect.test.mjs new file mode 100644 index 000000000..9c541900d --- /dev/null +++ b/scripts/ci/check-registration-dialect.test.mjs @@ -0,0 +1,87 @@ +import { checkDialect, staticProblems } from './check-registration-dialect.mjs'; + +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// The issuer's sample: every building block a generated dialect can use, for version 9.9.9. +const SAMPLE = fileURLToPath( + new URL('../../server/tests/fixtures/service-identity/registration-dialect.sample.ts', import.meta.url), +); +const source = readFileSync(SAMPLE, 'utf8'); + +const scratch = mkdtempSync(join(tmpdir(), 'registration-dialect-check-')); +after(() => rmSync(scratch, { recursive: true, force: true })); + +let written = 0; +function variant(text) { + written += 1; + const file = join(scratch, `variant-${written}.ts`); + writeFileSync(file, text); + return file; +} + +function replaced(from, to) { + assert.ok(source.includes(from), `the sample no longer holds ${from}`); + return source.replace(from, to); +} + +describe('check-registration-dialect', () => { + it('passes the issuer sample for its own version', async () => { + assert.deepEqual(await checkDialect(SAMPLE, '9.9.9'), []); + }); + + it('refuses a dialect written for another version', async () => { + const problems = await checkDialect(SAMPLE, '9.9.10'); + assert.ok(problems.some((p) => p.includes('not written for 9.9.10'))); + }); + + it('refuses an import from anywhere but node:crypto, node:zlib and the types', () => { + const text = replaced( + "import { crc32 } from 'node:zlib';", + "import { crc32 } from 'node:zlib';\nimport { readFileSync } from 'node:fs';", + ); + assert.ok(staticProblems(text, '9.9.9').some((p) => p.includes("from 'node:fs'"))); + const runtimeTypes = replaced('import type {', 'import {'); + assert.ok(staticProblems(runtimeTypes, '9.9.9').some((p) => p.includes('registration-dialect.types'))); + }); + + it('refuses what reaches the process, the network or code from strings, before running any of it', async () => { + for (const line of ['const env = process.env;', 'void fetch("https://example.org");', 'const run = eval;']) { + const file = variant(replaced('const DIALECT =', `${line}\nconst DIALECT =`)); + const problems = await checkDialect(file, '9.9.9'); + assert.ok( + problems.some((p) => p.includes('names what a dialect does not need')), + line, + ); + } + }); + + it('refuses a computed property name and any escape that could spell one', () => { + const computed = replaced('const DIALECT =', "const name = BASE32['length'];\nconst DIALECT ="); + assert.ok(staticProblems(computed, '9.9.9').some((p) => p.includes('computed name'))); + const escaped = replaced('const DIALECT =', "const name = 'pro\\u0063ess';\nconst DIALECT ="); + assert.ok(staticProblems(escaped, '9.9.9').some((p) => p.includes('backslash'))); + }); + + it('refuses a file without its header or without a dialect id', () => { + assert.ok(staticProblems(source.slice(source.indexOf('import')), '9.9.9').some((p) => p.includes('header'))); + const noId = replaced("const DIALECT = 'KitchenSink0';", "const DIALECT = 'short';"); + assert.ok(staticProblems(noId, '9.9.9').some((p) => p.includes('dialect id'))); + }); + + it('refuses a dialect that does not compute what its SELF_CHECK says', async () => { + const file = variant(replaced("seal11.toString('base64url')", "seal11.toString('hex')")); + const problems = await checkDialect(file, '9.9.9'); + assert.deepEqual(problems, ['its request is not the one the issuer computed']); + }); + + it('refuses a dialect whose request TREK would not send', async () => { + const file = variant(replaced("'X-TREK-Ferry-Seal'", "'X-TREK-Install'")); + const problems = await checkDialect(file, '9.9.9'); + assert.ok(problems.some((p) => p.includes('X-TREK-Install'))); + }); +});