diff --git a/.github/workflows/ci-nightly-heavy.yml b/.github/workflows/ci-nightly-heavy.yml index 6966149d7b..7755b84ea9 100644 --- a/.github/workflows/ci-nightly-heavy.yml +++ b/.github/workflows/ci-nightly-heavy.yml @@ -1,8 +1,69 @@ name: Nightly CI +# Heavy validation, entered three ways: +# - schedule: validates the maintained branch 1.x.x, with the repository +# variables CI_NIGHTLY_HEAVY_RUN_TEST_MATRIX and CI_NIGHTLY_HEAVY_RUN_SCANNERS +# and the scheduled defaults below. GitHub schedules only the default +# branch's copy of a workflow. +# - workflow_dispatch: validates the selected ref at its event commit, with +# the dispatch inputs. +# - workflow_call: main's callers (C4, part 2 of #184) run this copy on their +# schedule or on demand. A called run gets its caller's github context +# (GitHub's "Reusing workflows" reference), so github.event_name, github.ref +# and github.sha describe main's caller and cannot choose the source or the +# settings. A called run is recognized by its source_ref input instead: only +# workflow_call declares it, and its default is not empty. It validates +# source_ref and honors every input, whatever its caller's event. A caller +# must not pass an empty source_ref; the first step fails a run whose +# source_ref disagrees with GitHub's job.workflow_ref. +# The source is resolved once to a commit; every job checks out and verifies +# that commit, and the summaries record the source_ref input and source_mode +# (maintained, manual, or requested for a called run that names another source). + on: schedule: - cron: '30 3 * * *' + # The inputs mirror workflow_dispatch, plus source_ref. + workflow_call: + inputs: + source_ref: + description: 'Branch, tag or full commit hash to validate; defaults to the maintained branch 1.x.x; must not be empty' + required: false + type: string + default: '1.x.x' + run_test_matrix: + description: 'Run the existing heavy test matrix' + required: false + type: boolean + default: true + run_scanners: + description: 'Run scanners' + required: false + type: boolean + default: true + scanner_set: + description: 'Scanner set' + required: false + type: string + default: minimum + scanner_fail_policy: + description: 'Scanner failure policy' + required: false + type: string + default: infra-and-secrets + skip_build_scanners: + description: 'Skip build-backed scanners such as scan-build' + required: false + type: boolean + default: false + jobs: + description: 'Comma-separated jobs to run (arm32-unit-1, arm32-unit-2, previous-releases, fuzz, scanner); empty runs all' + required: false + type: string + default: '' + secrets: + LIBBITCOINPQC_READ_TOKEN: + required: false workflow_dispatch: inputs: run_test_matrix: @@ -42,9 +103,22 @@ on: required: false type: boolean default: false + jobs: + description: 'Comma-separated jobs to run (arm32-unit-1, arm32-unit-2, previous-releases, fuzz, scanner); empty runs all' + required: false + type: string + default: '' +# Concurrency groups are repository-wide, and every call from main shares +# main's github.ref. A called run's group therefore names its caller's event and +# its source_ref, so callers for different sources, and an on-demand call beside +# the scheduled one, never cancel each other; direct runs keep their groups. +# GitHub's "Reusing workflows" reference warns that a caller and a called +# workflow sharing a group with cancel-in-progress cancel the run already in +# progress, and that github.workflow is the caller's name in a called run: a +# caller must not use a nightly-heavy-call-* group. concurrency: - group: nightly-heavy-${{ github.event_name == 'schedule' && 'nightly-1.x.x' || github.ref }} + group: nightly-heavy-${{ inputs.source_ref != '' && format('call-{0}-{1}', github.event_name, inputs.source_ref) || github.event_name == 'schedule' && 'nightly-1.x.x' || github.ref }} cancel-in-progress: true env: @@ -62,14 +136,81 @@ jobs: name: Resolve source runs-on: ${{ github.repository == 'Qbit-Org/qbit' && fromJSON('["self-hosted", "linux", "x64", "qbit-trusted-ci"]') || fromJSON('["self-hosted", "linux", "x64"]') }} timeout-minutes: 10 + # Exactly one resolver step runs: resolve for a direct run, requested for a + # called one. outputs: - source_mode: ${{ steps.resolve.outputs.source_mode }} - requested_ref: ${{ steps.resolve.outputs.requested_ref }} - requested_ref_name: ${{ steps.resolve.outputs.requested_ref_name }} - resolved_sha: ${{ steps.resolve.outputs.resolved_sha }} + source_mode: ${{ steps.resolve.outputs.source_mode || steps.requested.outputs.source_mode }} + requested_ref: ${{ steps.resolve.outputs.requested_ref || steps.requested.outputs.requested_ref }} + requested_ref_name: ${{ steps.resolve.outputs.requested_ref_name || steps.requested.outputs.requested_ref_name }} + resolved_sha: ${{ steps.resolve.outputs.resolved_sha || steps.requested.outputs.resolved_sha }} + matrix: ${{ steps.select.outputs.matrix }} + matrix_count: ${{ steps.select.outputs.matrix_count }} + selected: ${{ steps.select.outputs.selected }} + run_scanner: ${{ steps.select.outputs.run_scanner }} steps: + # Fail closed when the source_ref rule and GitHub's record of how the run + # started disagree, for example a caller that passes an empty source_ref, + # which GitHub's loose equality cannot tell from no source_ref. The + # Contexts reference says of job.workflow_ref: "For jobs defined directly + # in a workflow file, this is the same as github.workflow_ref. For jobs + # defined in a [reusable workflow], this refers to the reusable workflow + # file." The full refs are compared, including @ref, so that main's own + # ci-nightly-heavy.yml calling this copy is still a called run. The value + # is read from toJSON(job) because actionlint does not know the property; + # where GitHub does not provide it (GitHub Enterprise Server), the check + # is skipped and the summary says so. + - name: Check how the run started + id: trigger + shell: python3 {0} + env: + RUN_KIND: ${{ inputs.source_ref != '' && 'called' || 'direct' }} + SOURCE_REF: ${{ inputs.source_ref }} + JOB_CONTEXT: ${{ toJSON(job) }} + RUN_WORKFLOW_REF: ${{ github.workflow_ref }} + EVENT_NAME: ${{ github.event_name }} + run: | + import json + import os + import sys + + + def fail(message): + print(f"::error::{message}", file=sys.stderr) + sys.exit(1) + + + kind = os.environ["RUN_KIND"] + run_ref = os.environ.get("RUN_WORKFLOW_REF", "") + try: + job = json.loads(os.environ.get("JOB_CONTEXT") or "null") or {} + except ValueError: + fail("the job context is not JSON") + job_ref = job.get("workflow_ref") or "" + if not job_ref: + check = "skipped: job.workflow_ref is not available" + else: + started = "direct" if job_ref == run_ref else "called" + if started == "called" and kind != "called": + fail(f"this run was called (job.workflow_ref {job_ref} is not github.workflow_ref {run_ref}) " + "with an empty source_ref; the caller must pass the branch, tag or commit to validate") + if started == "direct" and kind != "direct": + fail(f"source_ref '{os.environ.get('SOURCE_REF', '')}' is set, but this run was not called " + f"(job.workflow_ref is github.workflow_ref {run_ref})") + check = f"passed: {started}" + with open(os.environ.get("GITHUB_STEP_SUMMARY", os.devnull), "a", encoding="utf8") as summary: + summary.write("## Run\n\n| field | value |\n| --- | --- |\n" + f"| run | {kind} ({'source_ref given' if kind == 'called' else 'no source_ref'}) |\n" + f"| event | {os.environ.get('EVENT_NAME', '')} |\n" + f"| job.workflow_ref | {job_ref or '(not available)'} |\n" + f"| github.workflow_ref | {run_ref} |\n" + f"| cross-check | {check} |\n\n") + + # A direct run: a schedule validates the maintained branch, a dispatch + # its selected ref. This script is identical in the IBD and RPC perf + # workflows. - name: Resolve source id: resolve + if: ${{ inputs.source_ref == '' }} shell: bash env: SOURCE_MODE: ${{ github.event_name == 'schedule' && 'maintained' || 'manual' }} @@ -140,8 +281,169 @@ jobs: echo "| workflow_sha | $WORKFLOW_SHA |" } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + # A called run: its source_ref names the source, resolved exactly as + # ci-pq.yml resolves its own. github.ref and github.sha are the caller's, + # so this step never reads them. + - name: Resolve requested source + id: requested + if: ${{ inputs.source_ref != '' }} + shell: bash + env: + SOURCE_REF: ${{ inputs.source_ref }} + MAINTAINED_BRANCH: 1.x.x + CALLER_EVENT: ${{ github.event_name }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + SOURCE_REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}.git + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Resolve the validation source once per run, before any checkout, so + # every job builds and reports the same immutable commit. source_mode + # is maintained when source_ref names the maintained branch, and + # requested otherwise. + fail() { echo "::error::$*" >&2; exit 1; } + is_full_sha() { printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[0-9a-f]{40}$'; } + + # Sets requested_ref and resolved_sha from a non-empty source_ref, or + # fails the run: a full commit hash is used as is; a branch or tag + # name is looked up on the remote, and an annotated tag is peeled to + # its commit. ci-pq.yml and ci-nightly-heavy.yml (for a called run) + # share this function; ci/checks/test_ci_pq_contract.py keeps the two + # copies identical. + resolve_source_ref() { + local source_ref="$1" candidate status listing matches peeled + local -a candidates patterns auth + if is_full_sha "$source_ref"; then + requested_ref="$source_ref" + resolved_sha="$source_ref" + return 0 + fi + case "$source_ref" in + refs/heads/?*|refs/tags/?*) candidates=("$source_ref") ;; + refs/*) fail "source_ref '$source_ref' must be a branch, a tag or a full commit hash" ;; + *) candidates=("refs/heads/$source_ref" "refs/tags/$source_ref") ;; + esac + patterns=() + for candidate in "${candidates[@]}"; do + git check-ref-format "$candidate" || fail "source_ref '$source_ref' is not a valid ref name" + patterns+=("$candidate" "$candidate^{}") + done + auth=() + if [ -n "${GITHUB_TOKEN:-}" ]; then + auth=(-c "http.extraheader=AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')") + fi + status=0 + listing="$(git ${auth[@]+"${auth[@]}"} ls-remote --exit-code -- "$SOURCE_REPOSITORY_URL" "${patterns[@]}")" || status=$? + case "$status" in + 0) ;; + 2) fail "no branch or tag '$source_ref' in '$SOURCE_REPOSITORY_URL'" ;; + *) fail "could not list refs from '$SOURCE_REPOSITORY_URL' (git exit code $status)" ;; + esac + matches="$(printf '%s\n' "$listing" | awk -v a="${candidates[0]}" -v b="${candidates[1]:-}" '$2 == a || $2 == b { print $2 }')" + [ "$(printf '%s\n' "$matches" | grep -c .)" -eq 1 ] \ + || fail "source_ref '$source_ref' must name exactly one branch or tag, found: ${matches:-none}" + requested_ref="$matches" + peeled="$(printf '%s\n' "$listing" | awk -v ref="$requested_ref^{}" '$2 == ref { print $1 }')" + resolved_sha="${peeled:-$(printf '%s\n' "$listing" | awk -v ref="$requested_ref" '$2 == ref { print $1 }')}" + } + + source_ref="$(printf '%s' "$SOURCE_REF" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + [ -n "$source_ref" ] || fail "source_ref is empty; a called run must name the branch, tag or commit to validate" + [ -n "$MAINTAINED_BRANCH" ] || fail "MAINTAINED_BRANCH is empty" + resolve_source_ref "$source_ref" + if [ "$requested_ref" = "refs/heads/${MAINTAINED_BRANCH}" ]; then + source_mode=maintained + else + source_mode=requested + fi + is_full_sha "$resolved_sha" || fail "resolved source '$resolved_sha' is not a full commit hash" + is_full_sha "$WORKFLOW_SHA" || fail "workflow revision '$WORKFLOW_SHA' is not a full commit hash" + requested_ref_name="${requested_ref#refs/heads/}" + requested_ref_name="${requested_ref_name#refs/tags/}" + + { + echo "source_mode=$source_mode" + echo "requested_ref=$requested_ref" + echo "requested_ref_name=$requested_ref_name" + echo "resolved_sha=$resolved_sha" + } >> "$GITHUB_OUTPUT" + { + echo "## Validation source" + echo + echo "| field | value |" + echo "| --- | --- |" + echo "| source_ref input | $source_ref |" + echo "| source_mode | $source_mode |" + echo "| requested_ref | $requested_ref |" + echo "| resolved_sha | $resolved_sha |" + echo "| caller_event | $CALLER_EVENT |" + echo "| workflow_sha | $WORKFLOW_SHA |" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + + - name: Select jobs + id: select + shell: python3 {0} + env: + JOBS_INPUT: ${{ inputs.jobs }} + # The nightly-matrix entries; "job" is the name the jobs input uses. + JOB_CATALOG: | + [ + {"job": "arm32-unit-1", "name": "32 bit ARM, unit tests shard 1/2, no functional tests", "timeout-minutes": 180, "file-env": "./ci/test/00_setup_env_arm.sh", "ctest_include_range": "1,139", "container-cpus": "8", "container-memory": "48g"}, + {"job": "arm32-unit-2", "name": "32 bit ARM, unit tests shard 2/2, no functional tests", "timeout-minutes": 180, "file-env": "./ci/test/00_setup_env_arm.sh", "ctest_include_range": "140,99999", "container-cpus": "8", "container-memory": "48g"}, + {"job": "previous-releases", "name": "previous releases, depends DEBUG", "timeout-minutes": 180, "file-env": "./ci/test/00_setup_env_native_previous_releases.sh", "container-cpus": "8", "container-memory": "48g"}, + {"job": "fuzz", "name": "fuzzer,address,undefined,integer, no depends", "timeout-minutes": 240, "file-env": "./ci/test/00_setup_env_native_fuzz.sh", "makejobs": "-j12", "container-cpus": "12", "container-memory": "64g", "fuzz-mutate-min-time": "120"} + ] + # Selectable jobs that are not matrix entries; each gets a run_ output. + NON_MATRIX_JOBS: scanner + run: | + # Turn the comma-separated jobs input into the matrix: empty selects + # every job; an empty, unknown or repeated name fails the run. + import json + import os + import sys + + + def fail(message): + print(f"::error::{message}", file=sys.stderr) + sys.exit(1) + + + catalog = json.loads(os.environ["JOB_CATALOG"]) + non_matrix = os.environ.get("NON_MATRIX_JOBS", "").split() + known = [entry["job"] for entry in catalog] + non_matrix + if len(set(known)) != len(known): + fail(f"job catalog repeats a name: {known}") + + raw = os.environ.get("JOBS_INPUT", "") + if raw.strip(): + requested = [name.strip() for name in raw.split(",")] + if "" in requested: + fail(f"jobs input '{raw}' contains an empty name") + repeated = sorted({name for name in requested if requested.count(name) > 1}) + if repeated: + fail(f"jobs input names a job more than once: {', '.join(repeated)}") + unknown = [name for name in requested if name not in known] + if unknown: + fail(f"unknown job(s) {', '.join(unknown)}; known jobs: {', '.join(known)}") + else: + requested = known + + include = [entry for entry in catalog if entry["job"] in requested] + selected = [name for name in known if name in requested] + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf8") as out: + out.write(f"matrix={json.dumps({'include': include}, separators=(',', ':'))}\n") + out.write(f"matrix_count={len(include)}\n") + out.write(f"selected={json.dumps(selected, separators=(',', ':'))}\n") + for name in non_matrix: + out.write(f"run_{name.replace('-', '_')}={'true' if name in requested else 'false'}\n") + with open(os.environ.get("GITHUB_STEP_SUMMARY", os.devnull), "a", encoding="utf8") as summary: + summary.write(f"\nSelected jobs: {', '.join(selected) or 'none'} (jobs input: {raw.strip() or 'all'})\n") + nightly-matrix: - if: ${{ (github.event_name == 'schedule' && vars.CI_NIGHTLY_HEAVY_RUN_TEST_MATRIX != 'false') || (github.event_name == 'workflow_dispatch' && inputs.run_test_matrix) }} + # A direct scheduled run follows the repository variable; a called run or a + # direct dispatch follows its inputs. A called run reports its caller's + # event, so github.event_name alone cannot tell it from a direct one. + if: ${{ ((inputs.source_ref == '' && github.event_name == 'schedule' && vars.CI_NIGHTLY_HEAVY_RUN_TEST_MATRIX != 'false') || ((inputs.source_ref != '' || github.event_name != 'schedule') && inputs.run_test_matrix)) && needs.resolve-source.outputs.matrix_count != '0' }} name: ${{ matrix.name }} needs: resolve-source runs-on: ${{ github.repository == 'Qbit-Org/qbit' && fromJSON('["self-hosted", "linux", "x64", "qbit-trusted-ci"]') || fromJSON('["self-hosted", "linux", "x64"]') }} @@ -165,32 +467,8 @@ jobs: QBIT_FUZZ_MUTATE_MIN_TIME: ${{ matrix.fuzz-mutate-min-time || '' }} strategy: fail-fast: false - matrix: - include: - - name: '32 bit ARM, unit tests shard 1/2, no functional tests' - timeout-minutes: 180 - file-env: './ci/test/00_setup_env_arm.sh' - ctest_include_range: '1,139' - container-cpus: '8' - container-memory: '48g' - - name: '32 bit ARM, unit tests shard 2/2, no functional tests' - timeout-minutes: 180 - file-env: './ci/test/00_setup_env_arm.sh' - ctest_include_range: '140,99999' - container-cpus: '8' - container-memory: '48g' - - name: 'previous releases, depends DEBUG' - timeout-minutes: 180 - file-env: './ci/test/00_setup_env_native_previous_releases.sh' - container-cpus: '8' - container-memory: '48g' - - name: 'fuzzer,address,undefined,integer, no depends' - timeout-minutes: 240 - file-env: './ci/test/00_setup_env_native_fuzz.sh' - makejobs: '-j12' - container-cpus: '12' - container-memory: '64g' - fuzz-mutate-min-time: '120' + # The entries are JOB_CATALOG in resolve-source, filtered by the jobs input. + matrix: ${{ fromJSON(needs.resolve-source.outputs.matrix) }} steps: - name: Require resolved source @@ -215,6 +493,7 @@ jobs: - name: Verify checked-out source shell: bash env: + SOURCE_REF_INPUT: ${{ inputs.source_ref }} SOURCE_MODE: ${{ needs.resolve-source.outputs.source_mode }} REQUESTED_REF: ${{ needs.resolve-source.outputs.requested_ref }} RESOLVED_SHA: ${{ needs.resolve-source.outputs.resolved_sha }} @@ -230,6 +509,7 @@ jobs: { echo "### Checked-out source: ${GITHUB_JOB}${MATRIX_NAME:+ ($MATRIX_NAME)}" echo + echo "- source_ref input: ${SOURCE_REF_INPUT:-(none)}" echo "- source_mode: ${SOURCE_MODE}" echo "- requested_ref: ${REQUESTED_REF}" echo "- resolved_sha: ${RESOLVED_SHA}" @@ -256,7 +536,8 @@ jobs: uses: ./.github/actions/save-caches scanner-readiness: - if: ${{ (github.event_name == 'schedule' && vars.CI_NIGHTLY_HEAVY_RUN_SCANNERS != 'false') || (github.event_name == 'workflow_dispatch' && inputs.run_scanners) }} + # The same rule as nightly-matrix, with the scanner's variable and input. + if: ${{ ((inputs.source_ref == '' && github.event_name == 'schedule' && vars.CI_NIGHTLY_HEAVY_RUN_SCANNERS != 'false') || ((inputs.source_ref != '' || github.event_name != 'schedule') && inputs.run_scanners)) && needs.resolve-source.outputs.run_scanner == 'true' }} name: Scanner needs: resolve-source runs-on: ${{ github.repository == 'Qbit-Org/qbit' && fromJSON('["self-hosted", "linux", "x64", "qbit-trusted-ci"]') || fromJSON('["self-hosted", "linux", "x64"]') }} @@ -264,7 +545,13 @@ jobs: env: SCANNER_SET: ${{ inputs.scanner_set || 'minimum' }} SCANNER_FAIL_POLICY: ${{ inputs.scanner_fail_policy || 'infra-and-secrets' }} - SKIP_BUILD_SCANNERS: ${{ github.event_name == 'schedule' && 'true' || inputs.skip_build_scanners && 'true' || 'false' }} + # A direct scheduled run skips build-backed scanners; a called run or a + # direct dispatch follows skip_build_scanners. + SKIP_BUILD_SCANNERS: ${{ inputs.source_ref == '' && github.event_name == 'schedule' && 'true' || inputs.skip_build_scanners && 'true' || 'false' }} + # The run's purpose, which the caller's event does give: nightly for a + # scheduled run, direct or called by main's scheduled caller, and a manual + # dry run for anything on demand. + SCANNER_MODE: ${{ github.event_name == 'schedule' && 'nightly' || 'manual_dry_run' }} steps: - name: Require resolved source shell: bash @@ -288,6 +575,7 @@ jobs: - name: Verify checked-out source shell: bash env: + SOURCE_REF_INPUT: ${{ inputs.source_ref }} SOURCE_MODE: ${{ needs.resolve-source.outputs.source_mode }} REQUESTED_REF: ${{ needs.resolve-source.outputs.requested_ref }} RESOLVED_SHA: ${{ needs.resolve-source.outputs.resolved_sha }} @@ -302,6 +590,7 @@ jobs: { echo "### Checked-out source: ${GITHUB_JOB}${MATRIX_NAME:+ ($MATRIX_NAME)}" echo + echo "- source_ref input: ${SOURCE_REF_INPUT:-(none)}" echo "- source_mode: ${SOURCE_MODE}" echo "- requested_ref: ${REQUESTED_REF}" echo "- resolved_sha: ${RESOLVED_SHA}" @@ -321,10 +610,7 @@ jobs: LIBBITCOINPQC_READ_TOKEN: ${{ secrets.LIBBITCOINPQC_READ_TOKEN }} run: | set -euo pipefail - mode="nightly" - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - mode="manual_dry_run" - fi + mode="$SCANNER_MODE" git fetch --no-tags origin +main:refs/remotes/origin/main source_commit="$(git rev-parse HEAD)" history_diff_base_ref="origin/main" diff --git a/.github/workflows/ci-pq.yml b/.github/workflows/ci-pq.yml new file mode 100644 index 0000000000..dcf980763b --- /dev/null +++ b/.github/workflows/ci-pq.yml @@ -0,0 +1,602 @@ +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit. + +# Post-quantum transport validation (#184). One reusable workflow, run through +# callers on main, because GitHub schedules and dispatches only workflows whose +# file is on the default branch: +# - main's nightly caller runs it on a schedule with source_ref: 1.x.x; +# - on-demand runs go through main's PQ caller (C4, part 2 of #184), which takes +# workflow_dispatch inputs source_ref and jobs and calls ci-pq.yml@1.x.x, for +# example gh workflow run ci-maintained-pq.yml -f source_ref= -f jobs=aarch64-unit. +# That caller lands on main after this part merges; +# - Full Validation (ci.yml) calls it with aarch64-unit, macos-arm64-unit and +# macos-x86_64-unit, and in a second call with pq-functional, for every change +# that ci/checks/classify_merge_profile.py says needs them. +# The workflow_dispatch trigger below stays for when this file is on main itself; +# until then GitHub rejects gh workflow run ci-pq.yml. +# Jobs: aarch64-unit, s390x-unit (big-endian, emulated), macos-arm64-unit, +# macos-x86_64-unit and pq-functional (the whole functional suite with +# --v2pqtransport). +# The source is resolved once to a commit, every job checks out and verifies +# that commit, and the summaries record it. + +name: PQ CI +run-name: "PQ CI: ${{ inputs.source_ref || github.ref_name }} (${{ inputs.jobs || 'all jobs' }})" + +on: + workflow_call: + inputs: + source_ref: + description: 'Branch, tag or full commit hash to validate; empty validates the triggering commit' + required: false + type: string + default: '' + jobs: + description: 'Comma-separated jobs to run (aarch64-unit, s390x-unit, macos-arm64-unit, macos-x86_64-unit, pq-functional); empty runs all' + required: false + type: string + default: '' + workflow_dispatch: + inputs: + source_ref: + description: 'Branch, tag or full commit hash to validate; empty validates the selected ref' + required: false + type: string + default: '' + jobs: + description: 'Comma-separated jobs to run (aarch64-unit, s390x-unit, macos-arm64-unit, macos-x86_64-unit, pq-functional); empty runs all' + required: false + type: string + default: '' + +permissions: + contents: read + +jobs: + resolve-source: + name: Resolve source and jobs + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + source_mode: ${{ steps.resolve.outputs.source_mode }} + requested_ref: ${{ steps.resolve.outputs.requested_ref }} + resolved_sha: ${{ steps.resolve.outputs.resolved_sha }} + matrix: ${{ steps.select.outputs.matrix }} + matrix_count: ${{ steps.select.outputs.matrix_count }} + selected: ${{ steps.select.outputs.selected }} + run_macos_arm64_unit: ${{ steps.select.outputs.run_macos_arm64_unit }} + run_macos_x86_64_unit: ${{ steps.select.outputs.run_macos_x86_64_unit }} + run_pq_functional: ${{ steps.select.outputs.run_pq_functional }} + steps: + - name: Resolve source + id: resolve + shell: bash + env: + SOURCE_REF: ${{ inputs.source_ref }} + EVENT_REF: ${{ github.ref }} + EVENT_SHA: ${{ github.sha }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + SOURCE_REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}.git + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Resolve the validation source once per run, before any checkout, so + # every job builds and reports the same immutable commit. An empty + # source_ref keeps the triggering ref and commit; otherwise + # resolve_source_ref resolves it. + fail() { echo "::error::$*" >&2; exit 1; } + is_full_sha() { printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[0-9a-f]{40}$'; } + + # Sets requested_ref and resolved_sha from a non-empty source_ref, or + # fails the run: a full commit hash is used as is; a branch or tag + # name is looked up on the remote, and an annotated tag is peeled to + # its commit. ci-pq.yml and ci-nightly-heavy.yml (for a called run) + # share this function; ci/checks/test_ci_pq_contract.py keeps the two + # copies identical. + resolve_source_ref() { + local source_ref="$1" candidate status listing matches peeled + local -a candidates patterns auth + if is_full_sha "$source_ref"; then + requested_ref="$source_ref" + resolved_sha="$source_ref" + return 0 + fi + case "$source_ref" in + refs/heads/?*|refs/tags/?*) candidates=("$source_ref") ;; + refs/*) fail "source_ref '$source_ref' must be a branch, a tag or a full commit hash" ;; + *) candidates=("refs/heads/$source_ref" "refs/tags/$source_ref") ;; + esac + patterns=() + for candidate in "${candidates[@]}"; do + git check-ref-format "$candidate" || fail "source_ref '$source_ref' is not a valid ref name" + patterns+=("$candidate" "$candidate^{}") + done + auth=() + if [ -n "${GITHUB_TOKEN:-}" ]; then + auth=(-c "http.extraheader=AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')") + fi + status=0 + listing="$(git ${auth[@]+"${auth[@]}"} ls-remote --exit-code -- "$SOURCE_REPOSITORY_URL" "${patterns[@]}")" || status=$? + case "$status" in + 0) ;; + 2) fail "no branch or tag '$source_ref' in '$SOURCE_REPOSITORY_URL'" ;; + *) fail "could not list refs from '$SOURCE_REPOSITORY_URL' (git exit code $status)" ;; + esac + matches="$(printf '%s\n' "$listing" | awk -v a="${candidates[0]}" -v b="${candidates[1]:-}" '$2 == a || $2 == b { print $2 }')" + [ "$(printf '%s\n' "$matches" | grep -c .)" -eq 1 ] \ + || fail "source_ref '$source_ref' must name exactly one branch or tag, found: ${matches:-none}" + requested_ref="$matches" + peeled="$(printf '%s\n' "$listing" | awk -v ref="$requested_ref^{}" '$2 == ref { print $1 }')" + resolved_sha="${peeled:-$(printf '%s\n' "$listing" | awk -v ref="$requested_ref" '$2 == ref { print $1 }')}" + } + + source_ref="$(printf '%s' "$SOURCE_REF" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [ -z "$source_ref" ]; then + source_mode=event + requested_ref="$EVENT_REF" + resolved_sha="$EVENT_SHA" + else + resolve_source_ref "$source_ref" + if is_full_sha "$source_ref"; then + source_mode=commit + else + source_mode=ref + fi + fi + + is_full_sha "$resolved_sha" || fail "resolved source '$resolved_sha' is not a full commit hash" + [ -n "$requested_ref" ] || fail "requested ref is empty" + + { + echo "source_mode=$source_mode" + echo "requested_ref=$requested_ref" + echo "resolved_sha=$resolved_sha" + } >> "$GITHUB_OUTPUT" + { + echo "## Validation source" + echo + echo "| field | value |" + echo "| --- | --- |" + echo "| source_ref input | ${source_ref:-(empty)} |" + echo "| source_mode | $source_mode |" + echo "| requested_ref | $requested_ref |" + echo "| resolved_sha | $resolved_sha |" + echo "| event_sha | $EVENT_SHA |" + echo "| workflow_sha | $WORKFLOW_SHA |" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + + - name: Select jobs + id: select + shell: python3 {0} + env: + JOBS_INPUT: ${{ inputs.jobs }} + # Jobs run as matrix entries; "job" is the name the jobs input uses. + JOB_CATALOG: | + [ + { + "job": "aarch64-unit", + "name": "AArch64 unit tests", + "runs-on": "ubuntu-24.04-arm", + "timeout-minutes": 120, + "file-env": "./ci/test/00_setup_env_native_aarch64_pq.sh", + "phase": "mlkem_tests, bip324_tests and net_tests on AArch64 Linux. ML-KEM runs portable C here: its AArch64 assembly has no BTI landing pads." + }, + { + "job": "s390x-unit", + "name": "s390x big-endian unit tests (emulated)", + "runs-on": "ubuntu-24.04", + "timeout-minutes": 180, + "file-env": "./ci/test/00_setup_env_s390x_unit.sh", + "phase": "mlkem_tests, bip324_tests and net_tests cross-built for big-endian s390x and run under qemu-s390x. The timeout stays 180 minutes until runs are measured, then becomes twice the measured time." + } + ] + # Selectable jobs that are not matrix entries; each gets a run_ output. + NON_MATRIX_JOBS: 'macos-arm64-unit macos-x86_64-unit pq-functional' + run: | + # Turn the comma-separated jobs input into the matrix: empty selects + # every job; an empty, unknown or repeated name fails the run. + import json + import os + import sys + + + def fail(message): + print(f"::error::{message}", file=sys.stderr) + sys.exit(1) + + + catalog = json.loads(os.environ["JOB_CATALOG"]) + non_matrix = os.environ.get("NON_MATRIX_JOBS", "").split() + known = [entry["job"] for entry in catalog] + non_matrix + if len(set(known)) != len(known): + fail(f"job catalog repeats a name: {known}") + + raw = os.environ.get("JOBS_INPUT", "") + if raw.strip(): + requested = [name.strip() for name in raw.split(",")] + if "" in requested: + fail(f"jobs input '{raw}' contains an empty name") + repeated = sorted({name for name in requested if requested.count(name) > 1}) + if repeated: + fail(f"jobs input names a job more than once: {', '.join(repeated)}") + unknown = [name for name in requested if name not in known] + if unknown: + fail(f"unknown job(s) {', '.join(unknown)}; known jobs: {', '.join(known)}") + else: + requested = known + + include = [entry for entry in catalog if entry["job"] in requested] + selected = [name for name in known if name in requested] + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf8") as out: + out.write(f"matrix={json.dumps({'include': include}, separators=(',', ':'))}\n") + out.write(f"matrix_count={len(include)}\n") + out.write(f"selected={json.dumps(selected, separators=(',', ':'))}\n") + for name in non_matrix: + out.write(f"run_{name.replace('-', '_')}={'true' if name in requested else 'false'}\n") + with open(os.environ.get("GITHUB_STEP_SUMMARY", os.devnull), "a", encoding="utf8") as summary: + summary.write(f"\nSelected jobs: {', '.join(selected) or 'none'} (jobs input: {raw.strip() or 'all'})\n") + + unit: + name: ${{ matrix.name }} + needs: resolve-source + if: ${{ needs.resolve-source.outputs.matrix_count != '0' }} + runs-on: ${{ matrix.runs-on }} + timeout-minutes: ${{ matrix.timeout-minutes }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.resolve-source.outputs.matrix) }} + env: + CI_FAILFAST_TEST_LEAVE_DANGLING: 1 + DANGER_CI_ON_HOST_FOLDERS: 1 + FILE_ENV: ${{ matrix.file-env }} + # Read by the steps the macOS jobs share, so it is set per job. + MATRIX_NAME: ${{ matrix.name }} + TEST_RUNNER_FAILFAST: 'false' + MAKEJOBS: '-j4' + TEST_RUNNER_JOBS: '4' + CTEST_JOBS: '4' + # GitHub-hosted runners use the public registry and resolvers, not the + # internal registry and tailnet DNS that the self-hosted jobs use. + CI_PROFILE: public + CI_IMAGE_REGISTRY_PREFIX: docker.io/library + CI_ENFORCE_INTERNAL_REGISTRY: '0' + CI_BUILDKIT_DNS_NAMESERVERS: '1.1.1.1,8.8.8.8' + CI_BUILDKIT_DNS_SEARCH_DOMAINS: '' + steps: + - &REQUIRE_SOURCE + name: Require resolved source + shell: bash + env: + RESOLVED_SHA: ${{ needs.resolve-source.outputs.resolved_sha }} + REQUESTED_REF: ${{ needs.resolve-source.outputs.requested_ref }} + run: | + set -euo pipefail + # Refuse to check out anything but the per-run resolved commit; an + # empty ref would silently fall back to the triggering commit. + printf '%s\n' "$RESOLVED_SHA" | LC_ALL=C grep -Eq '^[0-9a-f]{40}$' \ + || { echo "::error::resolved source '$RESOLVED_SHA' is not a full commit hash" >&2; exit 1; } + [ -n "$REQUESTED_REF" ] || { echo "::error::requested ref is empty" >&2; exit 1; } + + - &CHECKOUT_SOURCE + name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + ref: ${{ needs.resolve-source.outputs.resolved_sha }} + fetch-depth: 0 + + - &VERIFY_SOURCE + name: Verify checked-out source + shell: bash + env: + SOURCE_MODE: ${{ needs.resolve-source.outputs.source_mode }} + REQUESTED_REF: ${{ needs.resolve-source.outputs.requested_ref }} + RESOLVED_SHA: ${{ needs.resolve-source.outputs.resolved_sha }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + actual_head="$(git rev-parse --verify 'HEAD^{commit}')" + if [ "$actual_head" != "$RESOLVED_SHA" ]; then + echo "::error::checked-out HEAD $actual_head does not match resolved source $RESOLVED_SHA" >&2 + exit 1 + fi + { + echo "### Checked-out source: ${GITHUB_JOB}${MATRIX_NAME:+ ($MATRIX_NAME)}" + echo + echo "- source_mode: ${SOURCE_MODE}" + echo "- requested_ref: ${REQUESTED_REF}" + echo "- resolved_sha: ${RESOLVED_SHA}" + echo "- actual_head: ${actual_head}" + echo "- workflow_sha: ${WORKFLOW_SHA}" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + + - name: Configure environment + uses: ./.github/actions/configure-environment + + - name: Restore caches + id: restore-cache + uses: ./.github/actions/restore-caches + + - name: Configure Docker + uses: ./.github/actions/configure-docker + with: + cache-provider: gha + + - &CI_SCRIPT + name: CI script + run: ./ci/test_run_all.sh + + - name: Report unit-test evidence + if: ${{ always() }} + shell: bash + env: + RESOLVED_SHA: ${{ needs.resolve-source.outputs.resolved_sha }} + REQUESTED_REF: ${{ needs.resolve-source.outputs.requested_ref }} + PHASE: ${{ matrix.phase }} + run: &REPORT_EVIDENCE | + set -euo pipefail + # ci/test/03_test_script.sh writes this after ctest_evidence.py has + # checked that every expected suite was selected, executed and passed. + evidence="${BASE_BUILD_DIR}/ctest-evidence/summary.md" + { + echo "## ${MATRIX_NAME}" + echo + echo "- source: ${RESOLVED_SHA} (${REQUESTED_REF})" + echo "- ${PHASE}" + echo + if [ -f "$evidence" ]; then + cat "$evidence" + else + echo "No unit-test evidence was produced." + fi + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + [ -f "$evidence" ] || { echo "::error::no unit-test evidence at $evidence" >&2; exit 1; } + + - name: Save caches + uses: ./.github/actions/save-caches + + # macOS runs natively on the host, without Docker, so each architecture is a + # job of its own rather than a matrix entry of unit. Both share unit's source + # checks, CI script and evidence report, and build with WITH_MLKEM_NATIVE=ON: + # these are the only jobs that run ML-KEM's native code on macOS. + macos-arm64-unit: + name: macOS arm64 unit tests + needs: resolve-source + if: ${{ needs.resolve-source.outputs.run_macos_arm64_unit == 'true' }} + runs-on: macos-15 + timeout-minutes: 120 + env: + DANGER_RUN_CI_ON_HOST: 1 + FILE_ENV: ./ci/test/00_setup_env_mac_native_pq.sh + MATRIX_NAME: macOS arm64 unit tests + BASE_ROOT_DIR: ${{ github.workspace }} + TEST_RUNNER_FAILFAST: 'false' + MAKEJOBS: '-j3' + TEST_RUNNER_JOBS: '3' + CTEST_JOBS: '3' + # Read by the steps the macOS jobs share: the runner's uname -m, the + # ML-KEM backend (arithmetic/Keccak) that must run, and the evidence line. + EXPECTED_MACHINE: arm64 + MLKEM_EXPECTED_BACKEND: aarch64-neon/aarch64 + PHASE: mlkem_tests, bip324_tests and net_tests natively on Apple Silicon, where ML-KEM runs its native AArch64 code. + steps: + - *REQUIRE_SOURCE + - *CHECKOUT_SOURCE + - *VERIFY_SOURCE + + - &REQUIRE_MACHINE + name: Require runner architecture + shell: bash + run: | + set -euo pipefail + machine="$(uname -m)" + if [ "$machine" != "$EXPECTED_MACHINE" ]; then + echo "::error::${GITHUB_JOB} must run on ${EXPECTED_MACHINE}, but uname -m reports '$machine'" >&2 + exit 1 + fi + echo "uname -m: $machine" + + # The runner context is not available in job-level env, so the build + # directory, which the later steps read, is set here. + - &SET_BUILD_DIR + name: Set build directory + shell: bash + run: echo "BASE_BUILD_DIR=${RUNNER_TEMP}/build" >> "$GITHUB_ENV" + + # Boost and libevent come from depends (see FILE_ENV), not Homebrew. + # depends needs GNU Make 4, which Homebrew installs as gmake; its gnubin + # directory makes it the make that 03_test_script.sh runs. + - &INSTALL_HOMEBREW_PACKAGES + name: Install Homebrew packages + env: + HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: 1 + run: | + brew install --quiet coreutils ninja pkgconf gnu-getopt ccache make + echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH" + + # 03_test_script.sh builds depends for HOST. Set HOST once here, and ask + # depends for the id of that build (its packages, recipes and compilers), + # so the cache key names exactly what the CI script builds. + - &IDENTIFY_DEPENDS + name: Identify depends build + shell: bash + run: | + set -euo pipefail + make_version="$(make --version | head -n 1)" + case "$make_version" in + "GNU Make "[4-9].*) echo "make: $make_version" ;; + *) echo "::error::depends needs GNU Make 4 or later, but make is '$make_version'" >&2; exit 1 ;; + esac + host="$("${BASE_ROOT_DIR}/depends/config.guess")" + # shellcheck source=/dev/null + source "$FILE_ENV" + # As in 03_test_script.sh, bash -c splits DEP_OPTS into make arguments. + id="$(bash -c "make -s -C '${BASE_ROOT_DIR}/depends' HOST=$host $DEP_OPTS print-final_build_id")" + id="${id#final_build_id=}" + printf '%s\n' "$id" | LC_ALL=C grep -Eq '^[0-9a-f]+$' \ + || { echo "::error::depends gave no build id for $host: '$id'" >&2; exit 1; } + echo "depends build $id for $host" + { + echo "HOST=$host" + echo "DEPENDS_BUILD_ID=$id" + } >> "$GITHUB_ENV" + + # Only the built packages are cached; depends rebuilds any whose id + # does not match. + - &RESTORE_DEPENDS + name: Restore depends cache + id: depends-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: depends/built + key: depends-${{ env.HOST }}-${{ env.DEPENDS_BUILD_ID }} + + - *CI_SCRIPT + + # Saved even when the suites fail, but only once depends wrote the + # toolchain file, which needs every package built. + - &SAVE_DEPENDS + name: Save depends cache + if: ${{ !cancelled() && steps.depends-cache.outputs.cache-hit != 'true' && hashFiles(format('depends/{0}/toolchain.cmake', env.HOST)) != '' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: depends/built + key: ${{ steps.depends-cache.outputs.cache-primary-key }} + + # Which libraries did the suites run against? Require the depends build + # the cache key named, its toolchain, its libevent, and Boost at the + # version depends/packages/boost.mk pins. + - &REQUIRE_DEPENDS_BUILD + name: Require depends build + shell: bash + run: | + set -euo pipefail + fail() { echo "::error::$*" >&2; exit 1; } + prefix="${BASE_ROOT_DIR}/depends/${HOST}" + [ -f "${prefix}/.stamp_${DEPENDS_BUILD_ID}" ] \ + || fail "depends did not build ${DEPENDS_BUILD_ID} for ${HOST}, the build the cache key names" + boost_version="$(sed -n 's/^[$](package)_version *= *//p' "${BASE_ROOT_DIR}/depends/packages/boost.mk")" + [ -n "$boost_version" ] || fail "no Boost version in depends/packages/boost.mk" + # Compares the value of a CMakeCache.txt entry, whatever its type. + require_cache_entry() { + local actual + actual="$(sed -n "s/^$1:[A-Z]*=//p" "${BASE_BUILD_DIR}/CMakeCache.txt")" + [ "$actual" = "$2" ] || fail "$1 must be $2, but the build has: ${actual:-$1 unset}" + } + require_cache_entry CMAKE_TOOLCHAIN_FILE "${prefix}/toolchain.cmake" + require_cache_entry Boost_DIR "${prefix}/lib/cmake/Boost-${boost_version}" + require_cache_entry Libevent_DIR "${prefix}/lib/cmake/libevent" + echo "Built with Boost ${boost_version} and libevent from ${prefix}" + + # The suites passed, but on which code? A runner whose CPU lacks an + # extension the native code needs would pass them on portable C, so + # require the native backend by name. mlkem_tests/backend_selection logs + # the compiled and the active backend. + - &REQUIRE_NATIVE_MLKEM + name: Require native ML-KEM + shell: bash + run: | + set -euo pipefail + expected="compiled ${MLKEM_EXPECTED_BACKEND}, active ${MLKEM_EXPECTED_BACKEND}" + log="$("${BASE_BUILD_DIR}/bin/test_qbit" --run_test=mlkem_tests/backend_selection --log_level=message --color_output=no 2>&1)" \ + || { printf '%s\n' "$log"; echo "::error::mlkem_tests/backend_selection failed" >&2; exit 1; } + printf '%s\n' "$log" + if ! printf '%s\n' "$log" | grep -qxF "$expected"; then + echo "::error::${GITHUB_JOB} must run native ML-KEM, but the test log lacks '${expected}'" >&2 + sysctl machdep.cpu.brand_string machdep.cpu.features machdep.cpu.leaf7_features 2>/dev/null || true + exit 1 + fi + + - &MACOS_REPORT_EVIDENCE + name: Report unit-test evidence + if: ${{ always() }} + shell: bash + env: + RESOLVED_SHA: ${{ needs.resolve-source.outputs.resolved_sha }} + REQUESTED_REF: ${{ needs.resolve-source.outputs.requested_ref }} + run: *REPORT_EVIDENCE + + macos-x86_64-unit: + name: macOS x86_64 unit tests + needs: resolve-source + if: ${{ needs.resolve-source.outputs.run_macos_x86_64_unit == 'true' }} + # GitHub's Intel macOS image (4 CPUs); macos-15 itself is Apple Silicon. + runs-on: macos-15-intel + timeout-minutes: 120 + env: + DANGER_RUN_CI_ON_HOST: 1 + FILE_ENV: ./ci/test/00_setup_env_mac_native_pq.sh + MATRIX_NAME: macOS x86_64 unit tests + BASE_ROOT_DIR: ${{ github.workspace }} + TEST_RUNNER_FAILFAST: 'false' + MAKEJOBS: '-j4' + TEST_RUNNER_JOBS: '4' + CTEST_JOBS: '4' + EXPECTED_MACHINE: x86_64 + MLKEM_EXPECTED_BACKEND: x86_64-avx2/x86_64-avx2 + PHASE: mlkem_tests, bip324_tests and net_tests natively on an Intel Mac, where ML-KEM runs its native x86_64 code. + steps: + - *REQUIRE_SOURCE + - *CHECKOUT_SOURCE + - *VERIFY_SOURCE + - *REQUIRE_MACHINE + - *SET_BUILD_DIR + - *INSTALL_HOMEBREW_PACKAGES + - *IDENTIFY_DEPENDS + - *RESTORE_DEPENDS + - *CI_SCRIPT + - *SAVE_DEPENDS + - *REQUIRE_DEPENDS_BUILD + - *REQUIRE_NATIVE_MLKEM + - *MACOS_REPORT_EVIDENCE + + # The whole functional suite with hybrid post-quantum v2 transport between all + # nodes (--v2pqtransport, #184 part 7), so the feature can't quietly break + # unrelated behavior. The mode passes -v2pqtransport=1 explicitly: with the + # default on, this is still the only whole-suite hybrid run, since the default + # mode runs v1 between nodes and the i686 job of ci.yml plain v2. It shares + # unit's source checks; the test runner's result is the job's. + pq-functional: + name: Functional tests with hybrid transport + needs: resolve-source + if: ${{ needs.resolve-source.outputs.run_pq_functional == 'true' }} + runs-on: ubuntu-24.04 + # Twice a measured local run, rounded up: a cold build at -j 4 on 4 cores + # (475 s) plus the whole suite at -j 4 (727 s) is 1,202 s; x 2 = 40.1 min. + timeout-minutes: 45 + env: + CI_FAILFAST_TEST_LEAVE_DANGLING: 1 + DANGER_CI_ON_HOST_FOLDERS: 1 + FILE_ENV: ./ci/test/00_setup_env_native_pq_functional.sh + MATRIX_NAME: Functional tests with hybrid transport + TEST_RUNNER_FAILFAST: 'false' + MAKEJOBS: '-j4' + TEST_RUNNER_JOBS: '4' + CTEST_JOBS: '4' + CI_PROFILE: public + CI_IMAGE_REGISTRY_PREFIX: docker.io/library + CI_ENFORCE_INTERNAL_REGISTRY: '0' + CI_BUILDKIT_DNS_NAMESERVERS: '1.1.1.1,8.8.8.8' + CI_BUILDKIT_DNS_SEARCH_DOMAINS: '' + steps: + - *REQUIRE_SOURCE + - *CHECKOUT_SOURCE + - *VERIFY_SOURCE + + - name: Configure environment + uses: ./.github/actions/configure-environment + + - name: Restore caches + id: restore-cache + uses: ./.github/actions/restore-caches + + - name: Configure Docker + uses: ./.github/actions/configure-docker + with: + cache-provider: gha + + - name: CI script + run: ./ci/test_run_all.sh + + - name: Save caches + uses: ./.github/actions/save-caches diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c01dbb2097..4a1d3d42b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,8 @@ jobs: profile: ${{ steps.classify.outputs.profile }} release_policy_only: ${{ steps.classify.outputs.release_policy_only }} source_validation_required: ${{ steps.classify.outputs.source_validation_required }} + pq_unit_required: ${{ steps.classify.outputs.pq_unit_required }} + pq_functional_required: ${{ steps.classify.outputs.pq_functional_required }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 @@ -93,7 +95,7 @@ jobs: echo "::error::Pull request base SHA is unavailable." exit 1 fi - git diff --name-only "${BASE_SHA}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "${BASE_SHA}...HEAD" > "${changed_files}" elif [[ "${EVENT_NAME}" == "merge_group" ]]; then base_branch="${BASE_REF#refs/heads/}" if [[ -z "${base_branch}" ]]; then @@ -101,15 +103,15 @@ jobs: exit 1 fi git fetch --no-tags --prune origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" - git diff --name-only "origin/${base_branch}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "origin/${base_branch}...HEAD" > "${changed_files}" elif [[ "${EVENT_NAME}" == "push" ]]; then if [[ -n "${BEFORE_SHA}" && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then - git diff --name-only "${BEFORE_SHA}..HEAD" > "${changed_files}" + git diff --name-only --no-renames "${BEFORE_SHA}..HEAD" > "${changed_files}" else git fetch --no-tags --prune origin "${DEFAULT_BRANCH}" merge_base="$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD 2>/dev/null || true)" if [[ -n "${merge_base}" ]]; then - git diff --name-only "${merge_base}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "${merge_base}...HEAD" > "${changed_files}" else git diff-tree --no-commit-id --name-only -r HEAD > "${changed_files}" fi @@ -973,6 +975,7 @@ jobs: - name: Run public docs lint run: | test/lint/lint-qbit-public-docs.py + test/lint/lint-pq-doc-tokens.py python3 -m unittest \ ci.release.test_verify_p2mr_v1_conformance.CheckedInP2MRIntegrationMatrixTest @@ -1068,6 +1071,36 @@ jobs: path: | build/doc/rpc/** + # Changes to the ML-KEM library, the v2 transport, or their build and CI + # wiring (ci/checks/classify_merge_profile.py) must pass these jobs of the PQ + # workflow on the change itself (for a pull request, its merge commit), on + # GitHub-hosted runners: AArch64 Linux, and macOS on Apple Silicon and on + # Intel, the only jobs that build and run ML-KEM's native code on macOS. + pq-unit: + name: 'PQ unit tests' + needs: classify-changes + if: ${{ needs.classify-changes.outputs.pq_unit_required == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} + uses: ./.github/workflows/ci-pq.yml + with: + source_ref: ${{ github.sha }} + jobs: aarch64-unit,macos-arm64-unit,macos-x86_64-unit + + # Changes that can break hybrid post-quantum v2 transport (every change that + # pq-unit requires, and the hybrid transport paths of + # ci/checks/classify_merge_profile.py that its jobs do not cover) must pass + # the whole functional suite with it, the pq-functional job of the PQ + # workflow, on the change itself, on a GitHub-hosted runner. No other job + # runs the suite hybrid: the ci-matrix jobs run v1 between nodes, and its + # i686 job plain v2. + pq-functional: + name: 'PQ functional tests' + needs: classify-changes + if: ${{ needs.classify-changes.outputs.pq_functional_required == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} + uses: ./.github/workflows/ci-pq.yml + with: + source_ref: ${{ github.sha }} + jobs: pq-functional + full-validation-gate: name: 'Full Validation Gate' runs-on: ubuntu-24.04 @@ -1081,6 +1114,8 @@ jobs: - lint - public-docs-lint - rpc-docs + - pq-unit + - pq-functional if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} steps: - name: Require trusted label for fork PRs @@ -1103,6 +1138,10 @@ jobs: LINT_RESULT: ${{ needs.lint.result }} PUBLIC_DOCS_LINT_RESULT: ${{ needs.public-docs-lint.result }} RPC_DOCS_RESULT: ${{ needs.rpc-docs.result }} + PQ_UNIT_REQUIRED: ${{ needs.classify-changes.outputs.pq_unit_required }} + PQ_UNIT_RESULT: ${{ needs.pq-unit.result }} + PQ_FUNCTIONAL_REQUIRED: ${{ needs.classify-changes.outputs.pq_functional_required }} + PQ_FUNCTIONAL_RESULT: ${{ needs.pq-functional.result }} run: | set -euo pipefail @@ -1118,9 +1157,41 @@ jobs: fi } + # A job that only some changes need may be skipped only when the + # classifier says this change does not need it; a missing or other + # classifier output fails. + check_required_result() { + local name="$1" + local output="$2" + local required="$3" + local result="$4" + + case "${required}" in + true) + check_result "${name}" "${result}" + ;; + false) + echo "${name}: ${result} (not required for this change)" + if [[ "${result}" != "skipped" && "${result}" != "success" ]]; then + echo "::error title=Full Validation failed::${name} result was ${result}." + failed=1 + fi + ;; + *) + echo "::error title=Full Validation failed::The classifier did not report whether ${name} are required (${output}='${required}')." + failed=1 + ;; + esac + } + check_result "classify validation profile" "${CLASSIFY_CHANGES_RESULT}" echo "CI profile: ${CI_PROFILE:-unavailable}" + # The PQ unit jobs: AArch64 Linux, macOS arm64 and x86_64. + check_required_result "PQ unit tests" pq_unit_required "${PQ_UNIT_REQUIRED}" "${PQ_UNIT_RESULT}" + # The whole functional suite with hybrid transport. + check_required_result "PQ functional tests" pq_functional_required "${PQ_FUNCTIONAL_REQUIRED}" "${PQ_FUNCTIONAL_RESULT}" + if [[ "${SOURCE_VALIDATION_REQUIRED}" != "true" ]]; then echo "Validation profile ${VALIDATION_PROFILE} does not require full source validation." exit "${failed}" diff --git a/.github/workflows/core-checks.yml b/.github/workflows/core-checks.yml index 30111a8636..1b2ca4a28f 100644 --- a/.github/workflows/core-checks.yml +++ b/.github/workflows/core-checks.yml @@ -6,6 +6,15 @@ name: Core Checks on: pull_request: + # The same activity types as the Required Merge Gate, which waits for + # its own event's Core Checks Gate instead of an earlier event's failure. + types: + - opened + - synchronize + - reopened + - ready_for_review + - labeled + - unlabeled branches: - main - 1.0.0 @@ -56,6 +65,10 @@ jobs: with: fetch-depth: 0 + # The same git commands as in ci.yml and required-merge-gate.yml, so that + # all three classify a change alike (ci/checks/test_pq_merge_gate_contract.py). + # --no-renames lists both paths of a rename: the old one alone may need + # the source checks. - name: Write changed file list id: changed-files env: @@ -75,7 +88,7 @@ jobs: echo "::error::Pull request base SHA is unavailable." exit 1 fi - git diff --name-only "${BASE_SHA}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "${BASE_SHA}...HEAD" > "${changed_files}" elif [[ "${EVENT_NAME}" == "merge_group" ]]; then base_branch="${BASE_REF#refs/heads/}" if [[ -z "${base_branch}" ]]; then @@ -83,15 +96,15 @@ jobs: exit 1 fi git fetch --no-tags --prune origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" - git diff --name-only "origin/${base_branch}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "origin/${base_branch}...HEAD" > "${changed_files}" elif [[ "${EVENT_NAME}" == "push" ]]; then if [[ -n "${BEFORE_SHA}" && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then - git diff --name-only "${BEFORE_SHA}..HEAD" > "${changed_files}" + git diff --name-only --no-renames "${BEFORE_SHA}..HEAD" > "${changed_files}" else git fetch --no-tags --prune origin "${DEFAULT_BRANCH}" merge_base="$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD 2>/dev/null || true)" if [[ -n "${merge_base}" ]]; then - git diff --name-only "${merge_base}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "${merge_base}...HEAD" > "${changed_files}" else git diff-tree --no-commit-id --name-only -r HEAD > "${changed_files}" fi @@ -131,12 +144,45 @@ jobs: needs: classify-changes if: ${{ needs.classify-changes.outputs.source_validation_required == 'true' }} runs-on: ubuntu-24.04 - timeout-minutes: 45 + # Room for a run that misses the package cache below to download its + # packages from a slower mirror. + timeout-minutes: 90 steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + # Hosted runners fetch packages from an Azure mirror in their own region + # first, and apt falls back to the next mirror only on an error, not when + # a mirror is slow. One region's mirror has served packages at 30 kB/s, + # so this job tries the main archive first and keeps the regional + # mirror as the last fallback. + - name: Prefer the main Ubuntu archive + run: | + if [ -f /etc/apt/apt-mirrors.txt ]; then + sudo sed -i -E 's#^(http://azure\.archive\.ubuntu\.com/ubuntu/[[:space:]]+priority:)[0-9]+$#\19#' /etc/apt/apt-mirrors.txt + cat /etc/apt/apt-mirrors.txt + fi + + # The packages the steps below install, so that a slow mirror delays + # only a run that misses the cache. apt reuses a cached package of the + # expected size without checking its hash, so only push runs save the + # cache, as with the ccache caches: a pull request cannot change what + # another run installs. + - name: Restore apt package cache + id: apt-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ runner.temp }}/apt-archives + key: build-smoke-apt-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.github/workflows/core-checks.yml') }} + restore-keys: build-smoke-apt-${{ runner.os }}-${{ runner.arch }}- + + - name: Keep downloaded packages in the cache directory + run: | + mkdir -p "${RUNNER_TEMP}/apt-archives/partial" + printf 'Dir::Cache::Archives "%s/apt-archives/";\nAPT::Keep-Downloaded-Packages "true";\n' "${RUNNER_TEMP}" \ + | sudo tee /etc/apt/apt.conf.d/90build-smoke-cache > /dev/null + - name: Install build dependencies run: | sudo apt-get update @@ -153,6 +199,38 @@ jobs: - name: Test integrated libbitcoinpqc build policy run: python3 ci/checks/test_libbitcoinpqc_build_policy.py + # Every compiler and tool the build-policy test uses, each listed as + # required so that none of its cases can skip: the AArch64 cross compiler + # proves that AArch64 ELF builds refuse native ML-KEM (no BTI landing + # pads), MinGW and Clang cover the Windows and Clang builds, and cc -m32 + # an x86 target without a backend. The AArch64 C++ cross compiler, its nm + # and qemu-aarch64 run the AArch64 backend's portable-override check: a + # static test binary under emulation. gcc-aarch64-linux-gnu only + # recommends its libc headers, so they are installed explicitly. For -m32, + # libc6-dev-i386 provides the 32-bit headers: gcc-multilib conflicts with + # the AArch64 cross compiler on Ubuntu 24.04. clang-21 comes from + # apt.llvm.org, as in ci/test/01_base_install.sh. + - name: Test integrated mlkem-native build policy + env: + MLKEM_POLICY_REQUIRED_COMPILERS: >- + cc cc-m32 clang-21 aarch64-linux-gnu-gcc aarch64-linux-gnu-g++ aarch64-linux-gnu-nm qemu-aarch64 + x86_64-w64-mingw32-gcc-posix + APT_LLVM_V: 21 + run: | + sudo install -d -m 0755 /etc/apt/keyrings + curl --fail --silent --show-error --location --retry 5 https://apt.llvm.org/llvm-snapshot.gpg.key \ + | sudo tee /etc/apt/keyrings/apt.llvm.org.asc > /dev/null + codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")" + echo "deb [signed-by=/etc/apt/keyrings/apt.llvm.org.asc] http://apt.llvm.org/${codename}/ llvm-toolchain-${codename}-${APT_LLVM_V} main" \ + | sudo tee "/etc/apt/sources.list.d/llvm-toolchain-${codename}-${APT_LLVM_V}.list" > /dev/null + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross qemu-user \ + gcc-mingw-w64-x86-64-posix \ + libc6-dev-i386 \ + "clang-${APT_LLVM_V}" "llvm-${APT_LLVM_V}" + python3 ci/checks/test_mlkem_build_policy.py -v + - name: Test IBD timeout wiring run: python3 ci/checks/test_ibd_timeout_wiring.py @@ -189,6 +267,18 @@ jobs: build/bin/qbitd -version build/bin/qbit-cli -version + # apt leaves its lock and partial directory readable only by root. + - name: Make the apt package cache readable + if: ${{ !cancelled() && github.event_name != 'pull_request' && steps.apt-cache.outputs.cache-hit != 'true' }} + run: sudo chown -R "$(id -u):$(id -g)" "${RUNNER_TEMP}/apt-archives" + + - name: Save apt package cache + if: ${{ !cancelled() && github.event_name != 'pull_request' && steps.apt-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ runner.temp }}/apt-archives + key: ${{ steps.apt-cache.outputs.cache-primary-key }} + focused-unit-suites: name: focused unit suites needs: classify-changes @@ -402,6 +492,7 @@ jobs: with: fetch-depth: 0 + # --no-renames, so that moving a release file away still runs the tests. - name: Detect release file changes id: release_changes env: @@ -417,11 +508,11 @@ jobs: git fetch --no-tags --prune origin main merge_base="$(git merge-base origin/main HEAD 2>/dev/null || true)" if [[ -n "${merge_base}" ]]; then - git diff --name-only "${merge_base}" HEAD > "${changed_files}" + git diff --name-only --no-renames "${merge_base}" HEAD > "${changed_files}" else git diff-tree --no-commit-id --name-only -r HEAD > "${changed_files}" fi - elif ! git diff --name-only "${before}" HEAD > "${changed_files}"; then + elif ! git diff --name-only --no-renames "${before}" HEAD > "${changed_files}"; then echo "Could not diff push range; running release validator tests." echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 @@ -436,7 +527,7 @@ jobs: fi git fetch --no-tags --prune origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" - if ! git diff --name-only "origin/${base_branch}...HEAD" > "${changed_files}"; then + if ! git diff --name-only --no-renames "origin/${base_branch}...HEAD" > "${changed_files}"; then echo "Could not diff against origin/${base_branch}; running release validator tests." echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 diff --git a/.github/workflows/required-merge-gate.yml b/.github/workflows/required-merge-gate.yml index 2fc2fa372b..2a71ff23df 100644 --- a/.github/workflows/required-merge-gate.yml +++ b/.github/workflows/required-merge-gate.yml @@ -37,6 +37,9 @@ on: permissions: contents: read checks: read + # The gate reads workflow runs, its own and those behind the checks it + # waits for, to tell which event produced a failed check. + actions: read concurrency: group: required-merge-gate-${{ github.event_name != 'pull_request' && github.run_id || github.ref }} @@ -83,7 +86,7 @@ jobs: echo "::error::Pull request base SHA is unavailable." exit 1 fi - git diff --name-only "${BASE_SHA}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "${BASE_SHA}...HEAD" > "${changed_files}" elif [[ "${EVENT_NAME}" == "merge_group" ]]; then base_branch="${BASE_REF#refs/heads/}" if [[ -z "${base_branch}" ]]; then @@ -91,15 +94,15 @@ jobs: exit 1 fi git fetch --no-tags --prune origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" - git diff --name-only "origin/${base_branch}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "origin/${base_branch}...HEAD" > "${changed_files}" elif [[ "${EVENT_NAME}" == "push" ]]; then if [[ -n "${BEFORE_SHA}" && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then - git diff --name-only "${BEFORE_SHA}..HEAD" > "${changed_files}" + git diff --name-only --no-renames "${BEFORE_SHA}..HEAD" > "${changed_files}" else git fetch --no-tags --prune origin "${DEFAULT_BRANCH}" merge_base="$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD 2>/dev/null || true)" if [[ -n "${merge_base}" ]]; then - git diff --name-only "${merge_base}...HEAD" > "${changed_files}" + git diff --name-only --no-renames "${merge_base}...HEAD" > "${changed_files}" else git diff-tree --no-commit-id --name-only -r HEAD > "${changed_files}" fi @@ -135,6 +138,17 @@ jobs: - name: Test merge gate polling contract run: /usr/bin/python3 ci/checks/test_merge_gate_poller.py -v + # Job selection, source resolution and unit-test evidence of the PQ and + # nightly workflows, which run outside pull requests, the nightly + # workflow's scheduled source, the PQ canary tools, and the PQ unit jobs + # that Full Validation requires for ML-KEM and transport changes. + - name: Test PQ CI workflow contract + run: | + /usr/bin/python3 ci/checks/test_ci_pq_contract.py -v + /usr/bin/python3 ci/checks/test_scheduled_validation_contract.py -v + /usr/bin/python3 contrib/pq-canary/test/test_pq_canary.py -v + /usr/bin/python3 ci/checks/test_pq_merge_gate_contract.py -v + - name: Classify validation profile id: classify run: | @@ -294,7 +308,7 @@ jobs: exit 1 fi diff_range="${BASE_SHA}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" elif [[ "${EVENT_NAME}" == "merge_group" ]]; then base_branch="${BASE_REF#refs/heads/}" if [[ -z "${base_branch}" ]]; then @@ -303,17 +317,17 @@ jobs: fi git fetch --no-tags --prune origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" diff_range="origin/${base_branch}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" elif [[ "${EVENT_NAME}" == "push" ]]; then if [[ -n "${BEFORE_SHA}" && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then diff_range="${BEFORE_SHA}..HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" else git fetch --no-tags --prune origin "${DEFAULT_BRANCH}" merge_base="$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD 2>/dev/null || true)" if [[ -n "${merge_base}" ]]; then diff_range="${merge_base}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" else git diff-tree --no-commit-id --name-only -r HEAD > "${changed_files}" fi @@ -413,7 +427,7 @@ jobs: exit 1 fi diff_range="${BASE_SHA}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" elif [[ "${EVENT_NAME}" == "merge_group" ]]; then base_branch="${BASE_REF#refs/heads/}" if [[ -z "${base_branch}" ]]; then @@ -422,17 +436,17 @@ jobs: fi git fetch --no-tags --prune origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" diff_range="origin/${base_branch}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" elif [[ "${EVENT_NAME}" == "push" ]]; then if [[ -n "${BEFORE_SHA}" && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then diff_range="${BEFORE_SHA}..HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" else git fetch --no-tags --prune origin "${DEFAULT_BRANCH}" merge_base="$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD 2>/dev/null || true)" if [[ -n "${merge_base}" ]]; then diff_range="${merge_base}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" else git diff-tree --no-commit-id --name-only -r HEAD > "${changed_files}" fi @@ -499,7 +513,7 @@ jobs: exit 1 fi diff_range="${BASE_SHA}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" elif [[ "${EVENT_NAME}" == "merge_group" ]]; then base_branch="${BASE_REF#refs/heads/}" if [[ -z "${base_branch}" ]]; then @@ -508,17 +522,17 @@ jobs: fi git fetch --no-tags --prune origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" diff_range="origin/${base_branch}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" elif [[ "${EVENT_NAME}" == "push" ]]; then if [[ -n "${BEFORE_SHA}" && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then diff_range="${BEFORE_SHA}..HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" else git fetch --no-tags --prune origin "${DEFAULT_BRANCH}" merge_base="$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD 2>/dev/null || true)" if [[ -n "${merge_base}" ]]; then diff_range="${merge_base}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" else git diff-tree --no-commit-id --name-only -r HEAD > "${changed_files}" fi @@ -582,7 +596,7 @@ jobs: exit 1 fi diff_range="${BASE_SHA}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" elif [[ "${EVENT_NAME}" == "merge_group" ]]; then base_branch="${BASE_REF#refs/heads/}" if [[ -z "${base_branch}" ]]; then @@ -591,17 +605,17 @@ jobs: fi git fetch --no-tags --prune origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" diff_range="origin/${base_branch}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" elif [[ "${EVENT_NAME}" == "push" ]]; then if [[ -n "${BEFORE_SHA}" && ! "${BEFORE_SHA}" =~ ^0+$ ]]; then diff_range="${BEFORE_SHA}..HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" else git fetch --no-tags --prune origin "${DEFAULT_BRANCH}" merge_base="$(git merge-base "origin/${DEFAULT_BRANCH}" HEAD 2>/dev/null || true)" if [[ -n "${merge_base}" ]]; then diff_range="${merge_base}...HEAD" - git diff --name-only "${diff_range}" > "${changed_files}" + git diff --name-only --no-renames "${diff_range}" > "${changed_files}" else git diff-tree --no-commit-id --name-only -r HEAD > "${changed_files}" fi @@ -711,6 +725,7 @@ jobs: TOUCHED_OPERATOR_KEYS: ${{ needs.classify-changes.outputs.touched_operator_keys }} REPOSITORY: ${{ github.repository }} SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RUN_ID: ${{ github.run_id }} run: | set -euo pipefail @@ -760,11 +775,17 @@ jobs: # # * a page without a check_runs array is malformed and must never be # mistaken for "this commit has no check runs"; - # * a re-run check appears more than once, so the newest run by - # started_at (created_at while a run has not started) wins; + # * every check run with the name is reported, one per line, oldest + # first by started_at (created_at while a run has not started): a + # re-run check appears more than once, and so does a check that + # more than one event ran (judge_check below picks the one that + # decides); # * "absent" means the query succeeded and reported no run with that # name, which is a different fact from a query that could not be - # read at all. + # read at all; + # * each check run's check suite travels with it: it names the + # workflow run, and so the event, that produced the check (see + # date_check_suite below). # $name is a jq variable bound by --arg below, not a shell expansion. # shellcheck disable=SC2016 check_run_filter=' @@ -773,33 +794,44 @@ jobs: | add | map(select(.name == $name)) | sort_by(.started_at // .created_at // "") - | last - | if . == null then "absent" else ["found", (.status // ""), (.conclusion // "")] | @tsv end + | if length == 0 then "absent" + else .[] | ["found", (.status // ""), (.conclusion // ""), (((.check_suite // {}).id // "") | tostring), (.started_at // .created_at // "")] | @tsv + end ' + # "foundstatusconclusioncheck suite idstarted_at" + check_run_line=$'found\t[a-z_]+\t[^\t\n]*\t[^\t\n]*\t[^\t\n]*' + check_run_pattern="^(absent|${check_run_line}("$'\n'"${check_run_line})*)\$" summarize_stderr() { tr '\n' ' ' < "$1" | cut -c1-300 } - # Read the newest check run named $1 on ${SHA}. + # GET the API path $4 with `gh api --paginate`, pass the slurped + # pages through the jq program $6 (arguments from $7 on go to jq), + # and trust the result only if it matches the bash regex $5. $1 + # names the check for the log and $2 what is read. # - # On success sets QUERY_OBSERVATION to "absent" or to - # "foundstatusconclusion" and returns 0. Returns 1 when the - # query could not be read within GATE_QUERY_MAX_ATTEMPTS attempts or - # before $2, the wall-clock deadline shared with the caller: an - # unreadable query is never success and never "not found yet". + # On success sets API_RESULT and returns 0. Returns 1 when the path + # could not be read within GATE_QUERY_MAX_ATTEMPTS attempts or + # before $3, the wall-clock deadline shared with the caller: an + # unreadable response is never success and never "not found yet". # # The result travels in a variable rather than on stdout because this # function also logs there, and errexit is not in force for a function # called from a condition, so every failure is handled explicitly. - query_check_run() { + read_api() { local check_name="$1" - local deadline="$2" + local what="$2" + local deadline="$3" + local path="$4" + local pattern="$5" + local filter="$6" + shift 6 local attempt=1 local backoff="${GATE_QUERY_BACKOFF_SECONDS}" - local body fields observation reason status stderr_path now rc + local body reason result stderr_path now rc - QUERY_OBSERVATION="" + API_RESULT="" while (( attempt <= GATE_QUERY_MAX_ATTEMPTS )); do stderr_path="$(mktemp)" @@ -808,39 +840,27 @@ jobs: timeout "${GATE_QUERY_TIMEOUT_SECONDS}" gh api \ -H "Accept: application/vnd.github+json" \ --paginate \ - "/repos/${REPOSITORY}/commits/${SHA}/check-runs?per_page=100" \ + "${path}" \ 2>"${stderr_path}" )" || rc=$? if (( rc != 0 )); then - reason="check-runs query failed (exit ${rc}): $(summarize_stderr "${stderr_path}")" + reason="${what} query failed (exit ${rc}): $(summarize_stderr "${stderr_path}")" else rc=0 - observation="$( + result="$( printf '%s' "${body}" \ - | jq -e -r -s --arg name "${check_name}" "${check_run_filter}" 2>"${stderr_path}" + | jq -e -r -s "$@" "${filter}" 2>"${stderr_path}" )" || rc=$? if (( rc != 0 )); then - reason="check-runs response could not be read (jq exit ${rc}): $(summarize_stderr "${stderr_path}")" + reason="${what} response could not be read (jq exit ${rc}): $(summarize_stderr "${stderr_path}")" + elif [[ "${result}" =~ ${pattern} ]]; then + rm -f "${stderr_path}" + API_RESULT="${result}" + return 0 else - case "${observation}" in - absent) - rm -f "${stderr_path}" - QUERY_OBSERVATION="${observation}" - return 0 - ;; - found$'\t'*) - fields="${observation#found$'\t'}" - status="${fields%%$'\t'*}" - if [[ "${status}" =~ ^[a-z_]+$ ]]; then - rm -f "${stderr_path}" - QUERY_OBSERVATION="${observation}" - return 0 - fi - ;; - esac - reason="check-runs response was malformed: ${observation}" + reason="${what} response was malformed: ${result}" fi fi @@ -854,7 +874,7 @@ jobs: now="$(date +%s)" if (( now + backoff > deadline )); then - echo "${check_name}: wait deadline reached while the check-runs query was unreadable" + echo "${check_name}: wait deadline reached while the ${what} query was unreadable" break fi @@ -868,16 +888,391 @@ jobs: return 1 } + # Read every check run named $1 on ${SHA}. + # + # On success sets QUERY_OBSERVATION to "absent" or to one line per + # check run (see check_run_pattern) and returns 0. Returns 1 when the + # query could not be read (see read_api; $2 is the deadline). + query_check_run() { + QUERY_OBSERVATION="" + read_api "$1" "check-runs" "$2" \ + "/repos/${REPOSITORY}/commits/${SHA}/check-runs?per_page=100" \ + "${check_run_pattern}" "${check_run_filter}" --arg name "$1" || return 1 + QUERY_OBSERVATION="${API_RESULT}" + } + + # Which event a check belongs to. + # + # When CI runs again on a commit (a reopen, a label, a re-run), this + # gate starts within seconds, but Core Checks and Full Validation + # create their gate checks only at their last job, minutes later. + # Until then the newest check of that name is an earlier event's, + # and its failure says nothing about the run under way. A cutoff + # in time cannot tell that apart from a failure of this event: + # Core Checks, Full Validation and this gate are separate workflows, + # scheduled independently, so a check this event started can fail + # before this gate's run even starts; an earlier event's check can be + # cancelled, or fail, after it started; and re-running this gate + # alone starts it again but starts nothing else. + # + # What the checks of one event share is the event, but GitHub's REST + # API names it nowhere: a workflow run, check suite or check run + # carries no delivery id or activity type. What it does give: + # + # * every workflow run a push, pull request event or merge group + # starts is created at that event, within seconds of the others + # (see EVENT_SKEW_SECONDS for how close), and a re-run keeps the + # workflow run: its id, run_number, check suite and created_at + # stay, and only run_attempt and run_started_at move (checked on + # this repository's PR #229); + # * a workflow's run_number grows with each new run and is kept by a + # re-run (GitHub's contexts reference), so of two runs of one + # workflow on this commit the higher run_number is the later + # event's, even when both were created in the same second. + # + # So: + # + # * a check run's check suite names the workflow run that produced + # it (GET /actions/runs?check_suite_id=), whose created_at dates + # the event that started it; once read, a check suite's workflow + # run is cached, as it cannot change; + # * this gate's own workflow run's created_at (GET + # /actions/runs/{run_id}, never the attempt's) dates the event it + # judges, so a gate-only re-run still judges its original event; + # * a check whose workflow run was created more than + # EVENT_SKEW_SECONDS before this gate's belongs to an earlier event; + # * any other belongs to this event or a later one, unless a newer + # run of its workflow is on this commit (GET + # /actions/workflows/{id}/runs?head_sha=): then an event since, + # however soon after, has started the run that replaces it. + # + # That newer run is the usual case, not an edge one. A newer event + # cancels an earlier event's Core Checks and Full Validation runs + # (their concurrency groups cancel in progress), whose gate jobs + # still run (if: always()) and conclude failure: of 8 such cancelled + # Core Checks runs sampled here, 7 did. So the earlier event's + # failure lands after the newer event, and the newer run already + # exists, having caused the cancellation. + # + # What is left: two events whose runs are created within + # EVENT_SKEW_SECONDS of each other, the earlier one's check failing, + # and the later one's run of that workflow not yet listed when this + # gate judges it. That failure is taken as current and fails at once. + # And a current failure whose workflow has a newer run, from an event + # after this gate's, is waited on: that run's result decides. + # + # Only a check that concluded neither success nor cancelled is + # dated against this gate: a success on this commit passes whichever + # event produced it, and a cancellation is never a verdict. Which of + # a check's runs is judged at all is judge_check's to say, below. + # + # Workflow runs started by one event are created this close together. + # Across 325 pull request events on this repository (June to October + # 2026), one event's runs were created within 4 s of each other, and + # this gate's run up to 3 s after another's; the closest two events + # on one commit were 28 s apart. A documented allowance, not a + # tunable: a smaller value risks taking a current failure as earlier + # (it is waited on until the deadline), a larger one widens the + # window left above, where only the newer run tells events apart. + EVENT_SKEW_SECONDS=10 + + # GET /actions/runs/{run_id}: this gate's own workflow run. + # $run_id is bound by --argjson below. + # shellcheck disable=SC2016 + gate_run_filter=' + if type != "array" or length != 1 then error("expected one workflow run") else .[0] end + | if .id != $run_id then error("not workflow run \($run_id)") else . end + | .created_at + | [(fromdateiso8601 | tostring), .] + | @tsv + ' + # "" + created_at_pattern=$'^[0-9]+\t[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' + # GET /actions/runs?check_suite_id={id}: the one workflow run that + # owns a check suite. A run of another suite is never taken for it, + # even if the filter were ignored. $suite is bound by --argjson. + # shellcheck disable=SC2016 + suite_run_filter=' + if type != "array" or length == 0 then error("empty workflow-runs response") else . end + | map(if (.workflow_runs | type) == "array" then .workflow_runs[] else error("malformed workflow-runs page") end) + | map(select(.check_suite_id == $suite)) + | if length != 1 then error("\(length) workflow runs own check suite \($suite)") else .[0] end + | [(.created_at | fromdateiso8601 | tostring), (.run_number | tostring), (.workflow_id | tostring), .created_at] + | @tsv + ' + # "" + suite_run_pattern=$'^[0-9]+\t[1-9][0-9]*\t[1-9][0-9]*\t[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' + # GET /actions/workflows/{id}/runs?head_sha=: a workflow's runs on + # this commit, and of them the one with the highest run_number. Runs + # of another workflow or commit are never counted, even if the + # filters were ignored, and a listing that lacks the failed check's + # own run is not trusted. $workflow and $suite are bound by + # --argjson, $sha by --arg. + # shellcheck disable=SC2016 + newest_run_filter=' + if type != "array" or length == 0 then error("empty workflow-runs response") else . end + | map(if (.workflow_runs | type) == "array" then .workflow_runs[] else error("malformed workflow-runs page") end) + | map(select(.workflow_id == $workflow and .head_sha == $sha)) + | if any(.[]; .check_suite_id == $suite) then . else error("check suite \($suite) is not among the runs of workflow \($workflow) on \($sha)") end + | if all(.[]; (.run_number | type) == "number") then . else error("a run of workflow \($workflow) has no run_number") end + | max_by(.run_number) + | [(.run_number | tostring), .created_at] + | @tsv + ' + # "" + newest_run_pattern=$'^[1-9][0-9]*\t[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' + + GATE_RUN_CREATED="" + declare -A SUITE_RUN=() + declare -A SUITE_NEWER_RUN=() + + # Read this gate's own workflow run once, into GATE_RUN_CREATED + # (""). Returns 1 if it could not be read by + # $2, the shared deadline ($1 names the check for the log). + date_gate_run() { + if [[ -n "${GATE_RUN_CREATED}" ]]; then + return 0 + fi + if [[ ! "${RUN_ID}" =~ ^[1-9][0-9]*$ ]]; then + echo "$1: this gate's workflow run id is not a number: '${RUN_ID}'" + return 1 + fi + read_api "$1" "workflow-run" "$2" \ + "/repos/${REPOSITORY}/actions/runs/${RUN_ID}" \ + "${created_at_pattern}" "${gate_run_filter}" --argjson run_id "${RUN_ID}" || return 1 + GATE_RUN_CREATED="${API_RESULT}" + echo "This gate's workflow run was created at ${GATE_RUN_CREATED#*$'\t'}, by the event it judges" + } + + # Read the workflow run that owns check suite $2 once, into + # SUITE_RUN[$2] (see suite_run_pattern). Returns 1 if $2 is not a + # check suite id, or its workflow run could not be read by $3, the + # shared deadline ($1 names the check for the log). + date_check_suite() { + if [[ ! "$2" =~ ^[1-9][0-9]*$ ]]; then + echo "$1: the check run names no check suite: '$2'" + return 1 + fi + if [[ -n "${SUITE_RUN[$2]:-}" ]]; then + return 0 + fi + read_api "$1" "workflow-run" "$3" \ + "/repos/${REPOSITORY}/actions/runs?check_suite_id=$2&exclude_pull_requests=true&per_page=100" \ + "${suite_run_pattern}" "${suite_run_filter}" --argjson suite "$2" || return 1 + SUITE_RUN[$2]="${API_RESULT}" + } + + # Date the event of check $1, from check suite $2 (see above). + # + # Sets CHECK_EVENT to "earlier", "superseded" (a newer run of its + # workflow is on this commit, named in CHECK_NEWER_RUN as + # "") or "current", and CHECK_RUN_NUMBER + # and CHECK_RUN_CREATED_AT to its own workflow run's, and returns 0. + # Returns 1 when the check run names no check suite, or a workflow + # run or listing it needs could not be read by $3, the shared + # deadline: an event that cannot be dated is never success, never + # earlier and never current. + date_check_event() { + local check_name="$1" + local check_suite_id="$2" + local deadline="$3" + local check_created gate_created workflow_id newest_number + + CHECK_EVENT="" + CHECK_RUN_NUMBER="" + CHECK_RUN_CREATED_AT="" + CHECK_NEWER_RUN="" + + date_gate_run "${check_name}" "${deadline}" || return 1 + date_check_suite "${check_name}" "${check_suite_id}" "${deadline}" || return 1 + + check_created="${SUITE_RUN[${check_suite_id}]}" + CHECK_RUN_CREATED_AT="${check_created##*$'\t'}" + CHECK_RUN_NUMBER="${check_created#*$'\t'}" + workflow_id="${CHECK_RUN_NUMBER#*$'\t'}" + CHECK_RUN_NUMBER="${CHECK_RUN_NUMBER%%$'\t'*}" + workflow_id="${workflow_id%%$'\t'*}" + check_created="${check_created%%$'\t'*}" + gate_created="${GATE_RUN_CREATED%%$'\t'*}" + if (( check_created < gate_created - EVENT_SKEW_SECONDS )); then + CHECK_EVENT="earlier" + return 0 + fi + + # A newer run, once listed, stays on the commit, so it is cached; + # its absence is read when the verdict hangs on it, and a failure + # with no newer run is a verdict that ends the wait. + if [[ -z "${SUITE_NEWER_RUN[${check_suite_id}]:-}" ]]; then + read_api "${check_name}" "workflow-runs" "${deadline}" \ + "/repos/${REPOSITORY}/actions/workflows/${workflow_id}/runs?head_sha=${SHA}&exclude_pull_requests=true&per_page=100" \ + "${newest_run_pattern}" "${newest_run_filter}" \ + --argjson workflow "${workflow_id}" --argjson suite "${check_suite_id}" --arg sha "${SHA}" || return 1 + newest_number="${API_RESULT%%$'\t'*}" + if (( newest_number > CHECK_RUN_NUMBER )); then + SUITE_NEWER_RUN[${check_suite_id}]="${API_RESULT}" + fi + fi + if [[ -n "${SUITE_NEWER_RUN[${check_suite_id}]:-}" ]]; then + CHECK_NEWER_RUN="${SUITE_NEWER_RUN[${check_suite_id}]}" + CHECK_EVENT="superseded" + else + CHECK_EVENT="current" + fi + } + + # Judge check $1 from its check runs in QUERY_OBSERVATION, by $2, the + # shared deadline. + # + # A check has a run per event that ran CI on this commit, each in + # that event's workflow run, and a re-run adds another to the same + # workflow run. The check run of the newest workflow run decides: by + # its created_at, which dates its event, then its run_number, which + # orders one workflow's runs created in the same second; and within + # that workflow run the newest attempt, by started_at. So a manual + # re-run of an earlier event's workflow, however recent, never + # outranks this event's (or a later event's) result, and an earlier + # event's result is judged only while no later one has reported. + # Workflow runs are read only to rank check runs from more than one + # of them, and only when not all of those succeeded: a check whose + # runs all succeeded passes whichever decides. + # + # Sets CHECK_VERDICT to "success", "failed" or "pending" and returns 0; + # a failed check's conclusion is in CHECK_CONCLUSION, and a pending + # one is named in CHECK_PENDING the way the timeout reports it. + # Returns 1, with the reason in CHECK_UNCORRELATED, when the workflow + # runs the verdict depends on could not be read. + judge_check() { + local check_name="$1" + local deadline="$2" + local lines=() statuses=() conclusions=() suites=() stamps=() + local line fields index decide ranked run run_number + local status conclusion all_success=1 + local -A distinct=() + + CHECK_VERDICT="" + CHECK_CONCLUSION="" + CHECK_PENDING="${check_name}" + CHECK_UNCORRELATED="" + + if [[ "${QUERY_OBSERVATION}" == "absent" ]]; then + echo "${check_name}: query succeeded, no such check run on ${SHA} yet" + CHECK_VERDICT="pending" + return 0 + fi + + mapfile -t lines <<< "${QUERY_OBSERVATION}" + for line in "${lines[@]}"; do + fields="${line#found$'\t'}" + statuses+=("${fields%%$'\t'*}") + fields="${fields#*$'\t'}" + conclusions+=("${fields%%$'\t'*}") + fields="${fields#*$'\t'}" + suites+=("${fields%%$'\t'*}") + stamps+=("${fields#*$'\t'}") + if [[ "${statuses[-1]}" != "completed" || "${conclusions[-1]}" != "success" ]]; then + all_success=0 + fi + done + + # The newest by started_at, which is right within one workflow run. + decide=$(( ${#lines[@]} - 1 )) + if (( ${#lines[@]} > 1 && ! all_success )); then + for index in "${!lines[@]}"; do + if [[ ! "${suites[index]}" =~ ^[1-9][0-9]*$ ]]; then + echo "${check_name}: a check run names no check suite: '${suites[index]}'" + CHECK_UNCORRELATED="${check_name} on ${SHA} has ${#lines[@]} check runs, but not every one names the workflow run that tells which event is newest" + return 1 + fi + distinct[${suites[index]}]=1 + done + if (( ${#distinct[@]} > 1 )); then + ranked="" + for index in "${!lines[@]}"; do + if ! date_check_suite "${check_name}" "${suites[index]}" "${deadline}"; then + CHECK_UNCORRELATED="${check_name} on ${SHA} has check runs from ${#distinct[@]} workflow runs, but the workflow runs that tell which event is newest could not be read" + return 1 + fi + run="${SUITE_RUN[${suites[index]}]}" + run_number="${run#*$'\t'}" + run_number="${run_number%%$'\t'*}" + ranked+="${run%%$'\t'*}"$'\t'"${run_number}"$'\t'"${stamps[index]}"$'\t'"${index}"$'\n' + done + decide="$(printf '%s' "${ranked}" | LC_ALL=C sort -t $'\t' -k1,1n -k2,2n -k3,3 -k4,4n | tail -n 1)" + decide="${decide##*$'\t'}" + if [[ ! "${decide}" =~ ^[0-9]+$ ]] || (( decide >= ${#lines[@]} )); then + CHECK_UNCORRELATED="${check_name} on ${SHA} has check runs from ${#distinct[@]} workflow runs, which could not be ranked" + return 1 + fi + run="${SUITE_RUN[${suites[decide]}]}" + echo "${check_name}: ${#lines[@]} check runs from ${#distinct[@]} workflow runs; the newest workflow run's, created at ${run##*$'\t'}, decides" + fi + fi + + status="${statuses[decide]}" + conclusion="${conclusions[decide]}" + echo "${check_name}: status=${status} conclusion=${conclusion:-pending}" + + if [[ "${status}" != "completed" ]]; then + CHECK_VERDICT="pending" + return 0 + fi + if [[ "${conclusion}" == "success" ]]; then + CHECK_VERDICT="success" + return 0 + fi + if [[ "${conclusion}" == "cancelled" ]]; then + # A cancelled check is never a verdict: it was superseded (a + # newer push, reopen or label cancels the running workflow, + # possibly after this gate started) or it never ran. Wait for + # the run that replaces it; the deadline still fails the gate. + echo "${check_name}: cancelled; waiting for the run that replaces it" + CHECK_PENDING="${check_name} (cancelled)" + CHECK_VERDICT="pending" + return 0 + fi + + if ! date_check_event "${check_name}" "${suites[decide]}" "${deadline}"; then + CHECK_UNCORRELATED="${check_name} on ${SHA} concluded ${conclusion:-unknown}, but the workflow runs that tell whether it belongs to this event could not be read" + return 1 + fi + case "${CHECK_EVENT}" in + earlier) + echo "${check_name}: concluded ${conclusion:-unknown} in a workflow run created at ${CHECK_RUN_CREATED_AT}, by an earlier event than this gate's (${GATE_RUN_CREATED#*$'\t'}); waiting for a newer run" + CHECK_PENDING="${check_name} (last ${conclusion:-unknown} from an earlier event)" + CHECK_VERDICT="pending" + ;; + superseded) + echo "${check_name}: concluded ${conclusion:-unknown} in run #${CHECK_RUN_NUMBER} of its workflow, created at ${CHECK_RUN_CREATED_AT}, but run #${CHECK_NEWER_RUN%%$'\t'*}, created at ${CHECK_NEWER_RUN#*$'\t'}, has since started on ${SHA}; waiting for its result" + CHECK_PENDING="${check_name} (last ${conclusion:-unknown} superseded by a newer run)" + CHECK_VERDICT="pending" + ;; + current) + CHECK_CONCLUSION="${conclusion}" + CHECK_VERDICT="failed" + ;; + *) + CHECK_UNCORRELATED="${check_name} on ${SHA} concluded ${conclusion:-unknown} in an event that could not be told ('${CHECK_EVENT}')" + return 1 + ;; + esac + } + # Wait until every named check on ${SHA} has concluded successfully. # - # Returns 1 under three distinct error titles: a check concluded - # something other than success, the check runs could not be read, or - # the shared deadline passed with checks still pending. Only a - # completed check with a success conclusion is success. + # Returns 1 under four distinct error titles: a check concluded + # something other than success, the check runs could not be read, a + # check's runs could not be traced to their events, or the shared + # deadline passed with checks still pending. Only a completed check + # with a success conclusion is success. A check that concluded + # otherwise is a verdict only if it belongs to the event this gate + # judges, or a later one, and no newer run of its workflow is on this + # commit; an earlier event's, or a superseded one, is waited on, as + # its newer run may not have reported yet, and a cancelled one is + # always waited on. wait_for_checks() { local check_names=("$@") local pending=() - local check_name conclusion fields now observation status deadline + local check_name now deadline now="$(date +%s)" deadline=$(( now + GATE_WAIT_TIMEOUT_SECONDS )) @@ -890,28 +1285,26 @@ jobs: echo "::error title=Required Merge Gate could not read check runs::Check runs for ${check_name} on ${SHA} could not be read after ${GATE_QUERY_MAX_ATTEMPTS} attempts; failing closed with no verdict for: ${check_names[*]}" return 1 fi - observation="${QUERY_OBSERVATION}" - - if [[ "${observation}" == "absent" ]]; then - echo "${check_name}: query succeeded, no such check run on ${SHA} yet" - pending+=("${check_name}") - continue + if ! judge_check "${check_name}" "${deadline}"; then + echo "::error title=Required Merge Gate could not correlate check runs::${CHECK_UNCORRELATED}; failing closed with no verdict for: ${check_names[*]}" + return 1 fi - fields="${observation#found$'\t'}" - status="${fields%%$'\t'*}" - conclusion="${fields#*$'\t'}" - echo "${check_name}: status=${status} conclusion=${conclusion:-pending}" - - if [[ "${status}" == "completed" ]]; then - if [[ "${conclusion}" != "success" ]]; then - echo "::error title=Required Merge Gate failed::${check_name} concluded ${conclusion:-unknown}." + case "${CHECK_VERDICT}" in + success) + ;; + pending) + pending+=("${CHECK_PENDING}") + ;; + failed) + echo "::error title=Required Merge Gate failed::${check_name} concluded ${CHECK_CONCLUSION:-unknown}." return 1 - fi - continue - fi - - pending+=("${check_name}") + ;; + *) + echo "::error title=Required Merge Gate failed::${check_name} has no verdict ('${CHECK_VERDICT}')." + return 1 + ;; + esac done if (( ${#pending[@]} == 0 )); then diff --git a/.github/workflows/rpc-docs-pages.yml b/.github/workflows/rpc-docs-pages.yml index c4b77f3583..df54dbe68c 100644 --- a/.github/workflows/rpc-docs-pages.yml +++ b/.github/workflows/rpc-docs-pages.yml @@ -85,20 +85,20 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} DISPATCH_REF: ${{ inputs.ref || '' }} EVENT_NAME: ${{ github.event_name }} - PULL_REQUEST_NUMBER: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }} PULL_REQUEST_SHA: ${{ github.event_name == 'pull_request' && github.sha || '' }} run: | set -euo pipefail development_ref="${DEFAULT_BRANCH}" - development_checkout_ref="" + # A pull request builds its event merge commit, checked out by hash: + # refs/pull//merge moves whenever the base branch or the pull + # request's stack changes, so it can name a different commit by the + # time the build job checks it out. if [[ "${EVENT_NAME}" == "pull_request" ]]; then development_ref="${PULL_REQUEST_SHA}" - development_checkout_ref="refs/pull/${PULL_REQUEST_NUMBER}/merge" elif [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${DISPATCH_REF}" ]]; then development_ref="${DISPATCH_REF}" fi echo "ref=${development_ref}" >> "${GITHUB_OUTPUT}" - echo "checkout_ref=${development_checkout_ref}" >> "${GITHUB_OUTPUT}" - name: Determine publication mode id: mode @@ -128,7 +128,6 @@ jobs: python3 doc/rpc/versioned_site.py discover \ --repository "${REPOSITORY}" \ --development-ref "${DEVELOPMENT_REF}" \ - --development-checkout-ref "${{ steps.development.outputs.checkout_ref }}" \ --publisher-sha "${publisher_sha}" \ --output "${RUNNER_TEMP}/rpc-docs-build-plan.json" \ --github-output "${GITHUB_OUTPUT}" diff --git a/.gitignore b/.gitignore index 65201c99d0..1a053bda74 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,11 @@ target/ /guix-build-* +# libFuzzer artifacts, written to the working directory +/crash-* +/leak-* +/oom-* +/slow-unit-* +/timeout-* + /ci/scratch/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 55e4117434..30f7cb1e77 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -136,6 +136,9 @@ if(WITH_USDT) find_package(USDT MODULE REQUIRED) endif() +set(WITH_MLKEM_NATIVE "AUTO" CACHE STRING "Build ML-KEM native code for x86_64 (GCC or Clang) and macOS arm64, used when the CPU allows it: AUTO, ON or OFF.") +set_property(CACHE WITH_MLKEM_NATIVE PROPERTY STRINGS AUTO ON OFF) + option(ENABLE_EXTERNAL_SIGNER "Enable external signer support." ON) cmake_dependent_option(WITH_QRENCODE "Enable QR code support." ON "BUILD_GUI" OFF) @@ -426,6 +429,9 @@ include(cmake/introspection.cmake) include(cmake/ccache.cmake) +include(cmake/mlkem-native.cmake) +mlkem_native_configure() + add_library(warn_interface INTERFACE) target_link_libraries(core_interface INTERFACE warn_interface) if(MSVC) @@ -689,6 +695,7 @@ message(" IPC ................................. ${ipc_status}") message(" USDT tracing ........................ ${WITH_USDT}") message(" QR code (GUI) ....................... ${WITH_QRENCODE}") message(" DBus (GUI) .......................... ${WITH_DBUS}") +message(" ML-KEM-1024 backend ................. ${MLKEM_NATIVE_SUMMARY}") message("Tests:") message(" test_qbit ........................... ${BUILD_TESTS}") message(" test_qbit-qt ........................ ${BUILD_GUI_TESTS}") diff --git a/ci/checks/classify_merge_profile.py b/ci/checks/classify_merge_profile.py index b1d32714a9..5d1ba94072 100755 --- a/ci/checks/classify_merge_profile.py +++ b/ci/checks/classify_merge_profile.py @@ -39,6 +39,17 @@ "doc/reference/", "doc/user/", ) +# Public docs that source checks read, which classify as source so that a change to only them runs +# those checks: the vectors the specification quotes, compared by +# contrib/devtools/generate-pq-transport-vectors.py --check in the lint job, and the operator guide's +# quick start, run by test/functional/feature_pq_quickstart.py. +# ci/checks/test_pq_merge_gate_contract.py fails if a document with their markers is missing here. +CHECKED_DOCS_FILES = frozenset( + { + "doc/design/pq-transport.md", + "doc/user/network/pq-transport.md", + } +) GITHUB_METADATA_FILES = frozenset( { ".github/PULL_REQUEST_TEMPLATE.md", @@ -55,6 +66,89 @@ "contrib/keys/operator-keys/", "contrib/guix/repo-templates/qbit-guix.sigs/operator-keys/", ) +# Changes to the ML-KEM library, the v2 transport, or their build and CI wiring +# must pass the aarch64-unit, macos-arm64-unit and macos-x86_64-unit jobs of +# .github/workflows/ci-pq.yml before they merge (the pq-unit job of ci.yml). +# ci/checks/test_pq_merge_gate_contract.py fails if a CMake file, workflow or +# ci/ file that mentions ML-KEM is missing here. +PQ_UNIT_FILES = frozenset( + { + # The build: the WITH_MLKEM_NATIVE option, the library and its link + # into bitcoin_node, the run-time CPU detection, the configure header, + # and the tests, fuzz targets and benchmarks that exercise it. + "CMakeLists.txt", + "cmake/bitcoin-build-config.h.in", + "cmake/mlkem-native.cmake", + "src/CMakeLists.txt", + "src/bench/CMakeLists.txt", + "src/test/CMakeLists.txt", + "src/test/fuzz/CMakeLists.txt", + "src/util/CMakeLists.txt", + "test/CMakeLists.txt", + # The jobs and the gate: this classifier, the workflows that run it and + # the PQ jobs, the scripts and evidence check of those jobs, and the + # tests of that wiring. + ".github/workflows/ci-pq.yml", + ".github/workflows/ci.yml", + ".github/workflows/core-checks.yml", + ".github/workflows/required-merge-gate.yml", + "ci/checks/classify_merge_profile.py", + "ci/checks/ctest_evidence.py", + "ci/checks/test_ci_pq_contract.py", + "ci/checks/test_classify_merge_profile.py", + "ci/checks/test_mlkem_build_policy.py", + "ci/checks/test_pq_merge_gate_contract.py", + "ci/test/00_setup_env_mac_native_pq.sh", + "ci/test/00_setup_env_native_aarch64_pq.sh", + "ci/test/00_setup_env_s390x_unit.sh", + "ci/test/03_test_script.sh", + # The sources and tests. + "src/compat/cpuid.h", + "src/net.cpp", + "src/net.h", + "src/test/bip324_tests.cpp", + "src/test/data/mlkem1024_vectors.json", + "src/test/data/pq_transport_vectors.json", + "src/test/mlkem_tests.cpp", + "src/test/net_tests.cpp", + } +) +PQ_UNIT_PREFIXES = ( + "src/bip324", + "src/compat/cpu_features.", + "src/crypto/mlkem", + "src/mlkem-native/", +) +# Changes that can break hybrid post-quantum v2 transport must also pass the +# whole functional suite with it, the pq-functional job of ci-pq.yml (the +# pq-functional job of ci.yml): every change that needs the PQ unit jobs, and +# these, which those jobs neither build nor run. Only that job runs the suite +# hybrid; the other functional jobs run v1 between nodes, and the i686 job +# plain v2. +PQ_FUNCTIONAL_FILES = frozenset( + { + # The job's own setup: --v2pqtransport and its build. + "ci/test/00_setup_env_native_pq_functional.sh", + # Turns -v2transport and -v2pqtransport into the node's hybrid + # setting, its startup warnings and line, and the connection + # manager's options. + "src/init.cpp", + # The hybrid fields of getpeerinfo, getnetworkinfo and + # getpqtransportinfo that the suite reads. + "src/rpc/net.cpp", + # Its checks of those fields for hybrid peers run only in the hybrid + # suite (options.v2pqtransport). + "test/functional/rpc_net.py", + } +) +PQ_FUNCTIONAL_PREFIXES = ( + # The tests of v2 transport, whose nodes take hybrid transport from the run + # mode, and whose expectations depend on it. + "test/functional/p2p_v2_", + # The run modes, which give each node its transport options, and the + # Python peers (BIP324 and hybrid) that the nodes talk to. + "test/functional/test_framework/", +) @dataclass(frozen=True) @@ -105,6 +199,17 @@ def public_docs_only(self) -> bool: def github_metadata_only(self) -> bool: return self.profile == GITHUB_METADATA_PROFILE + @property + def pq_unit_required(self) -> bool: + """Whether the PQ unit jobs must pass. A path that cannot be read requires them.""" + return bool(self.invalid_paths) or any(is_pq_unit_path(path) for path in self.paths) + + @property + def pq_functional_required(self) -> bool: + """Whether the hybrid functional suite must pass: whenever the PQ unit jobs must, and for the hybrid + transport paths those jobs do not cover.""" + return self.pq_unit_required or any(is_pq_functional_path(path) for path in self.paths) + def normalize_path(path: str) -> str | None: path = path.strip() @@ -136,7 +241,7 @@ def is_rpc_docs_path(path: str) -> bool: def is_public_docs_path(path: str) -> bool: - return ( + return path not in CHECKED_DOCS_FILES and ( path in PUBLIC_DOCS_ROOT_FILES or path.startswith(PUBLIC_DOCS_PREFIXES) or PUBLIC_RELEASE_NOTES_RE.fullmatch(path) is not None @@ -147,6 +252,14 @@ def is_github_metadata_path(path: str) -> bool: return path in GITHUB_METADATA_FILES or path.startswith(GITHUB_METADATA_PREFIXES) +def is_pq_unit_path(path: str) -> bool: + return path in PQ_UNIT_FILES or path.startswith(PQ_UNIT_PREFIXES) + + +def is_pq_functional_path(path: str) -> bool: + return path in PQ_FUNCTIONAL_FILES or path.startswith(PQ_FUNCTIONAL_PREFIXES) + + def classify_paths(paths: list[str] | tuple[str, ...]) -> Classification: normalized: list[str] = [] invalid: list[str] = [] @@ -223,6 +336,8 @@ def github_outputs(classification: Classification) -> dict[str, str]: "touched_rpc_docs": bool_output(bool(classification.rpc_docs_paths)), "touched_public_docs": bool_output(bool(classification.public_docs_paths)), "touched_github_metadata": bool_output(bool(classification.github_metadata_paths)), + "pq_unit_required": bool_output(classification.pq_unit_required), + "pq_functional_required": bool_output(classification.pq_functional_required), } @@ -236,6 +351,8 @@ def describe_classification(classification: Classification) -> str: lines = [ f"validation_profile={classification.profile}", f"changed_count={len(classification.paths)}", + f"pq_unit_required={bool_output(classification.pq_unit_required)}", + f"pq_functional_required={bool_output(classification.pq_functional_required)}", ] if classification.release_policy_paths: diff --git a/ci/checks/ctest_evidence.py b/ci/checks/ctest_evidence.py new file mode 100755 index 0000000000..c2f501c0e4 --- /dev/null +++ b/ci/checks/ctest_evidence.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +"""Prove that a CI job's expected unit-test suites really ran and passed. + +ctest exits 0 when a suite is skipped: a Boost suite that was compiled out, or +filtered away, prints "no test cases matching filter", which the suite's +SKIP_REGULAR_EXPRESSION turns into a skip. A selection regex can also drift and +quietly select fewer suites. ci/test/03_test_script.sh therefore runs this +check after ctest whenever CTEST_EXPECTED_SUITES is set: + +* the tests ctest selected (``ctest --show-only=json-v1`` with the same + arguments as the run) must be exactly the expected suites; +* every expected suite must appear once in ctest's JUnit file with status + ``run`` and no failure; skipped (``notrun``), disabled, failed and missing + suites fail the check. + +It writes a Markdown table of the results, which the workflow adds to the job +summary, and exits non-zero on any violation. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +PASSED = "passed" + + +def parse_expected(text: str) -> list[str]: + names = text.split() + if not names: + raise ValueError("no expected suites given") + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError(f"expected suites listed more than once: {', '.join(duplicates)}") + return names + + +def selected_tests(listing: str) -> list[str]: + """Test names from ``ctest --show-only=json-v1`` output.""" + return [test["name"] for test in json.loads(listing).get("tests", [])] + + +def junit_outcomes(junit: str) -> dict[str, tuple[str, str]]: + """Map each test case in a ctest JUnit file to (outcome, time in seconds).""" + root = ET.fromstring(junit) + outcomes: dict[str, tuple[str, str]] = {} + for case in root.iter("testcase"): + name = case.get("name", "") + status = case.get("status", "") + if case.find("failure") is not None or case.find("error") is not None or status == "fail": + outcome = "failed" + elif case.find("skipped") is not None or status == "notrun": + outcome = "skipped" + elif status == "disabled": + outcome = "disabled" + elif status == "run": + outcome = PASSED + else: + outcome = f"unknown status {status!r}" + if name in outcomes: + outcome = "reported more than once" + outcomes[name] = (outcome, case.get("time", "")) + return outcomes + + +def check(expected: list[str], selected: list[str], outcomes: dict[str, tuple[str, str]]) -> list[str]: + errors = [] + missing = [name for name in expected if name not in selected] + unexpected = [name for name in selected if name not in expected] + if missing: + errors.append(f"ctest did not select expected suite(s): {', '.join(missing)}") + if unexpected: + errors.append(f"ctest selected suite(s) that are not expected: {', '.join(unexpected)}") + for name in expected: + outcome = outcomes.get(name, ("missing from the JUnit results", ""))[0] + if outcome != PASSED: + errors.append(f"{name}: {outcome}") + return errors + + +def summary(expected: list[str], outcomes: dict[str, tuple[str, str]], errors: list[str]) -> str: + lines = ["| suite | result | time (s) |", "| --- | --- | --- |"] + for name in expected: + outcome, seconds = outcomes.get(name, ("missing", "")) + lines.append(f"| {name} | {outcome} | {seconds} |") + lines.append("") + if errors: + lines.append("Unit-test evidence check failed:") + lines.extend(f"- {error}" for error in errors) + else: + lines.append(f"All {len(expected)} expected suites were selected, executed and passed; none was skipped.") + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--expected", required=True, help="space-separated suite names (CTEST_EXPECTED_SUITES)") + parser.add_argument("--listing", required=True, type=Path, help="ctest --show-only=json-v1 output") + parser.add_argument("--junit", required=True, type=Path, help="ctest --output-junit file") + parser.add_argument("--summary", type=Path, help="write a Markdown summary here") + args = parser.parse_args(argv) + + try: + expected = parse_expected(args.expected) + except ValueError as e: + print(f"Error: CTEST_EXPECTED_SUITES: {e}", file=sys.stderr) + return 1 + errors: list[str] = [] + try: + selected = selected_tests(args.listing.read_text(encoding="utf8")) + except (OSError, ValueError, KeyError, TypeError) as e: + selected = [] + errors.append(f"unreadable ctest test listing {args.listing}: {e}") + try: + outcomes = junit_outcomes(args.junit.read_text(encoding="utf8")) + except (OSError, ET.ParseError) as e: + outcomes = {} + errors.append(f"unreadable JUnit results {args.junit}: {e}") + errors.extend(check(expected, selected, outcomes)) + + text = summary(expected, outcomes, errors) + if args.summary is not None: + args.summary.write_text(text, encoding="utf8") + print(text, end="") + for error in errors: + print(f"Error: {error}", file=sys.stderr) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/checks/test_ci_pq_contract.py b/ci/checks/test_ci_pq_contract.py new file mode 100755 index 0000000000..f54766db21 --- /dev/null +++ b/ci/checks/test_ci_pq_contract.py @@ -0,0 +1,1557 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +"""Executable contract for the PQ CI entry points (#184 part 2, work item C1; part 3, C2). + +``ci-pq.yml`` and ``ci-nightly-heavy.yml`` are reusable and job-selectable, and +the PQ unit job proves which suites ran. These tests read the real workflow +files and scripts and execute their inline steps and shell blocks: + +* job selection: the ``jobs`` input of a call or dispatch becomes the matrix, + and only the selected jobs pass their ``if``. On-demand PQ runs reach + ``ci-pq.yml`` through main's caller (C4), which forwards ``source_ref`` and + ``jobs``; GitHub dispatches only workflows whose file is on the default + branch, so ``gh workflow run ci-pq.yml --ref `` is not the path; +* an unknown, repeated or empty job name fails the run; +* the source is resolved once to a full commit (branch, peeled tag, commit or + the triggering commit), every job checks out and verifies exactly that + commit, and a scheduled call from main still validates the maintained branch; +* a called nightly run gets its caller's github context, so it is recognized by + its ``source_ref`` input: it validates that source (resolved by the same + function as ``ci-pq.yml``) and honors its inputs whatever the caller's event, + while direct runs keep their source, the repository variables of a schedule + and their concurrency groups; callers for different sources never share one; +* suites really ran: ``CI_BUILD_TARGET`` and ``CTEST_REGEX`` reach the build and + ctest commands of ``ci/test/03_test_script.sh``, and ``ctest_evidence.py`` + fails a skipped, failed, missing or unselected expected suite. + +ctest and cmake are replaced by recording stubs; GitHub itself is not called. +""" + +from __future__ import annotations + +import contextlib +import copy +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any +from unittest import mock + +import ctest_evidence +import test_scheduled_validation_contract as svc + +REPO_ROOT = svc.REPO_ROOT +PQ = "pq" +NIGHTLY = "nightly" +WORKFLOWS = { + PQ: svc.WORKFLOW_DIR / "ci-pq.yml", + NIGHTLY: svc.WORKFLOW_DIR / "ci-nightly-heavy.yml", +} +GATE_WORKFLOW = svc.WORKFLOW_DIR / "required-merge-gate.yml" +TEST_SCRIPT = REPO_ROOT / "ci" / "test" / "03_test_script.sh" +MLKEM_TESTS = REPO_ROOT / "src" / "test" / "mlkem_tests.cpp" +BOOST_RECIPE = REPO_ROOT / "depends" / "packages" / "boost.mk" +MLKEM_BACKEND_HEADERS = [REPO_ROOT / "src" / "crypto" / name for name in ("mlkem_arith_backend.h", "mlkem_fips202_backend.h")] +RESOLVER_JOB = svc.RESOLVER_JOB +TRIGGER_STEP = "Check how the run started" +REQUESTED_STEP = "Resolve requested source" +SHARED_RESOLVER = "resolve_source_ref" +SELECT_STEP = "Select jobs" +REPORT_STEP = "Report unit-test evidence" +DEPENDS_INSTALL_STEP = "Install Homebrew packages" +DEPENDS_IDENTIFY_STEP = "Identify depends build" +DEPENDS_RESTORE_STEP = "Restore depends cache" +DEPENDS_SAVE_STEP = "Save depends cache" +DEPENDS_REQUIRE_STEP = "Require depends build" +PQ_UNIT_JOB = "unit" +AARCH64_JOB = "aarch64-unit" +S390X_JOB = "s390x-unit" +MACOS_JOB = "macos-arm64-unit" +MACOS_X86_64_JOB = "macos-x86_64-unit" +# Each macOS job: (runner label, uname -m, the ML-KEM backend that must run, its +# word in the evidence line). The backend is read from the headers that name it. +MACOS_JOBS = { + MACOS_JOB: ("macos-15", "arm64", "aarch64", "native AArch64"), + MACOS_X86_64_JOB: ("macos-15-intel", "x86_64", "x86_64", "native x86_64"), +} +PQ_FUNCTIONAL_JOB = "pq-functional" +PQ_MATRIX_JOBS = [AARCH64_JOB, S390X_JOB] +ALL_PQ_JOBS = {PQ_UNIT_JOB: PQ_MATRIX_JOBS, MACOS_JOB: [], MACOS_X86_64_JOB: [], PQ_FUNCTIONAL_JOB: []} +I686_ENV = "ci/test/00_setup_env_i686_no_ipc.sh" +PQ_SUITES = ["mlkem_tests", "bip324_tests", "net_tests"] +AARCH64_SUITES = PQ_SUITES +NIGHTLY_MATRIX_JOBS = ["arm32-unit-1", "arm32-unit-2", "previous-releases", "fuzz"] +NIGHTLY_ALL_JOBS: dict[str, list[str]] = {"nightly-matrix": NIGHTLY_MATRIX_JOBS, "scanner-readiness": []} +NIGHTLY_VARS_OFF = {"CI_NIGHTLY_HEAVY_RUN_TEST_MATRIX": "false", "CI_NIGHTLY_HEAVY_RUN_SCANNERS": "false"} +SCANNER_SETTINGS = ("SCANNER_SET", "SCANNER_FAIL_POLICY", "SKIP_BUILD_SCANNERS", "SCANNER_MODE") +CALLER_EVENTS = ("schedule", "workflow_dispatch") +CALLER_WORKFLOW = "ci-maintained-heavy.yml" # main's heavy caller (C4) +MATRIX_EXPRESSION = "${{ fromJSON(needs." + RESOLVER_JOB + ".outputs.matrix) }}" +ANNOTATED_TAG = "v9.9.9-annotated" +AMBIGUOUS_NAME = "dup" +SUITE_INVENTORY = ["addrman_tests", "bip324_tests", "bip324_tests_extra", "mlkem_tests", "net_tests", "netbase_tests", "util_tests"] + + +# --------------------------------------------------------------------------- +# Workflow helpers +# --------------------------------------------------------------------------- + + +def run_python_step(fixture: svc.Fixture, step: dict[str, Any], env: dict[str, str], cwd: Path) -> svc.StepResult: + """Execute a ``shell: python3 {0}`` step the way the runner does.""" + assert step.get("shell") == "python3 {0}", step.get("shell") + with tempfile.TemporaryDirectory(prefix="step-", dir=fixture.root) as tmp: + script = Path(tmp) / "step.py" + script.write_text(step["run"], encoding="utf8") + output_path = Path(tmp) / "github-output" + summary_path = Path(tmp) / "step-summary" + output_path.touch() + summary_path.touch() + full_env = fixture.base_env() + full_env.update(env) + full_env["GITHUB_OUTPUT"] = str(output_path) + full_env["GITHUB_STEP_SUMMARY"] = str(summary_path) + completed = subprocess.run([sys.executable, str(script)], cwd=cwd, env=full_env, text=True, capture_output=True) + outputs = svc.parse_key_values(output_path.read_text(encoding="utf8")) + summary = summary_path.read_text(encoding="utf8") + return svc.StepResult(completed, outputs, summary) + + +def resolver_outputs(workflow: dict[str, Any], steps: dict[str, dict[str, str]]) -> dict[str, str]: + """Evaluate the resolver job's ``outputs`` from the outputs of the steps that ran, by step id.""" + context = {"steps": {step_id: {"outputs": outputs} for step_id, outputs in steps.items()}} + job = workflow["jobs"][RESOLVER_JOB] + return {name: svc._to_text(svc.render(value, context)) for name, value in job["outputs"].items()} + + +def call_inputs(workflow: dict[str, Any]) -> dict[str, Any]: + """A called run's inputs: GitHub gives every input the caller does not pass its default.""" + declared = workflow["on"]["workflow_call"].get("inputs") or {} + return {name: spec.get("default") for name, spec in declared.items()} + + +def workflow_ref(github: dict[str, Any], file: str, ref: str) -> str: + """A workflow_ref as GitHub writes it: /.github/workflows/@.""" + return f"{github['repository']}/.github/workflows/{file}@{ref}" + + +def shell_function(script: str, name: str) -> str: + """The definition of a top-level shell function in a step script.""" + lines = script.splitlines() + first = next(index for index, line in enumerate(lines) if line.startswith(f"{name}() {{")) + last = next(index for index in range(first, len(lines)) if lines[index] == "}") + return "\n".join(lines[first:last + 1]) + + +def catalog(workflow: dict[str, Any]) -> list[dict[str, Any]]: + step = svc.find_step(workflow["jobs"][RESOLVER_JOB], SELECT_STEP) + return json.loads(step["env"]["JOB_CATALOG"]) + + +def running_jobs(workflow: dict[str, Any], context: dict[str, Any]) -> dict[str, list[str]]: + """Jobs whose ``if`` passes, with the matrix entries each one would run.""" + outputs = context["needs"][RESOLVER_JOB]["outputs"] + running: dict[str, list[str]] = {} + for job_id, job in workflow["jobs"].items(): + if job_id == RESOLVER_JOB: + continue + condition = job.get("if") + if condition is not None and not svc._truthy(svc.render(condition, context)): + continue + matrix = (job.get("strategy") or {}).get("matrix") + if matrix is None: + running[job_id] = [] + continue + assert matrix == MATRIX_EXPRESSION, f"{job_id} matrix must come from the resolver: {matrix!r}" + running[job_id] = [entry["job"] for entry in json.loads(outputs["matrix"])["include"]] + return running + + +def mlkem_backend_names() -> dict[str, str]: + """The arithmetic/Keccak backend names GetBackendNames() reports, per architecture, read from the backend headers.""" + names: dict[str, list[str]] = {} + for header in MLKEM_BACKEND_HEADERS: + text = header.read_text(encoding="utf8") + for arch, name in re.findall(r'^#(?:el)?if defined\(MLK_SYS_(X86_64|AARCH64)\)\n#define QBIT_MLKEM_\w+_BACKEND_NAME "([^"]+)"', + text, re.M): + names.setdefault(arch.lower(), []).append(name) + assert all(len(pair) == 2 for pair in names.values()) and set(names) == {"x86_64", "aarch64"}, names + return {arch: "/".join(pair) for arch, pair in names.items()} + + +def source_env(env_file: str, names: list[str], extra_env: dict[str, str] | None = None) -> dict[str, str]: + """Source a ci/test env file in bash and return the effective values of names.""" + script = f"set -e\nsource {env_file}\n" + "".join(f'printf "%s=%s\\n" {name} "${{{name}:-}}"\n' for name in names) + env = {"PATH": os.environ["PATH"], "HOME": os.environ.get("HOME", "/tmp")} + env.update(extra_env or {}) + completed = subprocess.run(["bash", "-c", script], cwd=REPO_ROOT, env=env, text=True, capture_output=True, check=True) + return svc.parse_key_values(completed.stdout) + + +# Prints, for each case of argv[2] (a JSON list of [run-mode flags, v2pqtransport_from_run_mode, the +# test's node arguments]), the -v2transport and -v2pqtransport arguments, in order, that the functional +# framework starts a node of the build under test with. The node isn't started: Popen is replaced. +RUN_MODE_PROBE = r''' +import json +import sys +import tempfile +import types +from pathlib import Path +from unittest import mock + +sys.path.insert(0, sys.argv[1]) +from test_framework import test_node +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import get_datadir_path + +BINARIES = ("bitcoin_bin", "bitcoind", "bitcoincli", "bitcoinutil", "bitcointx", "bitcoinchainstate", "bitcoinwallet") + + +class RunMode(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.v2pqtransport_from_run_mode = FROM_RUN_MODE + + def get_binary_paths(self): + return types.SimpleNamespace(**dict.fromkeys(BINARIES, sys.executable), bitcoin_cmd=None) + + def run_test(self): + pass + + +results = [] +for flags, FROM_RUN_MODE, node_args in json.loads(sys.argv[2]): + with tempfile.TemporaryDirectory() as tmp: + config = Path(tmp) / "config.ini" + config.write_text("[environment]\n", encoding="utf8") + with mock.patch.object(sys, "argv", ["probe", f"--configfile={config}", f"--tmpdir={tmp}"] + flags): + framework = RunMode(str(Path(sys.argv[1]) / "probe.py")) + datadir = Path(get_datadir_path(tmp, 0)) + for directory in ("stdout", "stderr"): + (datadir / directory).mkdir(parents=True) + framework.add_nodes(1) + with mock.patch.object(test_node.subprocess, "Popen") as popen: + framework.nodes[0].start(extra_args=list(node_args)) + argv = popen.call_args.args[0] + results.append([arg for arg in argv if arg.startswith(("-v2transport", "-v2pqtransport"))]) +print(json.dumps(results)) +''' + + +def depends_print(host: str, dep_opts: str, *names: str) -> dict[str, str]: + """Values depends/Makefile computes for host with dep_opts, read with its print- rule (nothing is built).""" + targets = " ".join(f"print-{name}" for name in names) + completed = subprocess.run(["bash", "-c", f"make -s -C depends HOST={host} {dep_opts} {targets}"], cwd=REPO_ROOT, + text=True, capture_output=True, check=True) + return {name: value.strip() for name, value in svc.parse_key_values(completed.stdout).items()} + + +def shell_block(text: str, start: str, end_line: str) -> str: + """The lines of text from the line starting with start through the next line equal to end_line.""" + lines = text.splitlines() + first = next(index for index, line in enumerate(lines) if line.startswith(start)) + last = next(index for index in range(first + 1, len(lines)) if lines[index] == end_line) + return "\n".join(lines[first:last + 1]) + "\n" + + +STUB_CTEST = r'''#!/usr/bin/env python3 +import json, os, re, sys +args = sys.argv[1:] +with open(os.environ["STUB_LOG"], "a", encoding="utf8") as log: + log.write(json.dumps(["ctest"] + args) + "\n") +selected = os.environ["STUB_INVENTORY"].split() +junit = None +for flag, value in zip(args, args[1:]): + if flag == "-I": + start, end = (int(part) for part in value.split(",")) + selected = selected[start - 1:end] + elif flag == "-R": + selected = [name for name in selected if re.search(value, name)] + elif flag == "--output-junit": + junit = value +if "--show-only=json-v1" in args: + print(json.dumps({"kind": "ctestInfo", "version": {"major": 1, "minor": 0}, "tests": [{"name": n} for n in selected]})) + sys.exit(0) +if "--show-only" in args: + print("\n".join(selected)) + sys.exit(0) +if not selected and "--no-tests=error" in args: + print("No tests were found!!!") + sys.exit(8) +outcomes = json.loads(os.environ.get("STUB_OUTCOMES") or "{}") +cases = [] +for name in selected: + status = outcomes.get(name, "run") + body = {"notrun": '', "fail": ''}.get(status, "") + cases.append(f'{body}') +if junit: + with open(junit, "w", encoding="utf8") as out: + out.write(f'\n' + "".join(cases) + "\n") +sys.exit(8 if "fail" in outcomes.values() else 0) +''' + +STUB_CMAKE = r'''#!/usr/bin/env python3 +import json, os, sys +with open(os.environ["STUB_LOG"], "a", encoding="utf8") as log: + log.write(json.dumps(["cmake"] + sys.argv[1:]) + "\n") +counter = os.environ["STUB_LOG"] + ".cmake-calls" +calls = int(open(counter).read()) if os.path.exists(counter) else 0 +open(counter, "w").write(str(calls + 1)) +sys.exit(1 if os.environ.get("STUB_FAIL_FIRST") == "1" and calls == 0 else 0) +''' + + +class ScriptRun: + def __init__(self, completed: subprocess.CompletedProcess[str], calls: list[list[str]], files: dict[str, str]) -> None: + self.completed = completed + self.calls = calls + self.files = files # ctest-evidence/ -> contents + + @property + def returncode(self) -> int: + return self.completed.returncode + + +def run_test_script_block(block: str, env: dict[str, str]) -> ScriptRun: + """Run a block of 03_test_script.sh under its own shell options with stub cmake and ctest.""" + with tempfile.TemporaryDirectory(prefix="ci-test-script-") as tmp: + root = Path(tmp) + stubs = root / "stubs" + stubs.mkdir() + for name, body in (("ctest", STUB_CTEST), ("cmake", STUB_CMAKE)): + (stubs / name).write_text(body, encoding="utf8") + (stubs / name).chmod(0o755) + build = root / "build" + build.mkdir() + log = root / "calls.jsonl" + log.touch() + full_env = { + "PATH": f"{stubs}:{os.environ['PATH']}", + "HOME": str(root), + "LC_ALL": "C", + "BASE_ROOT_DIR": str(REPO_ROOT), + "BASE_BUILD_DIR": str(build), + "DEPENDS_DIR": str(root / "depends"), + "HOST": "x86_64-pc-linux-gnu", + "MAKEJOBS": "-j2", + "CTEST_JOBS": "2", + "TEST_RUNNER_TIMEOUT_FACTOR": "1", + "RUN_UNIT_TESTS": "true", + "DIR_UNIT_TEST_DATA": "", + "GOAL": "install", + "STUB_LOG": str(log), + "STUB_INVENTORY": " ".join(SUITE_INVENTORY), + } + full_env.update(env) + script = root / "block.sh" + script.write_text("set -ex\n" + block, encoding="utf8") + completed = subprocess.run(["bash", str(script)], env=full_env, text=True, capture_output=True) + calls = [json.loads(line) for line in log.read_text(encoding="utf8").splitlines()] + evidence = build / "ctest-evidence" + files = {path.name: path.read_text(encoding="utf8") for path in evidence.glob("*")} if evidence.is_dir() else {} + return ScriptRun(completed, calls, files) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class PQWorkflowContractTest(unittest.TestCase): + fixture: svc.Fixture + tag_object: str + workflows: dict[str, dict[str, Any]] + workflow_text: dict[str, str] + + @classmethod + def setUpClass(cls) -> None: + cls.fixture = svc.Fixture() + fixture = cls.fixture + # An annotated tag and a name that is both a branch and a tag. + fixture.git("tag", "-a", ANNOTATED_TAG, "-m", "annotated", fixture.maintained_sha, cwd=fixture.work) + fixture.git("tag", AMBIGUOUS_NAME, fixture.manual_sha, cwd=fixture.work) + fixture.git("push", "-q", "origin", f"refs/tags/{ANNOTATED_TAG}", f"refs/tags/{AMBIGUOUS_NAME}", cwd=fixture.work) + fixture.git("push", "-q", "origin", f"{fixture.default_sha}:refs/heads/{AMBIGUOUS_NAME}", cwd=fixture.work) + cls.tag_object = fixture.git("rev-parse", ANNOTATED_TAG, cwd=fixture.work).strip() + cls.workflows = {key: svc.load_workflow(path) for key, path in WORKFLOWS.items()} + cls.workflow_text = {key: path.read_text(encoding="utf8") for key, path in WORKFLOWS.items()} + + @classmethod + def tearDownClass(cls) -> None: + cls.fixture.close() + + def tearDown(self) -> None: + for key, path in WORKFLOWS.items(): + self.assertEqual(path.read_text(encoding="utf8"), self.workflow_text[key], f"{path} was modified") + + # -- contexts and step runs ------------------------------------------------ + + def direct(self, key: str, context: dict[str, Any]) -> dict[str, Any]: + """A direct run: the job is defined in the workflow file that runs, so job.workflow_ref is github.workflow_ref.""" + github = dict(context["github"]) + github["workflow_ref"] = workflow_ref(github, WORKFLOWS[key].name, github["ref"]) + return dict(context, github=github, job={"status": "success", "workflow_ref": github["workflow_ref"]}) + + def dispatch(self, key: str, **inputs: Any) -> dict[str, Any]: + declared = svc.dispatch_inputs(self.workflows[key]) + unknown = set(inputs) - set(declared) + self.assertFalse(unknown, f"{key} has no workflow_dispatch input(s) {unknown}") + declared.update(inputs) + return self.direct(key, svc.dispatch_context(self.fixture, f"refs/heads/{self.fixture.manual_branch}", self.fixture.manual_sha, declared)) + + def scheduled(self, key: str, **context: Any) -> dict[str, Any]: + """A direct scheduled run on main.""" + return self.direct(key, dict(svc.schedule_context(self.fixture), **context)) + + def call(self, key: str, caller_event: str, *, variables: dict[str, str] | None = None, remote: Path | None = None, + caller_workflow: str = CALLER_WORKFLOW, **inputs: Any) -> dict[str, Any]: + """A run called by main's caller: the github context is the caller's event and + workflow on main, job.workflow_ref is this file at 1.x.x, and every input the + caller does not pass has its default.""" + declared = call_inputs(self.workflows[key]) + unknown = set(inputs) - set(declared) + self.assertFalse(unknown, f"{key} has no workflow_call input(s) {unknown}") + declared.update(inputs) + github = self.fixture.github_context(caller_event, "refs/heads/main", self.fixture.default_sha, remote) + github["workflow_ref"] = workflow_ref(github, caller_workflow, "refs/heads/main") + job = {"status": "success", "workflow_ref": workflow_ref(github, WORKFLOWS[key].name, svc.MAINTAINED_REF)} + return {"github": github, "inputs": declared, "needs": {}, "matrix": {}, "vars": dict(variables or {}), "steps": {}, "job": job} + + def run_resolver_step(self, key: str, name: str, context: dict[str, Any]) -> svc.StepResult: + """Run one bash step of the resolver job, whatever its if.""" + workflow = self.workflows[key] + job = workflow["jobs"][RESOLVER_JOB] + step = svc.find_step(job, name) + env = svc.step_env(workflow, job, step, context) + return svc.run_step(self.fixture, step, env, self.fixture.root, svc.github_env_for(context, RESOLVER_JOB, workflow["name"])) + + def run_resolver(self, key: str, context: dict[str, Any]) -> svc.StepResult: + return self.run_resolver_step(key, svc.RESOLVER_STEP, context) + + def run_resolver_job(self, key: str, context: dict[str, Any]) -> tuple[list[str], dict[str, svc.StepResult]]: + """Run the resolver job's steps the way the runner does: a step whose if is + false is skipped, and a failed step ends the job. Returns the names of the + steps that ran and their results by step id.""" + workflow = self.workflows[key] + job = workflow["jobs"][RESOLVER_JOB] + ran: list[str] = [] + results: dict[str, svc.StepResult] = {} + step_context = dict(context, steps={}) + for step in job["steps"]: + condition = step.get("if") + if condition is not None and not svc._truthy(svc.render(condition, step_context)): + continue + env = svc.step_env(workflow, job, step, step_context) + if step.get("shell") == "python3 {0}": + result = run_python_step(self.fixture, step, env, self.fixture.root) + else: + github_env = svc.github_env_for(context, RESOLVER_JOB, workflow["name"]) + result = svc.run_step(self.fixture, step, env, self.fixture.root, github_env) + ran.append(step["name"]) + results[step["id"]] = result + if result.returncode != 0: + break + step_context["steps"] = dict(step_context["steps"], **{step["id"]: {"outputs": result.outputs}}) + return ran, results + + def run_select(self, key: str, context: dict[str, Any]) -> svc.StepResult: + workflow = self.workflows[key] + job = workflow["jobs"][RESOLVER_JOB] + step = svc.find_step(job, SELECT_STEP) + return run_python_step(self.fixture, step, svc.step_env(workflow, job, step, context), self.fixture.root) + + def resolve(self, key: str, context: dict[str, Any]) -> dict[str, Any]: + """Run the resolver job; return the context with its outputs as ``needs``.""" + _ran, results = self.run_resolver_job(key, context) + for result in results.values(): + self.assertEqual(result.returncode, 0, result.stderr) + steps = {step_id: result.outputs for step_id, result in results.items()} + return svc.with_needs(context, resolver_outputs(self.workflows[key], steps)) + + def nightly_run(self, context: dict[str, Any]) -> dict[str, Any]: + """What a nightly run does with a context: the resolver step that ran, the + source, the jobs that run, the scanner settings and the concurrency group.""" + workflow = self.workflows[NIGHTLY] + ran, results = self.run_resolver_job(NIGHTLY, context) + for result in results.values(): + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(ran[-1], SELECT_STEP) + context = svc.with_needs(context, resolver_outputs(workflow, {step_id: result.outputs for step_id, result in results.items()})) + outputs = context["needs"][RESOLVER_JOB]["outputs"] + scanner_env = svc.job_env(workflow, workflow["jobs"]["scanner-readiness"], context) + self.assertEqual(ran[0], TRIGGER_STEP) + return { + "resolvers": ran[1:-1], + "source": (outputs["source_mode"], outputs["requested_ref"], outputs["resolved_sha"]), + "jobs": running_jobs(workflow, context), + "scanner": {name: scanner_env[name] for name in SCANNER_SETTINGS}, + "group": svc.render(workflow["concurrency"]["group"], context), + "summary": "".join(result.summary for result in results.values()), + "context": context, + } + + # -- entry points ------------------------------------------------------------ + + def test_entry_points(self) -> None: + pq = self.workflows[PQ] + for trigger in ("workflow_call", "workflow_dispatch"): + with self.subTest(workflow=PQ, trigger=trigger): + inputs = pq["on"][trigger]["inputs"] + self.assertEqual(set(inputs), {"source_ref", "jobs"}) + for spec in inputs.values(): + self.assertEqual((spec["type"], spec["default"], spec["required"]), ("string", "", False)) + + nightly = self.workflows[NIGHTLY] + self.assertIn("schedule", nightly["on"]) + call = nightly["on"]["workflow_call"]["inputs"] + dispatch = nightly["on"]["workflow_dispatch"]["inputs"] + # Only a call names its source, so a direct dispatch validates the ref it was dispatched on. + self.assertNotIn("source_ref", dispatch) + source = call["source_ref"] + self.assertEqual((source["type"], source["default"], source["required"]), ("string", svc.MAINTAINED_BRANCH, False)) + self.assertIn("must not be empty", source["description"]) + self.assertEqual([name for name in call if name != "source_ref"], list(dispatch), + "the other workflow_call inputs must mirror workflow_dispatch") + self.assertIn("jobs", call) + for name, spec in dispatch.items(): + with self.subTest(workflow=NIGHTLY, input=name): + mirrored = call[name] + expected_type = "string" if spec["type"] == "choice" else spec["type"] + self.assertEqual(mirrored["type"], expected_type) + self.assertEqual(mirrored["default"], spec["default"]) + self.assertEqual(mirrored["required"], spec["required"]) + if spec["type"] == "choice": + self.assertIn(spec["default"], spec["options"]) + # The scanner's secret reaches a called run. + self.assertEqual(set(nightly["on"]["workflow_call"]["secrets"]), {"LIBBITCOINPQC_READ_TOKEN"}) + self.assertIn("secrets.LIBBITCOINPQC_READ_TOKEN", self.workflow_text[NIGHTLY]) + + def test_selection_scripts_are_identical(self) -> None: + scripts = {key: svc.find_step(workflow["jobs"][RESOLVER_JOB], SELECT_STEP)["run"] for key, workflow in self.workflows.items()} + self.assertEqual(scripts[PQ], scripts[NIGHTLY]) + for key, workflow in self.workflows.items(): + resolver = workflow["jobs"][RESOLVER_JOB] + self.assertFalse(any("uses" in step for step in resolver["steps"]), f"{key}: selection must run inline") + names = [entry["job"] for entry in catalog(workflow)] + self.assertEqual(len(names), len(set(names)), key) + + # -- named-dispatch selection -------------------------------------------------- + + def test_named_dispatch_runs_only_selected_jobs(self) -> None: + cases: list[tuple[str, dict[str, Any], dict[str, list[str]]]] = [ + (PQ, {"jobs": AARCH64_JOB}, {PQ_UNIT_JOB: [AARCH64_JOB]}), + (PQ, {"jobs": S390X_JOB}, {PQ_UNIT_JOB: [S390X_JOB]}), + (PQ, {"jobs": MACOS_JOB}, {MACOS_JOB: []}), + (PQ, {"jobs": MACOS_X86_64_JOB}, {MACOS_X86_64_JOB: []}), + (PQ, {"jobs": f" {MACOS_JOB} , {AARCH64_JOB} "}, {PQ_UNIT_JOB: [AARCH64_JOB], MACOS_JOB: []}), + # What Full Validation's pq-unit job passes. + (PQ, {"jobs": f"{AARCH64_JOB},{MACOS_JOB},{MACOS_X86_64_JOB}"}, + {PQ_UNIT_JOB: [AARCH64_JOB], MACOS_JOB: [], MACOS_X86_64_JOB: []}), + # What Full Validation's pq-functional job passes. + (PQ, {"jobs": PQ_FUNCTIONAL_JOB}, {PQ_FUNCTIONAL_JOB: []}), + (PQ, {"jobs": ""}, ALL_PQ_JOBS), + (NIGHTLY, {"run_test_matrix": True, "run_scanners": False, "jobs": "previous-releases"}, + {"nightly-matrix": ["previous-releases"]}), + (NIGHTLY, {"jobs": ""}, {"nightly-matrix": NIGHTLY_MATRIX_JOBS, "scanner-readiness": []}), + (NIGHTLY, {"jobs": " fuzz , previous-releases "}, {"nightly-matrix": ["previous-releases", "fuzz"]}), + (NIGHTLY, {"jobs": "scanner"}, {"scanner-readiness": []}), + (NIGHTLY, {"run_test_matrix": False, "jobs": "previous-releases"}, {}), + ] + for key, inputs, expected in cases: + with self.subTest(workflow=key, inputs=inputs): + context = self.resolve(key, self.dispatch(key, **inputs)) + self.assertEqual(running_jobs(self.workflows[key], context), expected) + + # The nightly run keeps its entries' settings, such as the shard ranges and the fuzz mutation time. + entries = {entry["job"]: entry for entry in catalog(self.workflows[NIGHTLY])} + self.assertEqual(entries["arm32-unit-2"]["ctest_include_range"], "140,99999") + self.assertEqual(entries["fuzz"]["fuzz-mutate-min-time"], "120") + + def test_scheduled_call_from_main_runs_everything_on_the_maintained_branch(self) -> None: + context = self.resolve(NIGHTLY, self.call(NIGHTLY, "schedule")) + outputs = context["needs"][RESOLVER_JOB]["outputs"] + self.assertEqual(outputs["resolved_sha"], self.fixture.maintained_sha) + self.assertEqual(outputs["requested_ref"], svc.MAINTAINED_REF) + self.assertEqual(running_jobs(self.workflows[NIGHTLY], context), + {"nightly-matrix": NIGHTLY_MATRIX_JOBS, "scanner-readiness": []}) + refs = svc.check_source_wiring(self.workflows[NIGHTLY], context) + self.assertEqual(set(refs.values()), {self.fixture.maintained_sha}) + + # The PQ workflow is told which branch to validate. + context = self.resolve(PQ, self.call(PQ, "schedule", source_ref=svc.MAINTAINED_BRANCH)) + outputs = context["needs"][RESOLVER_JOB]["outputs"] + self.assertEqual(outputs["resolved_sha"], self.fixture.maintained_sha) + self.assertNotEqual(outputs["resolved_sha"], context["github"]["sha"]) + self.assertEqual(running_jobs(self.workflows[PQ], context), ALL_PQ_JOBS) + + # -- called and direct nightly runs --------------------------------------------- + + def test_called_nightly_run_validates_source_ref_whatever_the_caller(self) -> None: + fixture = self.fixture + workflow = self.workflows[NIGHTLY] + defaults = {"SCANNER_SET": "minimum", "SCANNER_FAIL_POLICY": "infra-and-secrets", "SKIP_BUILD_SCANNERS": "false"} + for event in CALLER_EVENTS: + mode = "nightly" if event == "schedule" else "manual_dry_run" + with self.subTest(caller=event): + # The repository variables that switch a direct scheduled run off do not apply to a called one. + run = self.nightly_run(self.call(NIGHTLY, event, variables=NIGHTLY_VARS_OFF)) + github = run["context"]["github"] + self.assertEqual((github["ref"], github["sha"]), ("refs/heads/main", fixture.default_sha)) + self.assertEqual(run["resolvers"], [REQUESTED_STEP]) + self.assertEqual(run["source"], ("maintained", svc.MAINTAINED_REF, fixture.maintained_sha)) + self.assertEqual(run["jobs"], NIGHTLY_ALL_JOBS) + self.assertEqual(run["scanner"], dict(defaults, SCANNER_MODE=mode)) + for row in ("| source_ref input | 1.x.x |", "| source_mode | maintained |", f"| caller_event | {event} |", + f"| resolved_sha | {fixture.maintained_sha} |"): + self.assertIn(row, run["summary"]) + refs = svc.check_source_wiring(workflow, run["context"]) + self.assertEqual(set(refs.values()), {fixture.maintained_sha}) + fixture.checkout_work(fixture.maintained_sha) + for job_id in sorted(refs): + job_context = svc.matrix_context(workflow, job_id, run["context"]) + verify = svc.run_job_step(fixture, workflow, job_id, svc.VERIFY_STEP, job_context, fixture.work) + self.assertEqual(verify.returncode, 0, verify.stderr) + self.assertIn("- source_ref input: 1.x.x\n- source_mode: maintained\n", verify.summary) + + # The default source is looked up on the remote, never taken from the caller. + ran, results = self.run_resolver_job(NIGHTLY, self.call(NIGHTLY, event, remote=fixture.remote_without_maintained)) + self.assertEqual(ran, [TRIGGER_STEP, REQUESTED_STEP]) + self.assertNotEqual(results["requested"].returncode, 0) + self.assertIn(f"::error::no branch or tag '{svc.MAINTAINED_BRANCH}'", results["requested"].stderr) + + # Every input is honored. + honored: list[tuple[dict[str, Any], dict[str, list[str]], dict[str, str]]] = [ + ({"run_test_matrix": False}, {"scanner-readiness": []}, {}), + ({"run_scanners": False}, {"nightly-matrix": NIGHTLY_MATRIX_JOBS}, {}), + ({"run_test_matrix": False, "run_scanners": False}, {}, {}), + ({"jobs": "fuzz"}, {"nightly-matrix": ["fuzz"]}, {}), + ({"scanner_set": "secrets-only", "scanner_fail_policy": "never", "skip_build_scanners": True}, NIGHTLY_ALL_JOBS, + {"SCANNER_SET": "secrets-only", "SCANNER_FAIL_POLICY": "never", "SKIP_BUILD_SCANNERS": "true"}), + ] + for inputs, jobs, scanner in honored: + with self.subTest(caller=event, inputs=inputs): + run = self.nightly_run(self.call(NIGHTLY, event, **inputs)) + self.assertEqual(run["source"][2], fixture.maintained_sha) + self.assertEqual(run["jobs"], jobs) + self.assertEqual(run["scanner"], dict(defaults, SCANNER_MODE=mode, **scanner)) + + def test_called_nightly_run_resolves_each_kind_of_source(self) -> None: + fixture = self.fixture + manual_ref = f"refs/heads/{fixture.manual_branch}" + cases = [ + (fixture.manual_branch, manual_ref, fixture.manual_sha, "requested"), + (manual_ref, manual_ref, fixture.manual_sha, "requested"), + (fixture.manual_tag, f"refs/tags/{fixture.manual_tag}", fixture.manual_sha, "requested"), + (ANNOTATED_TAG, f"refs/tags/{ANNOTATED_TAG}", fixture.maintained_sha, "requested"), + (fixture.manual_sha, fixture.manual_sha, fixture.manual_sha, "requested"), + # A hash never names the maintained branch, even at its tip. + (fixture.maintained_sha, fixture.maintained_sha, fixture.maintained_sha, "requested"), + (svc.MAINTAINED_REF, svc.MAINTAINED_REF, fixture.maintained_sha, "maintained"), + (f" {svc.MAINTAINED_BRANCH} ", svc.MAINTAINED_REF, fixture.maintained_sha, "maintained"), + ] + # A dispatched caller: github.ref and github.sha are main's. + for source_ref, requested_ref, resolved_sha, mode in cases: + with self.subTest(source_ref=source_ref): + run = self.nightly_run(self.call(NIGHTLY, "workflow_dispatch", source_ref=source_ref)) + self.assertEqual(run["resolvers"], [REQUESTED_STEP]) + self.assertEqual(run["source"], (mode, requested_ref, resolved_sha)) + self.assertIn(f"| source_ref input | {source_ref.strip()} |", run["summary"]) + self.assertIn(f"| source_mode | {mode} |", run["summary"]) + self.assertEqual(set(svc.check_source_wiring(self.workflows[NIGHTLY], run["context"]).values()), {resolved_sha}) + + rejected = { + "no-such-branch": "::error::no branch or tag 'no-such-branch'", + fixture.default_sha[:12]: "::error::no branch or tag", + AMBIGUOUS_NAME: f"::error::source_ref '{AMBIGUOUS_NAME}' must name exactly one branch or tag", + "refs/pull/1/head": "::error::source_ref 'refs/pull/1/head' must be a branch, a tag or a full commit hash", + "bad..name": "::error::source_ref 'bad..name' is not a valid ref name", + " ": "::error::source_ref is empty", + } + for source_ref, message in rejected.items(): + with self.subTest(rejected=source_ref): + ran, results = self.run_resolver_job(NIGHTLY, self.call(NIGHTLY, "workflow_dispatch", source_ref=source_ref)) + self.assertEqual(ran, [TRIGGER_STEP, REQUESTED_STEP], "a failed resolution ends the job") + self.assertNotEqual(results["requested"].returncode, 0) + self.assertIn(message, results["requested"].stderr) + self.assertNotIn("resolved_sha", results["requested"].outputs) + + def test_nightly_run_cross_checks_how_it_started(self) -> None: + fixture = self.fixture + direct = self.dispatch(NIGHTLY) + cases: list[tuple[str, dict[str, Any], list[str], str]] = [] + for event in CALLER_EVENTS: + cases += [ + (f"called by a {event} caller with a source_ref", self.call(NIGHTLY, event), + [TRIGGER_STEP, REQUESTED_STEP, SELECT_STEP], "| cross-check | passed: called |"), + # Only @ref tells main's own ci-nightly-heavy.yml calling this copy from a direct run. + (f"called by main's own copy on {event}", self.call(NIGHTLY, event, caller_workflow=WORKFLOWS[NIGHTLY].name), + [TRIGGER_STEP, REQUESTED_STEP, SELECT_STEP], "| cross-check | passed: called |"), + (f"called by a {event} caller with an empty source_ref", self.call(NIGHTLY, event, source_ref=""), + [TRIGGER_STEP], "with an empty source_ref; the caller must pass the branch, tag or commit to validate"), + ] + cases += [ + ("direct without a source_ref", direct, [TRIGGER_STEP, svc.RESOLVER_STEP, SELECT_STEP], "| cross-check | passed: direct |"), + ("direct schedule without a source_ref", self.scheduled(NIGHTLY), [TRIGGER_STEP, svc.RESOLVER_STEP, SELECT_STEP], + "| cross-check | passed: direct |"), + ("direct with a source_ref", dict(direct, inputs=dict(direct["inputs"], source_ref=svc.MAINTAINED_BRANCH)), + [TRIGGER_STEP], f"source_ref '{svc.MAINTAINED_BRANCH}' is set, but this run was not called"), + ] + for label, context, steps, expected in cases: + with self.subTest(label): + ran, results = self.run_resolver_job(NIGHTLY, context) + self.assertEqual(ran, steps) + check = results["trigger"] + if steps == [TRIGGER_STEP]: + self.assertNotEqual(check.returncode, 0) + self.assertIn("::error::", check.stderr) + self.assertIn(expected, check.stderr) + self.assertNotIn(fixture.default_sha, json.dumps({key: result.outputs for key, result in results.items()})) + else: + self.assertEqual(check.returncode, 0, check.stderr) + self.assertIn(expected, check.summary) + self.assertIn(f"| job.workflow_ref | {context['job']['workflow_ref']} |", check.summary) + + # Without job.workflow_ref the check is skipped and says so, and the + # source_ref rule alone decides: then a caller's empty source_ref is a + # direct run on main's ref, which is the gap the check closes. + for label, context, source in ( + ("called with a source_ref", self.call(NIGHTLY, "workflow_dispatch"), + ("maintained", svc.MAINTAINED_REF, fixture.maintained_sha)), + ("direct", self.dispatch(NIGHTLY), ("manual", f"refs/heads/{fixture.manual_branch}", fixture.manual_sha)), + ("called with an empty source_ref", self.call(NIGHTLY, "workflow_dispatch", source_ref=""), + ("manual", "refs/heads/main", fixture.default_sha)), + ): + for job in ({"status": "success"}, None): + with self.subTest(f"{label}, job.workflow_ref unavailable", job=job): + run = self.nightly_run(dict(context, job=job)) + self.assertEqual(run["source"], source) + self.assertIn("| job.workflow_ref | (not available) |", run["summary"]) + self.assertIn("| cross-check | skipped: job.workflow_ref is not available |", run["summary"]) + + def test_called_nightly_and_pq_resolve_source_ref_alike(self) -> None: + nightly_step = svc.find_step(self.workflows[NIGHTLY]["jobs"][RESOLVER_JOB], REQUESTED_STEP) + pq_step = svc.find_step(self.workflows[PQ]["jobs"][RESOLVER_JOB], svc.RESOLVER_STEP) + self.assertEqual(shell_function(nightly_step["run"], SHARED_RESOLVER), shell_function(pq_step["run"], SHARED_RESOLVER)) + # The caller's ref and commit never reach the called run's resolver. + for name, value in nightly_step["env"].items(): + self.assertNotRegex(str(value), r"github\.(ref|sha)\b", name) + self.assertNotRegex(nightly_step["run"], r"GITHUB_(REF|SHA)\b") + sources = [svc.MAINTAINED_BRANCH, f"refs/tags/{self.fixture.manual_tag}", ANNOTATED_TAG, self.fixture.manual_sha, + "no-such-branch", AMBIGUOUS_NAME, "refs/pull/1/head", "bad..name", self.fixture.manual_sha[:12]] + for source_ref in sources: + with self.subTest(source_ref=source_ref): + pq = self.run_resolver(PQ, self.dispatch(PQ, source_ref=source_ref)) + nightly = self.run_resolver_step(NIGHTLY, REQUESTED_STEP, self.call(NIGHTLY, "workflow_dispatch", source_ref=source_ref)) + self.assertEqual(pq.returncode == 0, nightly.returncode == 0, pq.stderr + nightly.stderr) + if pq.returncode == 0: + for output in ("requested_ref", "resolved_sha"): + self.assertEqual(pq.outputs[output], nightly.outputs[output]) + else: + self.assertEqual(pq.stderr.strip().splitlines()[-1], nightly.stderr.strip().splitlines()[-1]) + + def test_direct_nightly_runs_keep_their_source_and_settings(self) -> None: + fixture = self.fixture + # A direct scheduled run: the maintained branch, the repository variables and the scheduled defaults. + scheduled = {"SCANNER_SET": "minimum", "SCANNER_FAIL_POLICY": "infra-and-secrets", "SKIP_BUILD_SCANNERS": "true", + "SCANNER_MODE": "nightly"} + scheduled_cases: tuple[tuple[dict[str, str], dict[str, list[str]]], ...] = ( + ({}, NIGHTLY_ALL_JOBS), + ({"CI_NIGHTLY_HEAVY_RUN_TEST_MATRIX": "true", "CI_NIGHTLY_HEAVY_RUN_SCANNERS": "true"}, NIGHTLY_ALL_JOBS), + ({"CI_NIGHTLY_HEAVY_RUN_TEST_MATRIX": "false"}, {"scanner-readiness": []}), + ({"CI_NIGHTLY_HEAVY_RUN_SCANNERS": "false"}, {"nightly-matrix": NIGHTLY_MATRIX_JOBS}), + (NIGHTLY_VARS_OFF, {}), + ) + for variables, jobs in scheduled_cases: + with self.subTest(trigger="schedule", variables=variables): + run = self.nightly_run(self.scheduled(NIGHTLY, vars=variables)) + self.assertEqual(run["resolvers"], [svc.RESOLVER_STEP]) + self.assertEqual(run["source"], ("maintained", svc.MAINTAINED_REF, fixture.maintained_sha)) + self.assertEqual(run["jobs"], jobs) + self.assertEqual(run["scanner"], scheduled) + self.assertEqual(run["group"], "nightly-heavy-nightly-1.x.x") + + # A direct dispatch: manual mode on the selected ref and its commit, following its inputs, not the variables. + manual = ("manual", f"refs/heads/{fixture.manual_branch}", fixture.manual_sha) + dispatched = {"SCANNER_SET": "minimum", "SCANNER_FAIL_POLICY": "infra-and-secrets", "SKIP_BUILD_SCANNERS": "false", + "SCANNER_MODE": "manual_dry_run"} + dispatched_cases: tuple[tuple[dict[str, Any], dict[str, list[str]], dict[str, str]], ...] = ( + ({}, NIGHTLY_ALL_JOBS, {}), + ({"run_test_matrix": False, "skip_build_scanners": True}, {"scanner-readiness": []}, {"SKIP_BUILD_SCANNERS": "true"}), + ({"run_scanners": False, "jobs": "previous-releases"}, {"nightly-matrix": ["previous-releases"]}, {}), + ) + for inputs, jobs, scanner in dispatched_cases: + with self.subTest(trigger="workflow_dispatch", inputs=inputs): + run = self.nightly_run(dict(self.dispatch(NIGHTLY, **inputs), vars=NIGHTLY_VARS_OFF)) + self.assertEqual(run["resolvers"], [svc.RESOLVER_STEP]) + self.assertEqual(run["source"], manual) + self.assertEqual(run["jobs"], jobs) + self.assertEqual(run["scanner"], dict(dispatched, **scanner)) + self.assertEqual(run["group"], f"nightly-heavy-refs/heads/{fixture.manual_branch}") + + # The verify step records that no source_ref was given. + run = self.nightly_run(self.scheduled(NIGHTLY)) + fixture.checkout_work(fixture.maintained_sha) + context = svc.matrix_context(self.workflows[NIGHTLY], "nightly-matrix", run["context"]) + verify = svc.run_job_step(fixture, self.workflows[NIGHTLY], "nightly-matrix", svc.VERIFY_STEP, context, fixture.work) + self.assertEqual(verify.returncode, 0, verify.stderr) + self.assertIn("- source_ref input: (none)\n- source_mode: maintained\n", verify.summary) + + def test_nightly_concurrency_keeps_sources_and_callers_apart(self) -> None: + fixture = self.fixture + workflow = self.workflows[NIGHTLY] + + def group(context: dict[str, Any]) -> str: + return svc._to_text(svc.render(workflow["concurrency"]["group"], context)) + + groups = { + "direct schedule": group(self.scheduled(NIGHTLY)), + "direct dispatch": group(self.dispatch(NIGHTLY)), + "scheduled caller": group(self.call(NIGHTLY, "schedule")), + "dispatched caller, maintained branch": group(self.call(NIGHTLY, "workflow_dispatch")), + "dispatched caller, branch": group(self.call(NIGHTLY, "workflow_dispatch", source_ref=fixture.manual_branch)), + "dispatched caller, tag": group(self.call(NIGHTLY, "workflow_dispatch", source_ref=fixture.manual_tag)), + "dispatched caller, commit": group(self.call(NIGHTLY, "workflow_dispatch", source_ref=fixture.manual_sha)), + } + self.assertEqual(len(set(groups.values())), len(groups), groups) + self.assertEqual(groups["scheduled caller"], "nightly-heavy-call-schedule-1.x.x") + self.assertEqual(groups["dispatched caller, branch"], f"nightly-heavy-call-workflow_dispatch-{fixture.manual_branch}") + # A second call for the same source still replaces the one in progress. + self.assertEqual(group(self.call(NIGHTLY, "workflow_dispatch", source_ref=fixture.manual_branch, jobs="fuzz")), + groups["dispatched caller, branch"]) + # The group is evaluated before any job runs, from the contexts GitHub allows there. + self.assertNotRegex(workflow["concurrency"]["group"], r"\b(needs|steps|env|job)\.") + + def test_rejects_unknown_repeated_and_empty_job_names(self) -> None: + bad = { + PQ: ["aarch64", "AARCH64-UNIT", f"{AARCH64_JOB},{AARCH64_JOB}", f"{AARCH64_JOB},", ",", "s390x", "macos-arm64", + f"{MACOS_JOB},{MACOS_JOB}", "macos-x86_64", "macos-intel", "pq_functional", "functional"], + NIGHTLY: ["previous_releases", "previous-releases,previous-releases", "previous-releases,,fuzz", "unit"], + } + for key, values in bad.items(): + for value in values: + with self.subTest(workflow=key, jobs=value): + result = self.run_select(key, self.dispatch(key, jobs=value)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("::error::", result.stderr) + self.assertNotIn("matrix", result.outputs) + + # -- source resolution --------------------------------------------------------- + + def test_source_ref_resolves_once_to_a_commit(self) -> None: + fixture = self.fixture + cases = [ + ("", f"refs/heads/{fixture.manual_branch}", fixture.manual_sha, "event"), + (svc.MAINTAINED_BRANCH, svc.MAINTAINED_REF, fixture.maintained_sha, "ref"), + (f" {svc.MAINTAINED_BRANCH} ", svc.MAINTAINED_REF, fixture.maintained_sha, "ref"), + (svc.MAINTAINED_REF, svc.MAINTAINED_REF, fixture.maintained_sha, "ref"), + (ANNOTATED_TAG, f"refs/tags/{ANNOTATED_TAG}", fixture.maintained_sha, "ref"), + (fixture.manual_tag, f"refs/tags/{fixture.manual_tag}", fixture.manual_sha, "ref"), + (fixture.default_sha, fixture.default_sha, fixture.default_sha, "commit"), + ] + for source_ref, requested_ref, resolved_sha, mode in cases: + with self.subTest(source_ref=source_ref): + result = self.run_resolver(PQ, self.dispatch(PQ, source_ref=source_ref)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.outputs["requested_ref"], requested_ref) + self.assertEqual(result.outputs["resolved_sha"], resolved_sha) + self.assertEqual(result.outputs["source_mode"], mode) + self.assertIn(f"| resolved_sha | {resolved_sha} |", result.summary) + # The annotated tag is peeled: its tag object is not what gets checked out. + self.assertNotEqual(self.tag_object, fixture.maintained_sha) + + rejected = { + "no-such-branch": "no branch or tag 'no-such-branch'", + fixture.default_sha[:12]: "no branch or tag", + AMBIGUOUS_NAME: "must name exactly one branch or tag", + "refs/pull/1/head": "must be a branch, a tag or a full commit hash", + "bad..name": "is not a valid ref name", + } + for source_ref, message in rejected.items(): + with self.subTest(rejected=source_ref): + result = self.run_resolver(PQ, self.dispatch(PQ, source_ref=source_ref)) + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"::error::source_ref '{source_ref}' " if "source_ref" in message else "::error::", result.stderr) + self.assertIn(message, result.stderr) + self.assertNotIn("resolved_sha", result.outputs) + + def test_every_pq_job_checks_out_the_resolved_commit(self) -> None: + fixture = self.fixture + workflow = self.workflows[PQ] + context = self.resolve(PQ, self.dispatch(PQ, source_ref=svc.MAINTAINED_BRANCH, jobs=AARCH64_JOB)) + refs = svc.check_source_wiring(workflow, context) + self.assertEqual(refs, {job: fixture.maintained_sha for job in ALL_PQ_JOBS}) + # The macOS and functional jobs share the unit job's guard, checkout and verification steps. + for job_id in (*MACOS_JOBS, PQ_FUNCTIONAL_JOB): + for step_name in (svc.GUARD_STEP, svc.CHECKOUT_STEP, svc.VERIFY_STEP): + self.assertEqual(svc.find_step(workflow["jobs"][job_id], step_name), + svc.find_step(workflow["jobs"][PQ_UNIT_JOB], step_name), (job_id, step_name)) + + mutated = copy.deepcopy(workflow) + checkout = svc.find_step(mutated["jobs"][PQ_UNIT_JOB], svc.CHECKOUT_STEP) + checkout["with"]["ref"] = "${{ github.sha }}" + with self.assertRaises(AssertionError): + svc.check_source_wiring(mutated, context) + + job_context = dict(context, matrix=catalog(workflow)[0]) + fixture.checkout_work(fixture.maintained_sha) + guard = svc.run_job_step(fixture, workflow, PQ_UNIT_JOB, svc.GUARD_STEP, job_context, fixture.root) + self.assertEqual(guard.returncode, 0, guard.stderr) + verify = svc.run_job_step(fixture, workflow, PQ_UNIT_JOB, svc.VERIFY_STEP, job_context, fixture.work) + self.assertEqual(verify.returncode, 0, verify.stderr) + self.assertIn(f"- actual_head: {fixture.maintained_sha}", verify.summary) + + # A checkout that moved (for example a branch push between jobs) is refused. + fixture.checkout_work(fixture.manual_sha) + verify = svc.run_job_step(fixture, workflow, PQ_UNIT_JOB, svc.VERIFY_STEP, job_context, fixture.work) + self.assertNotEqual(verify.returncode, 0) + self.assertIn("does not match resolved source", verify.stderr) + + # -- the aarch64 job and its suites ---------------------------------------------- + + def test_aarch64_job_runs_on_a_public_arm_runner(self) -> None: + workflow = self.workflows[PQ] + (entry,) = [entry for entry in catalog(workflow) if entry["job"] == AARCH64_JOB] + self.assertEqual(entry["runs-on"], "ubuntu-24.04-arm") + self.assertIn("mlkem_tests", entry["phase"]) + env_file = entry["file-env"] + self.assertTrue(os.access(REPO_ROOT / env_file, os.X_OK), env_file) + + context = dict(self.resolve(PQ, self.dispatch(PQ, jobs=AARCH64_JOB)), matrix=entry) + env = svc.job_env(workflow, workflow["jobs"][PQ_UNIT_JOB], context) + self.assertEqual(env["FILE_ENV"], env_file) + self.assertEqual(env["CI_IMAGE_REGISTRY_PREFIX"], "docker.io/library") + self.assertEqual(env["CI_ENFORCE_INTERNAL_REGISTRY"], "0") + self.assertEqual(env["CI_PROFILE"], "public") + self.assertNotIn("100.100.100.100", env["CI_BUILDKIT_DNS_NAMESERVERS"]) + self.assertNotIn("ts.net", env["CI_BUILDKIT_DNS_SEARCH_DOMAINS"]) + self.assertEqual(env["DANGER_CI_ON_HOST_FOLDERS"], "1", "the evidence must reach the host through BASE_BUILD_DIR") + + names = ["CI_BUILD_TARGET", "CTEST_REGEX", "CTEST_EXPECTED_SUITES", "CTEST_INCLUDE_RANGE", "CI_IMAGE_NAME_TAG", "RUN_FUNCTIONAL_TESTS"] + effective = source_env(env_file, names, {key: env[key] for key in ("CI_IMAGE_REGISTRY_PREFIX", "CI_ENFORCE_INTERNAL_REGISTRY")}) + self.assertEqual(effective["CI_BUILD_TARGET"], "test_bitcoin") + self.assertEqual(effective["CTEST_EXPECTED_SUITES"].split(), AARCH64_SUITES) + self.assertEqual(effective["CTEST_INCLUDE_RANGE"], "") + self.assertEqual(effective["CI_IMAGE_NAME_TAG"], "docker.io/library/ubuntu:24.04") + self.assertEqual(effective["RUN_FUNCTIONAL_TESTS"], "false") + regex = re.compile(effective["CTEST_REGEX"]) + self.assertEqual([name for name in SUITE_INVENTORY if regex.search(name)], sorted(AARCH64_SUITES)) + + # 02_run_container.py passes only variables exported by some ci/test/00_setup_env*.sh into the container. + exported = set() + for path in (REPO_ROOT / "ci" / "test").glob("00_setup_env*.sh"): + exported.update(re.findall(r"^\s*export ([A-Z_][A-Z0-9_]*)=", path.read_text(encoding="utf8"), re.M)) + for name in ("CI_BUILD_TARGET", "CTEST_REGEX", "CTEST_EXPECTED_SUITES", "CTEST_INCLUDE_RANGE"): + self.assertIn(name, exported) + + def test_s390x_job_cross_compiles_and_emulates_only_the_tests(self) -> None: + workflow = self.workflows[PQ] + (entry,) = [entry for entry in catalog(workflow) if entry["job"] == S390X_JOB] + self.assertEqual(entry["runs-on"], "ubuntu-24.04", "built on an x64 runner, not in an emulated container") + self.assertEqual(entry["timeout-minutes"], 180) + env_file = entry["file-env"] + self.assertTrue(os.access(REPO_ROOT / env_file, os.X_OK), env_file) + + context = dict(self.resolve(PQ, self.dispatch(PQ, jobs=S390X_JOB)), matrix=entry) + env = svc.job_env(workflow, workflow["jobs"][PQ_UNIT_JOB], context) + self.assertEqual(env["FILE_ENV"], env_file) + names = ["HOST", "PACKAGES", "DEP_OPTS", "NO_DEPENDS", "CI_IMAGE_PLATFORM", "CI_BUILD_TARGET", "CTEST_REGEX", + "CTEST_EXPECTED_SUITES", "CTEST_INCLUDE_RANGE", "RUN_FUNCTIONAL_TESTS", "BITCOIN_CONFIG"] + effective = source_env(env_file, names, {key: env[key] for key in ("CI_IMAGE_REGISTRY_PREFIX", "CI_ENFORCE_INTERNAL_REGISTRY")}) + self.assertEqual(effective["HOST"], "s390x-linux-gnu") + self.assertEqual(effective["NO_DEPENDS"], "", "the depends tree is built for s390x") + self.assertEqual(effective["CI_IMAGE_PLATFORM"], "", "only the tests run emulated") + self.assertIn("g++-s390x-linux-gnu", effective["PACKAGES"].split()) + self.assertIn("qemu-user", effective["PACKAGES"].split()) + self.assertEqual(effective["CI_BUILD_TARGET"], "test_bitcoin") + self.assertEqual(effective["CTEST_EXPECTED_SUITES"].split(), PQ_SUITES) + self.assertEqual(effective["CTEST_INCLUDE_RANGE"], "") + self.assertEqual(effective["RUN_FUNCTIONAL_TESTS"], "false") + regex = re.compile(effective["CTEST_REGEX"]) + self.assertEqual([name for name in SUITE_INVENTORY if regex.search(name)], sorted(PQ_SUITES)) + # ctest runs each test through the emulator, so test_qbit is never started directly. + self.assertIn("-DCMAKE_CROSSCOMPILING_EMULATOR='qemu-s390x;-L;/usr/s390x-linux-gnu'", effective["BITCOIN_CONFIG"]) + + def test_macos_jobs_run_natively_on_apple_silicon_and_intel(self) -> None: + workflow = self.workflows[PQ] + catalog_names = [entry["job"] for entry in catalog(workflow)] + # Both jobs run the same steps; only their runner and job env differ. + self.assertEqual(workflow["jobs"][MACOS_JOB]["steps"], workflow["jobs"][MACOS_X86_64_JOB]["steps"]) + backends = mlkem_backend_names() + for job_id, (runner, machine, arch, evidence_word) in MACOS_JOBS.items(): + with self.subTest(job=job_id): + self.assertNotIn(job_id, catalog_names) + self.check_macos_job(workflow, job_id, runner, machine, backends[arch], evidence_word) + + def check_macos_job(self, workflow: dict[str, Any], job_id: str, runner: str, machine: str, backend: str, + evidence_word: str) -> None: + fixture = self.fixture + job = workflow["jobs"][job_id] + self.assertEqual(job["runs-on"], runner) + context = self.resolve(PQ, self.dispatch(PQ, jobs=job_id)) + env = svc.job_env(workflow, job, context) + self.assertEqual(env["DANGER_RUN_CI_ON_HOST"], "1", "no Docker on the macOS host") + env_file = env["FILE_ENV"] + self.assertTrue(os.access(REPO_ROOT / env_file, os.X_OK), env_file) + self.assertEqual(env["MATRIX_NAME"], job["name"]) + self.assertEqual(env["EXPECTED_MACHINE"], machine) + self.assertEqual(env["MLKEM_EXPECTED_BACKEND"], backend) + names = ["CI_OS_NAME", "NO_DEPENDS", "CI_BUILD_TARGET", "CTEST_REGEX", "CTEST_EXPECTED_SUITES", "RUN_FUNCTIONAL_TESTS", + "BITCOIN_CONFIG"] + effective = source_env(env_file, names) + # Losing the native backend must fail the configure step, not fall back quietly. + self.assertIn("-DWITH_MLKEM_NATIVE=ON", effective["BITCOIN_CONFIG"].split()) + self.assertEqual(effective["CI_OS_NAME"], "macos") + self.assertEqual(effective["NO_DEPENDS"], "", "Boost and libevent come from depends, not Homebrew") + self.assertEqual(effective["CI_BUILD_TARGET"], "test_bitcoin") + self.assertEqual(effective["CTEST_EXPECTED_SUITES"].split(), PQ_SUITES) + self.assertEqual(effective["RUN_FUNCTIONAL_TESTS"], "false") + regex = re.compile(effective["CTEST_REGEX"]) + self.assertEqual([name for name in SUITE_INVENTORY if regex.search(name)], sorted(PQ_SUITES)) + github_env = svc.github_env_for(context, job_id, workflow["name"]) + + # The report and native checks read the build from BASE_BUILD_DIR, which + # a step sets from RUNNER_TEMP: the runner context is unavailable in job env. + self.assertNotIn("BASE_BUILD_DIR", job["env"]) + set_dir = svc.find_step(job, "Set build directory") + self.assertLess(svc.step_index(job, "Set build directory"), svc.step_index(job, "CI script")) + with tempfile.TemporaryDirectory(dir=fixture.root) as tmp: + github_env_file = Path(tmp) / "github-env" + github_env_file.touch() + step_env = svc.step_env(workflow, job, set_dir, context) + step_env.update({"RUNNER_TEMP": "/runner/temp", "GITHUB_ENV": str(github_env_file)}) + result = svc.run_step(fixture, set_dir, step_env, fixture.root, github_env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(github_env_file.read_text(encoding="utf8"), "BASE_BUILD_DIR=/runner/temp/build\n") + + # The architecture check refuses any other machine. + step = svc.find_step(job, "Require runner architecture") + self.assertLess(svc.step_index(job, "Require runner architecture"), svc.step_index(job, "CI script")) + with tempfile.TemporaryDirectory(dir=fixture.root) as tmp: + stubs = Path(tmp) + (stubs / "uname").write_text('#!/bin/sh\necho "$STUB_MACHINE"\n', encoding="utf8") + (stubs / "uname").chmod(0o755) + for other in ("arm64", "x86_64"): + with self.subTest(machine=other): + step_env = svc.step_env(workflow, job, step, context) + step_env.update({"PATH": f"{stubs}:{os.environ['PATH']}", "STUB_MACHINE": other}) + result = svc.run_step(fixture, step, step_env, fixture.root, github_env) + self.assertEqual(result.returncode == 0, other == machine, result.stderr) + if other != machine: + self.assertIn(f"{job_id} must run on {machine}", result.stderr) + + # After the suites pass, the native backend must be the one that ran. + step = svc.find_step(job, "Require native ML-KEM") + self.assertLess(svc.step_index(job, "CI script"), svc.step_index(job, "Require native ML-KEM")) + self.assertLess(svc.step_index(job, "Require native ML-KEM"), svc.step_index(job, REPORT_STEP)) + native_line = f"compiled {backend}, active {backend}" + cases = [ + ("native", native_line, 0, True), + ("portable on a CPU without an extension", f"compiled {backend}, active portable/portable", 0, False), + ("portable build", "compiled portable/portable, active portable/portable", 0, False), + ("other native backend", "compiled other/other, active other/other", 0, False), + ("only part of the line", f"compiled {backend}, active {backend}x", 0, False), + ("test failed", native_line, 201, False), + ] + for label, line, exit_code, ok in cases: + with self.subTest(job=job_id, native_check=label), tempfile.TemporaryDirectory(dir=fixture.root) as tmp: + build = Path(tmp) + (build / "bin").mkdir() + stub = build / "bin" / "test_qbit" + stub.write_text("#!/bin/sh\n" + 'printf "%s\\n" "$*" > "$(dirname "$0")/args"\n' + f"echo 'Running 1 test case...'\necho '{line}'\nexit {exit_code}\n", encoding="utf8") + stub.chmod(0o755) + step_env = svc.step_env(workflow, job, step, context) + step_env["BASE_BUILD_DIR"] = str(build) + result = svc.run_step(fixture, step, step_env, fixture.root, github_env) + self.assertEqual(result.returncode == 0, ok, result.completed.stdout + result.stderr) + self.assertEqual((build / "bin" / "args").read_text(encoding="utf8").split(), + ["--run_test=mlkem_tests/backend_selection", "--log_level=message", "--color_output=no"]) + if not ok: + self.assertIn("::error::", result.stderr) + # The line the step looks for is the one backend_selection logs. + self.assertIn('BOOST_TEST_MESSAGE("compiled " << compiled_arith << "/" << compiled_keccak << ", active " << ' + 'active.arith << "/" << active.keccak);', MLKEM_TESTS.read_text(encoding="utf8")) + + # Evidence is reported exactly as for the matrix jobs. + report = svc.find_step(job, REPORT_STEP) + self.assertEqual(report.get("if"), "${{ always() }}") + self.assertEqual(report["run"], svc.find_step(workflow["jobs"][PQ_UNIT_JOB], REPORT_STEP)["run"]) + with tempfile.TemporaryDirectory(dir=fixture.root) as tmp: + build = Path(tmp) + (build / "ctest-evidence").mkdir() + (build / "ctest-evidence" / "summary.md").write_text("| mlkem_tests | passed | 0.5 |\n", encoding="utf8") + step_env = svc.step_env(workflow, job, report, context) + step_env["BASE_BUILD_DIR"] = str(build) + result = svc.run_step(fixture, report, step_env, fixture.root, github_env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"## {job['name']}", result.summary) + self.assertIn(evidence_word, result.summary) + self.assertIn("| mlkem_tests | passed | 0.5 |", result.summary) + + def test_macos_jobs_build_boost_and_libevent_through_depends(self) -> None: + workflow = self.workflows[PQ] + for job_id, (_runner, _machine, arch, _evidence_word) in MACOS_JOBS.items(): + with self.subTest(job=job_id): + self.check_macos_depends(workflow, job_id, f"{arch}-apple-darwin24.6.0") + + def check_macos_depends(self, workflow: dict[str, Any], job_id: str, runner_host: str) -> None: + fixture = self.fixture + job = workflow["jobs"][job_id] + context = self.resolve(PQ, self.dispatch(PQ, jobs=job_id)) + github_env = svc.github_env_for(context, job_id, workflow["name"]) + env_file = svc.job_env(workflow, job, context)["FILE_ENV"] + dep_opts = source_env(env_file, ["DEP_OPTS"])["DEP_OPTS"] + + # For the runner's host, depends builds Boost and libevent and nothing else. + printed = depends_print(runner_host, dep_opts, "packages", "native_packages") + self.assertEqual(printed["packages"].split(), ["boost", "libevent"]) + self.assertEqual(printed["native_packages"].split(), []) + + order = [DEPENDS_INSTALL_STEP, DEPENDS_IDENTIFY_STEP, DEPENDS_RESTORE_STEP, "CI script", DEPENDS_SAVE_STEP, + DEPENDS_REQUIRE_STEP, "Require native ML-KEM", REPORT_STEP] + self.assertEqual([svc.step_index(job, name) for name in order], sorted(svc.step_index(job, name) for name in order)) + + # Homebrew provides GNU Make 4 as the make on PATH, but neither library. + step = svc.find_step(job, DEPENDS_INSTALL_STEP) + with tempfile.TemporaryDirectory(dir=fixture.root) as tmp: + stubs = Path(tmp) + (stubs / "brew").write_text('#!/bin/sh\nif [ "$1" = --prefix ]; then echo "/stub/opt/$2"; ' + 'else echo "$*" >> "$STUB_LOG"; fi\n', encoding="utf8") + (stubs / "brew").chmod(0o755) + step_env = svc.step_env(workflow, job, step, context) + step_env.update({"PATH": f"{stubs}:{os.environ['PATH']}", "STUB_LOG": str(stubs / "log"), + "GITHUB_PATH": str(stubs / "github-path")}) + result = svc.run_step(fixture, step, step_env, fixture.root, github_env) + self.assertEqual(result.returncode, 0, result.stderr) + installed = (stubs / "log").read_text(encoding="utf8").split() + self.assertEqual(installed[:2], ["install", "--quiet"]) + self.assertIn("make", installed) + self.assertNotIn("boost", installed) + self.assertNotIn("libevent", installed) + self.assertEqual((stubs / "github-path").read_text(encoding="utf8"), "/stub/opt/make/libexec/gnubin\n") + + # HOST and the cache key's id are what depends reports for this build. + step = svc.find_step(job, DEPENDS_IDENTIFY_STEP) + host = subprocess.run([str(REPO_ROOT / "depends" / "config.guess")], text=True, capture_output=True, + check=True).stdout.strip() + expected_id = depends_print(host, dep_opts, "final_build_id")["final_build_id"] + self.assertRegex(expected_id, r"^[0-9a-f]+$") + with tempfile.TemporaryDirectory(dir=fixture.root) as tmp: + # A working make that reports macOS's own GNU Make 3.81. + stubs = Path(tmp) / "stubs" + stubs.mkdir() + (stubs / "make").write_text("#!/bin/sh\n" + "if [ \"$1\" = --version ]; then echo 'GNU Make 3.81'; exit 0; fi\n" + f"exec {shutil.which('make')} \"$@\"\n", encoding="utf8") + (stubs / "make").chmod(0o755) + for label, path, ok in (("GNU Make 4", os.environ["PATH"], True), ("GNU Make 3.81", f"{stubs}:{os.environ['PATH']}", False)): + with self.subTest(job=job_id, make=label): + github_env_file = Path(tmp) / f"github-env-{ok}" + github_env_file.touch() + step_env = svc.step_env(workflow, job, step, context) + step_env.update({"PATH": path, "BASE_ROOT_DIR": str(REPO_ROOT), "GITHUB_ENV": str(github_env_file)}) + result = svc.run_step(fixture, step, step_env, REPO_ROOT, github_env) + self.assertEqual(result.returncode == 0, ok, result.completed.stdout + result.stderr) + if ok: + self.assertEqual(github_env_file.read_text(encoding="utf8"), f"HOST={host}\nDEPENDS_BUILD_ID={expected_id}\n") + else: + self.assertIn("::error::depends needs GNU Make 4 or later", result.stderr) + self.assertEqual(github_env_file.read_text(encoding="utf8"), "") + + # The cache holds depends/built under that key, and is saved under the + # same key once the CI script wrote the toolchain file. + restore = svc.find_step(job, DEPENDS_RESTORE_STEP) + save = svc.find_step(job, DEPENDS_SAVE_STEP) + self.assertEqual(restore["uses"].split("@"), ["actions/cache/restore", save["uses"].split("@")[1]]) + self.assertEqual(save["uses"].split("@")[0], "actions/cache/save") + self.assertEqual(restore["with"]["path"], "depends/built") + self.assertEqual(save["with"]["path"], restore["with"]["path"]) + self.assertEqual(save["with"]["key"], "${{ steps." + restore["id"] + ".outputs.cache-primary-key }}") + step_context = dict(context, env={"HOST": runner_host, "DEPENDS_BUILD_ID": "0123456789a"}) + self.assertEqual(svc.render(restore["with"]["key"], step_context), f"depends-{runner_host}-0123456789a") + toolchain = f"depends/{runner_host}/toolchain.cmake" + save_cases = [ + ("built on a miss", "", [toolchain], False, True), + ("restore-key match", "false", [toolchain], False, True), + ("exact hit", "true", [toolchain], False, False), + ("depends did not finish", "", [], False, False), + ("cancelled", "", [toolchain], True, False), + ] + for label, cache_hit, files, cancelled, saves in save_cases: + with self.subTest(job=job_id, save=label): + functions = {"cancelled": lambda cancelled=cancelled: cancelled, + "hashfiles": lambda pattern, files=files: "f00d" if pattern in files else ""} + save_context = dict(step_context, steps={restore["id"]: {"outputs": {"cache-hit": cache_hit}}}) + with mock.patch.dict(svc._FUNCTIONS, functions): + self.assertIs(svc._truthy(svc.render(save["if"], save_context)), saves) + + # Afterwards the build must have used that depends tree: its toolchain, + # libevent, and Boost at the version boost.mk pins. + step = svc.find_step(job, DEPENDS_REQUIRE_STEP) + pinned = re.search(r"^\$\(package\)_version *= *(\S+)$", BOOST_RECIPE.read_text(encoding="utf8"), re.M) + assert pinned is not None, BOOST_RECIPE + boost_version = pinned.group(1) + build_id = "0123456789a" + build_cases: list[tuple[str, dict[str, str | None], str, str | None]] = [ + ("depends", {}, build_id, None), + ("depends, other entry type", {"CMAKE_TOOLCHAIN_FILE:FILEPATH": None, + "CMAKE_TOOLCHAIN_FILE:UNINITIALIZED": "{prefix}/toolchain.cmake"}, build_id, None), + ("stale cache key", {}, "fedcba98765", "depends did not build 0123456789a"), + ("no depends", {"CMAKE_TOOLCHAIN_FILE:FILEPATH": None, + "Boost_DIR:PATH": "/opt/homebrew/Cellar/boost/1.92.0/lib/cmake/Boost-1.92.0"}, + build_id, "CMAKE_TOOLCHAIN_FILE unset"), + ("Homebrew Boost", {"Boost_DIR:PATH": "/opt/homebrew/Cellar/boost/1.92.0/lib/cmake/Boost-1.92.0"}, + build_id, "Boost_DIR must be {prefix}/lib/cmake/Boost-" + boost_version), + ("other depends Boost", {"Boost_DIR:PATH": "{prefix}/lib/cmake/Boost-1.0.0"}, build_id, "Boost-1.0.0"), + ("Homebrew libevent", {"Libevent_DIR:PATH": "/opt/homebrew/lib/cmake/libevent"}, build_id, "Libevent_DIR must be"), + ] + for label, changes, stamp_id, error in build_cases: + with self.subTest(job=job_id, depends_build=label), tempfile.TemporaryDirectory(dir=fixture.root) as tmp: + root = Path(tmp) / "workspace" + prefix = root / "depends" / runner_host + prefix.mkdir(parents=True) + (prefix / f".stamp_{stamp_id}").touch() + (root / "depends" / "packages").mkdir() + shutil.copy(BOOST_RECIPE, root / "depends" / "packages") + entries: dict[str, str | None] = { + "CMAKE_TOOLCHAIN_FILE:FILEPATH": "{prefix}/toolchain.cmake", + "Boost_DIR:PATH": "{prefix}/lib/cmake/Boost-" + boost_version, + "Libevent_DIR:PATH": "{prefix}/lib/cmake/libevent", + } + entries.update(changes) + build = Path(tmp) / "build" + build.mkdir() + (build / "CMakeCache.txt").write_text( + "".join(f"{key}={value.format(prefix=prefix)}\n" for key, value in entries.items() if value is not None), + encoding="utf8") + step_env = svc.step_env(workflow, job, step, context) + step_env.update({"BASE_ROOT_DIR": str(root), "BASE_BUILD_DIR": str(build), "HOST": runner_host, + "DEPENDS_BUILD_ID": build_id}) + result = svc.run_step(fixture, step, step_env, fixture.root, github_env) + self.assertEqual(result.returncode == 0, error is None, result.completed.stdout + result.stderr) + if error is None: + self.assertIn(f"Built with Boost {boost_version} and libevent from {prefix}", result.completed.stdout) + else: + self.assertIn("::error::", result.stderr) + self.assertIn(error.format(prefix=prefix), result.stderr) + + def test_functional_job_runs_the_whole_suite_with_hybrid_transport(self) -> None: + workflow = self.workflows[PQ] + job = workflow["jobs"][PQ_FUNCTIONAL_JOB] + self.assertNotIn(PQ_FUNCTIONAL_JOB, [entry["job"] for entry in catalog(workflow)]) + self.assertEqual(job["runs-on"], "ubuntu-24.04") + context = self.resolve(PQ, self.dispatch(PQ, jobs=PQ_FUNCTIONAL_JOB)) + env = svc.job_env(workflow, job, context) + self.assertEqual(env["MATRIX_NAME"], job["name"]) + self.assertEqual(env["CI_IMAGE_REGISTRY_PREFIX"], "docker.io/library") + self.assertEqual(env["CI_ENFORCE_INTERNAL_REGISTRY"], "0") + self.assertEqual(env["CI_PROFILE"], "public") + env_file = env["FILE_ENV"] + self.assertTrue(os.access(REPO_ROOT / env_file, os.X_OK), env_file) + self.assertLess(svc.step_index(job, svc.VERIFY_STEP), svc.step_index(job, "CI script")) + self.assertEqual(svc.find_step(job, "CI script")["run"], "./ci/test_run_all.sh") + + names = ["RUN_UNIT_TESTS", "RUN_FUNCTIONAL_TESTS", "TEST_RUNNER_EXTRA", "CI_BUILD_TARGET", "NO_DEPENDS", "BITCOIN_CONFIG", + "CI_IMAGE_NAME_TAG"] + effective = source_env(env_file, names, {key: env[key] for key in ("CI_IMAGE_REGISTRY_PREFIX", "CI_ENFORCE_INTERNAL_REGISTRY")}) + # The whole suite (no test selection), with hybrid transport between all nodes. + self.assertEqual(effective["TEST_RUNNER_EXTRA"].split(), ["--v2pqtransport"]) + self.assertEqual((effective["RUN_FUNCTIONAL_TESTS"], effective["RUN_UNIT_TESTS"]), ("true", "false")) + self.assertEqual(effective["CI_BUILD_TARGET"], "", "every binary the suite runs is built") + self.assertIn("-DWITH_ZMQ=ON", effective["BITCOIN_CONFIG"].split()) + self.assertEqual(effective["CI_IMAGE_NAME_TAG"], "docker.io/library/ubuntu:24.04") + + # The i686 job of ci.yml keeps covering plain v2: --v2transport without --v2pqtransport, for which + # the test framework passes -v2pqtransport=0 explicitly (test_run_modes_set_hybrid_transport_explicitly). + extra = source_env(I686_ENV, ["TEST_RUNNER_EXTRA"])["TEST_RUNNER_EXTRA"].split() + self.assertIn("--v2transport", extra) + self.assertNotIn("--v2pqtransport", extra) + + def test_run_modes_set_hybrid_transport_explicitly(self) -> None: + """Each run mode of the functional framework keeps its transport whatever the node's -v2pqtransport + default, which #222 turns on and its release-cut decision may turn off again: --v2pqtransport + (pq-functional) passes -v2pqtransport=1 and plain --v2transport (the i686 job) -v2pqtransport=0. + The default mode runs v1, and a test that starts a node with v2 gets the node's default.""" + cases = [ + # (run-mode flags, v2pqtransport_from_run_mode, the test's node arguments), expected arguments + (([], True, []), ["-v2transport=0"]), + (([], True, ["-v2transport=1"]), ["-v2transport=0", "-v2transport=1"]), + ((["--v2transport"], True, []), ["-v2transport=1", "-v2pqtransport=0"]), + ((["--v2pqtransport"], True, []), ["-v2transport=1", "-v2pqtransport=1"]), + # The test's own arguments come later and win. + ((["--v2pqtransport"], True, ["-v2pqtransport=0"]), ["-v2transport=1", "-v2pqtransport=1", "-v2pqtransport=0"]), + # A node the test starts without v2 gets no -v2pqtransport, which would warn. + ((["--v2pqtransport"], True, ["-v2transport=0"]), ["-v2transport=1", "-v2transport=0"]), + ((["--v2pqtransport", "--v1transport"], True, []), ["-v2transport=0"]), + # Tests of the option itself opt out of the run mode's setting. + ((["--v2pqtransport"], False, []), ["-v2transport=1"]), + ((["--v2transport"], False, []), ["-v2transport=1"]), + ] + completed = subprocess.run([sys.executable, "-I", "-c", RUN_MODE_PROBE, str(REPO_ROOT / "test" / "functional"), + json.dumps([case for case, _ in cases])], + cwd=REPO_ROOT, text=True, capture_output=True, check=False) + self.assertEqual(completed.returncode, 0, completed.stderr) + for (case, expected), actual in zip(cases, json.loads(completed.stdout), strict=True): + with self.subTest(case=case): + self.assertEqual(actual, expected) + + def test_contexts_are_available_where_used(self) -> None: + """Mirror actionlint's context-availability rule for the places these workflows use expressions. + + GitHub refuses to load a workflow that reads, say, runner in a job's env, + while this file's expression evaluator would render it as empty. + """ + expression = re.compile(r"\$\{\{(.*?)\}\}", re.S) + root = re.compile(r"(? None: + block = shell_block(TEST_SCRIPT.read_text(encoding="utf8"), "CI_BUILD_TARGET=${CI_BUILD_TARGET:-all}", ")") + for target, expected in (("test_bitcoin", ["test_bitcoin"]), ("", ["all", "install"])): + with self.subTest(CI_BUILD_TARGET=target): + run = run_test_script_block(block, {"CI_BUILD_TARGET": target, "STUB_FAIL_FIRST": "1"}) + self.assertNotEqual(run.returncode, 0, "the verbose retry still fails the build") + builds = [call for call in run.calls if call[:2] == ["cmake", "--build"]] + self.assertEqual(len(builds), 2, run.completed.stderr) + for call in builds: + targets = call[call.index("--target") + 1:] + self.assertEqual([t for t in targets if t != "--verbose"], expected) + self.assertIn("--verbose", builds[1]) + + def test_ctest_selection_reaches_listing_run_and_evidence(self) -> None: + block = shell_block(TEST_SCRIPT.read_text(encoding="utf8"), 'if [ "$RUN_UNIT_TESTS" = "true" ]; then', "fi") + regex = "^(mlkem_tests|bip324_tests|net_tests)$" + expected = " ".join(AARCH64_SUITES) + + run = run_test_script_block(block, {"CTEST_REGEX": regex, "CTEST_EXPECTED_SUITES": expected}) + self.assertEqual(run.returncode, 0, run.completed.stderr) + ctest_calls = [call[1:] for call in run.calls if call[0] == "ctest"] + self.assertEqual(len(ctest_calls), 3) + listing, human, ran = ctest_calls + self.assertIn("--show-only=json-v1", listing) + self.assertIn("--show-only", human) + for call in ctest_calls: + self.assertEqual(call[call.index("-R") + 1], regex) + self.assertNotIn("-I", call) + self.assertIn("--no-tests=error", ran) + self.assertIn("--output-junit", ran) + summary = run.files["summary.md"] + for suite in AARCH64_SUITES: + self.assertIn(f"| {suite} | passed |", summary) + + failures = { + "skipped suite": ({"CTEST_REGEX": regex, "CTEST_EXPECTED_SUITES": expected, + "STUB_OUTCOMES": json.dumps({"bip324_tests": "notrun"})}, "bip324_tests: skipped"), + "regex selects too little": ({"CTEST_REGEX": "^(mlkem_tests|net_tests)$", "CTEST_EXPECTED_SUITES": expected}, + "did not select expected suite(s): bip324_tests"), + "regex selects too much": ({"CTEST_REGEX": "^mlkem_tests$|^bip324_tests|^net_tests$", "CTEST_EXPECTED_SUITES": expected}, + "not expected: bip324_tests_extra"), + "suite missing from the build": ({"CTEST_REGEX": "^(mlkem_tests|bip324_tests|net_tests|pq_records_tests)$", + "CTEST_EXPECTED_SUITES": expected + " pq_records_tests"}, "pq_records_tests"), + "range and regex together": ({"CTEST_REGEX": regex, "CTEST_INCLUDE_RANGE": "1,2"}, "set only one"), + "nothing selected": ({"CTEST_REGEX": "^nothing$"}, "No tests were found"), + } + for label, (env, message) in failures.items(): + with self.subTest(label): + run = run_test_script_block(block, env) + self.assertNotEqual(run.returncode, 0) + self.assertIn(message, run.completed.stderr + run.completed.stdout) + run = run_test_script_block(block, {"CTEST_REGEX": regex, "CTEST_INCLUDE_RANGE": "1,2"}) + self.assertEqual(run.calls, [], "conflicting selections are rejected before ctest runs") + + # A job without expected suites, such as a range shard, keeps its old behavior. + run = run_test_script_block(block, {"CTEST_INCLUDE_RANGE": "1,3", "STUB_OUTCOMES": json.dumps({"addrman_tests": "notrun"})}) + self.assertEqual(run.returncode, 0, run.completed.stderr) + ran = [call[1:] for call in run.calls if call[0] == "ctest"][-1] + self.assertEqual(ran[ran.index("-I") + 1], "1,3") + self.assertNotIn("summary.md", run.files) + + def test_report_step_publishes_source_and_suites(self) -> None: + fixture = self.fixture + workflow = self.workflows[PQ] + context = self.resolve(PQ, self.dispatch(PQ, jobs=AARCH64_JOB)) + entry = catalog(workflow)[0] + job_context = dict(context, matrix=entry) + step = svc.find_step(workflow["jobs"][PQ_UNIT_JOB], REPORT_STEP) + self.assertEqual(step.get("if"), "${{ always() }}") + # The step reads the file 03_test_script.sh writes. + self.assertIn('CTEST_EVIDENCE_DIR="${BASE_BUILD_DIR}/ctest-evidence"', TEST_SCRIPT.read_text(encoding="utf8")) + self.assertIn('--summary "${CTEST_EVIDENCE_DIR}/summary.md"', TEST_SCRIPT.read_text(encoding="utf8")) + with tempfile.TemporaryDirectory(dir=fixture.root) as tmp: + build = Path(tmp) + env = svc.step_env(workflow, workflow["jobs"][PQ_UNIT_JOB], step, job_context) + env["BASE_BUILD_DIR"] = str(build) + github_env = svc.github_env_for(job_context, PQ_UNIT_JOB, workflow["name"]) + missing = svc.run_step(fixture, step, env, fixture.root, github_env) + self.assertNotEqual(missing.returncode, 0) + self.assertIn("No unit-test evidence was produced.", missing.summary) + + (build / "ctest-evidence").mkdir() + (build / "ctest-evidence" / "summary.md").write_text("| bip324_tests | passed | 0.5 |\n", encoding="utf8") + report = svc.run_step(fixture, step, env, fixture.root, github_env) + self.assertEqual(report.returncode, 0, report.stderr) + self.assertIn(f"- source: {fixture.manual_sha} (refs/heads/{fixture.manual_branch})", report.summary) + self.assertIn("mlkem_tests, bip324_tests and net_tests on AArch64", report.summary) + self.assertIn("| bip324_tests | passed | 0.5 |", report.summary) + + def test_required_gate_runs_the_contracts(self) -> None: + gate = svc.load_workflow(GATE_WORKFLOW) + commands = [step.get("run", "") for step in gate["jobs"]["classify-changes"]["steps"]] + for contract in ("ci/checks/test_ci_pq_contract.py", "ci/checks/test_scheduled_validation_contract.py"): + self.assertTrue(any(contract in command for command in commands), contract) + + +class CtestEvidenceTest(unittest.TestCase): + """ctest_evidence.py against JUnit in the format ctest --output-junit writes.""" + + @staticmethod + def junit(*cases: tuple[str, str, str]) -> str: + body = "".join(f'{inner}' + for name, status, inner in cases) + return f'\n{body}\n' + + def test_outcomes(self) -> None: + outcomes = ctest_evidence.junit_outcomes(self.junit( + ("passed_suite", "run", ""), + ("skipped_suite", "notrun", ''), + ("failed_suite", "fail", ''), + ("disabled_suite", "disabled", "Disabled"), + ("odd_suite", "weird", ""), + )) + self.assertEqual({name: outcome for name, (outcome, _) in outcomes.items()}, { + "passed_suite": "passed", + "skipped_suite": "skipped", + "failed_suite": "failed", + "disabled_suite": "disabled", + "odd_suite": "unknown status 'weird'", + }) + twice = ctest_evidence.junit_outcomes(self.junit(("a", "run", ""), ("a", "run", ""))) + self.assertEqual(twice["a"][0], "reported more than once") + + def test_check(self) -> None: + passed = {"a": ("passed", "1"), "b": ("passed", "2")} + self.assertEqual(ctest_evidence.check(["a", "b"], ["a", "b"], passed), []) + self.assertEqual(ctest_evidence.check(["a", "b"], ["a"], {"a": ("passed", "1")}), + ["ctest did not select expected suite(s): b", "b: missing from the JUnit results"]) + self.assertEqual(ctest_evidence.check(["a"], ["a", "c"], passed), ["ctest selected suite(s) that are not expected: c"]) + self.assertEqual(ctest_evidence.check(["a"], ["a"], {"a": ("skipped", "0")}), ["a: skipped"]) + with self.assertRaises(ValueError): + ctest_evidence.parse_expected("") + with self.assertRaises(ValueError): + ctest_evidence.parse_expected("a b a") + + def test_main_reports_unreadable_inputs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + listing = root / "selected.json" + junit = root / "junit.xml" + summary = root / "summary.md" + listing.write_text(json.dumps({"tests": [{"name": "a"}]}), encoding="utf8") + junit.write_text(" int: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + return ctest_evidence.main(args) + + self.assertEqual(main(), 1) + self.assertIn("unreadable JUnit results", summary.read_text(encoding="utf8")) + junit.write_text(self.junit(("a", "run", "")), encoding="utf8") + self.assertEqual(main(), 0) + self.assertIn("| a | passed | 0.1 |", summary.read_text(encoding="utf8")) + listing.unlink() + self.assertEqual(main(), 1) + self.assertIn("unreadable ctest test listing", summary.read_text(encoding="utf8")) + + + +class FuzzReplayContractTest(unittest.TestCase): + """The fuzz phase of 03_test_script.sh replays the ML-KEM targets on portable C too (#190 D5).""" + + STUB_RUNNER = r'''#!/usr/bin/env python3 +import json, os, sys +with open(os.environ["STUB_LOG"], "a", encoding="utf8") as log: + log.write(json.dumps({"args": sys.argv[1:], "MLKEM_FORCE_PORTABLE": os.environ.get("MLKEM_FORCE_PORTABLE")}) + "\n") +''' + + @staticmethod + def fuzz_block() -> str: + """The last RUN_FUZZ_TESTS block of 03_test_script.sh: the replay and mutation phase.""" + lines = TEST_SCRIPT.read_text(encoding="utf8").splitlines() + first = max(index for index, line in enumerate(lines) if line == 'if [ "$RUN_FUZZ_TESTS" = "true" ]; then') + last = next(index for index in range(first + 1, len(lines)) if lines[index] == "fi") + return "\n".join(lines[first:last + 1]) + "\n" + + @staticmethod + def required_targets() -> list[str]: + """QBIT_REQUIRED_CORPUS_TARGETS from test/fuzz/test_runner.py, read without importing it.""" + import ast + tree = ast.parse((REPO_ROOT / "test" / "fuzz" / "test_runner.py").read_text(encoding="utf8")) + for node in tree.body: + if isinstance(node, ast.Assign) and any(getattr(t, "id", None) == "QBIT_REQUIRED_CORPUS_TARGETS" for t in node.targets): + return list(ast.literal_eval(node.value)) + raise AssertionError("QBIT_REQUIRED_CORPUS_TARGETS not found") + + def run_block(self, mutate_min_time: str) -> list[dict[str, Any]]: + with tempfile.TemporaryDirectory(prefix="ci-fuzz-") as tmp: + root = Path(tmp) + runner = root / "build" / "test" / "fuzz" / "test_runner.py" + runner.parent.mkdir(parents=True) + runner.write_text(self.STUB_RUNNER, encoding="utf8") + runner.chmod(0o755) + log = root / "calls.jsonl" + log.touch() + env = {"PATH": os.environ["PATH"], "HOME": tmp, "LC_ALL": "C", "RUN_FUZZ_TESTS": "true", + "BASE_BUILD_DIR": str(root / "build"), "DEPENDS_DIR": str(root / "depends"), "HOST": "x86_64-pc-linux-gnu", + "MAKEJOBS": "-j2", "DIR_FUZZ_IN": str(root / "corpora"), "FUZZ_TESTS_CONFIG": "", + "QBIT_FUZZ_MUTATE_MIN_TIME": mutate_min_time, "STUB_LOG": str(log)} + script = root / "block.sh" + script.write_text("set -ex\n" + self.fuzz_block(), encoding="utf8") + completed = subprocess.run(["bash", str(script)], env=env, text=True, capture_output=True) + self.assertEqual(completed.returncode, 0, completed.stderr) + return [json.loads(line) for line in log.read_text(encoding="utf8").splitlines()] + + def test_mlkem_targets_replay_on_portable_c(self) -> None: + replay, portable = self.run_block(mutate_min_time="") + self.assertIn("--require_qbit_corpus", replay["args"]) + self.assertIsNone(replay["MLKEM_FORCE_PORTABLE"]) + self.assertEqual(portable["MLKEM_FORCE_PORTABLE"], "1") + self.assertEqual(portable["args"][-2:], ["mlkem", "mlkem_backend_diff"]) + corpus = [arg for arg in replay["args"] if arg.endswith("/corpora")] + self.assertEqual(len(corpus), 1, replay["args"]) + self.assertIn(corpus[0], portable["args"], "the portable replay uses the same corpus") + + def test_mutation_phase_covers_every_required_target(self) -> None: + calls = self.run_block(mutate_min_time="60") + self.assertEqual(len(calls), 3) + mutate = calls[2] + self.assertIn("--mutate_min_time=60", mutate["args"]) + self.assertIsNone(mutate["MLKEM_FORCE_PORTABLE"]) + targets = self.required_targets() + self.assertIn("mlkem", targets) + self.assertEqual(mutate["args"][-len(targets):], targets) + + +if __name__ == "__main__": + unittest.main() diff --git a/ci/checks/test_classify_merge_profile.py b/ci/checks/test_classify_merge_profile.py index b2e3824651..7bab9a066f 100755 --- a/ci/checks/test_classify_merge_profile.py +++ b/ci/checks/test_classify_merge_profile.py @@ -129,6 +129,30 @@ def test_public_docs_paths_are_public_docs_only(self) -> None: self.assertTrue(classification.public_docs_only) self.assertEqual(classification.outside_paths, ()) + def test_docs_that_source_checks_read_require_source_validation(self) -> None: + """The specification's quoted vectors (generate-pq-transport-vectors.py --check, in the lint job) and the + operator guide's quick start (feature_pq_quickstart.py) fail only in source validation.""" + for path in ("doc/design/pq-transport.md", "doc/user/network/pq-transport.md"): + with self.subTest(path=path): + classification = self.classify([path]) + outputs = classify_merge_profile.github_outputs(classification) + + self.assertEqual(classification.profile, classify_merge_profile.SOURCE_PROFILE) + self.assertEqual(classification.outside_paths, (path,)) + self.assertEqual(classification.public_docs_paths, ()) + self.assertEqual(outputs["source_validation_required"], "true") + self.assertEqual(outputs["public_docs_only"], "false") + # The lint job and the CI matrix run those checks, not the PQ jobs. + self.assertEqual(outputs["pq_unit_required"], "false") + self.assertEqual(outputs["pq_functional_required"], "false") + # Also among other public docs. + self.assertEqual(self.classify(PUBLIC_DOCS_PATHS + [path]).profile, classify_merge_profile.SOURCE_PROFILE) + + # Other docs beside them stay public docs. + for path in ("doc/design/p2mr-datapqchash.md", "doc/user/network/README.md", "doc/user/pq-transport.md"): + with self.subTest(path=path): + self.assertEqual(self.classify([path]).profile, classify_merge_profile.PUBLIC_DOCS_PROFILE) + def test_github_metadata_paths_are_github_metadata_only(self) -> None: classification = self.classify(GITHUB_METADATA_PATHS) @@ -242,6 +266,146 @@ def test_github_outputs_report_github_metadata_profile(self) -> None: self.assertEqual(outputs["source_validation_required"], "false") self.assertEqual(outputs["touched_github_metadata"], "true") + def test_pq_unit_jobs_required_for_mlkem_and_transport_paths(self) -> None: + required = [ + "src/mlkem-native/mlkem/src/kem.c", + "src/crypto/mlkem.cpp", + "src/crypto/mlkem_config.h", + "cmake/mlkem-native.cmake", + "src/bip324.cpp", + "src/bip324.h", + "src/bip324_pq.cpp", + "src/bip324_pq.h", + "src/net.h", + "src/net.cpp", + "src/compat/cpuid.h", + "src/compat/cpu_features.h", + "src/compat/cpu_features.cpp", + ".github/workflows/ci-pq.yml", + "ci/test/00_setup_env_native_aarch64_pq.sh", + "ci/test/00_setup_env_s390x_unit.sh", + "ci/test/00_setup_env_mac_native_pq.sh", + "src/test/mlkem_tests.cpp", + "src/test/bip324_tests.cpp", + "src/test/net_tests.cpp", + "src/test/data/pq_transport_vectors.json", + "src/test/data/mlkem1024_vectors.json", + # Build wiring. + "CMakeLists.txt", + "src/CMakeLists.txt", + "src/util/CMakeLists.txt", + "src/test/CMakeLists.txt", + "src/test/fuzz/CMakeLists.txt", + "src/bench/CMakeLists.txt", + "test/CMakeLists.txt", + "cmake/bitcoin-build-config.h.in", + # Gate and CI wiring, and its tests. + ".github/workflows/ci.yml", + ".github/workflows/core-checks.yml", + ".github/workflows/required-merge-gate.yml", + "ci/checks/classify_merge_profile.py", + "ci/checks/ctest_evidence.py", + "ci/checks/test_ci_pq_contract.py", + "ci/checks/test_classify_merge_profile.py", + "ci/checks/test_mlkem_build_policy.py", + "ci/checks/test_pq_merge_gate_contract.py", + "ci/test/03_test_script.sh", + ] + for path in required: + with self.subTest(path=path): + classification = self.classify([path]) + self.assertTrue(classification.pq_unit_required) + self.assertEqual(classify_merge_profile.github_outputs(classification)["pq_unit_required"], "true") + self.assertEqual(classification.profile, classify_merge_profile.SOURCE_PROFILE) + # One matching path among others is enough. + self.assertTrue(self.classify(["doc/user/README.md", "src/bip324.cpp"]).pq_unit_required) + + def test_pq_unit_jobs_not_required_elsewhere(self) -> None: + for paths in ( + ["src/wallet/wallet.cpp"], + ["src/netbase.cpp", "src/net_processing.cpp", "src/compat/compat.h"], + ["src/test/net_peer_eviction_tests.cpp"], + ["ci/test/00_setup_env_native_asan.sh", ".github/workflows/rpc-perf-manual.yml"], + # Wiring shared by every CI job, and ML-KEM tooling the PQ unit jobs never run. + ["ci/test/01_base_install.sh", "ci/test/02_run_container.py", ".github/actions/configure-docker/action.yml"], + ["src/crypto/CMakeLists.txt", "src/test/cpu_features_tests.cpp"], + ["test/lint/mlkem-native-check.sh", "contrib/devtools/mlkem-native.pin", "contrib/devtools/update-mlkem-native.sh"], + ["doc/user/README.md"], + RPC_DOCS_PATHS, + PUBLIC_DOCS_PATHS, + ): + with self.subTest(paths=paths): + classification = self.classify(paths) + self.assertFalse(classification.pq_unit_required) + self.assertEqual(classify_merge_profile.github_outputs(classification)["pq_unit_required"], "false") + + def test_pq_unit_jobs_required_for_unreadable_paths(self) -> None: + self.assertTrue(self.classify(["../src/bip324.cpp"]).pq_unit_required) + self.assertTrue(self.classify(["/abs/path"]).pq_unit_required) + + def test_pq_functional_job_required_for_hybrid_transport_paths(self) -> None: + """Paths that can break the hybrid functional suite without touching what the PQ unit jobs build.""" + required = [ + "src/init.cpp", + "src/rpc/net.cpp", + "test/functional/rpc_net.py", + "test/functional/p2p_v2_encrypted.py", + "test/functional/p2p_v2_misbehaving.py", + "test/functional/p2p_v2_pq_fallback.py", + "test/functional/p2p_v2_transport.py", + "test/functional/test_framework/test_framework.py", + "test/functional/test_framework/test_node.py", + "test/functional/test_framework/v2_p2p.py", + "test/functional/test_framework/crypto/mlkem.py", + "ci/test/00_setup_env_native_pq_functional.sh", + ] + for path in required: + with self.subTest(path=path): + classification = self.classify([path]) + outputs = classify_merge_profile.github_outputs(classification) + self.assertTrue(classification.pq_functional_required) + self.assertEqual(outputs["pq_functional_required"], "true") + # The macOS and AArch64 unit jobs don't start for them. + self.assertFalse(classification.pq_unit_required) + self.assertEqual(outputs["pq_unit_required"], "false") + self.assertEqual(classification.profile, classify_merge_profile.SOURCE_PROFILE) + self.assertTrue(self.classify(["doc/user/README.md", "src/init.cpp"]).pq_functional_required) + + def test_pq_functional_job_required_whenever_pq_unit_jobs_are(self) -> None: + paths = sorted(classify_merge_profile.PQ_UNIT_FILES) + [ + "src/mlkem-native/mlkem/src/kem.c", + "src/bip324_pq.cpp", + "src/crypto/mlkem.cpp", + "src/compat/cpu_features.cpp", + "../src/bip324.cpp", + ] + for path in paths: + with self.subTest(path=path): + classification = self.classify([path]) + self.assertTrue(classification.pq_unit_required) + self.assertTrue(classification.pq_functional_required) + self.assertEqual(classify_merge_profile.github_outputs(classification)["pq_functional_required"], "true") + + def test_pq_functional_job_not_required_elsewhere(self) -> None: + for paths in ( + ["src/wallet/wallet.cpp"], + ["src/net_processing.cpp", "src/netbase.cpp", "src/protocol.cpp", "src/bitcoin-cli.cpp", "src/qt/rpcconsole.cpp"], + ["src/rpc/blockchain.cpp", "src/test/net_pq_tests.cpp"], + # Tests that set every transport option themselves, or that run alike over plain and hybrid v2. + ["test/functional/rpc_pqtransport.py", "test/functional/feature_pq_quickstart.py"], + ["test/functional/tool_pq_canary.py", "contrib/pq-canary/pq-canary-eval.py"], + ["test/functional/p2p_handshake.py", "test/functional/interface_bitcoin_cli.py", "test/functional/test_runner.py"], + # The other CI jobs, and the wiring every CI job shares. + ["ci/test/00_setup_env_i686_no_ipc.sh", "ci/test/00_setup_env_base_image.sh", "ci/test_run_all.sh"], + ["doc/user/network/pq-transport.md"], + RPC_DOCS_PATHS, + PUBLIC_DOCS_PATHS, + ): + with self.subTest(paths=paths): + classification = self.classify(paths) + self.assertFalse(classification.pq_functional_required) + self.assertEqual(classify_merge_profile.github_outputs(classification)["pq_functional_required"], "false") + def test_require_release_policy_only_cli_rejects_outside_path(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: changed_files = Path(tmpdir) / "changed-files.txt" diff --git a/ci/checks/test_merge_gate_poller.py b/ci/checks/test_merge_gate_poller.py index 4ea097df16..1d1e6cb2b8 100755 --- a/ci/checks/test_merge_gate_poller.py +++ b/ci/checks/test_merge_gate_poller.py @@ -17,9 +17,17 @@ test, so the production defaults stay the values CI actually uses while the suite still runs in seconds. +The stub also answers the workflow-run lookups the gate makes to rank a +check's runs by the event that produced them and to tell which event produced +a failed one (its own run, and the run that owns each such check suite), so +those are exercised by the same real script. + Not provable here: whether the hosted ``Required Merge Gate`` check is attached to a branch ruleset, and whether the real API agrees with the shapes -modelled below. +modelled below. The workflow-run shapes and their re-run semantics (a re-run +keeps the run's ``check_suite_id`` and ``created_at`` and moves only +``run_attempt`` and ``run_started_at``) were read from GitHub's REST API +description and checked against this repository's PR #229 on 2026-10-06. """ from __future__ import annotations @@ -31,6 +39,7 @@ import tempfile import time import unittest +from datetime import datetime, timedelta, timezone from pathlib import Path CHECKS_DIR = Path(__file__).resolve().parent @@ -85,16 +94,119 @@ ERROR_CHECK_FAILED = "::error title=Required Merge Gate failed::" ERROR_UNREADABLE = "::error title=Required Merge Gate could not read check runs::" +ERROR_UNCORRELATED = "::error title=Required Merge Gate could not correlate check runs::" ERROR_TIMED_OUT = "::error title=Required Merge Gate timed out::" WARNING_RETRYING = "::warning title=Required Merge Gate retrying::" +# The documented allowance between the workflow runs one event creates: 325 +# pull request events on this repository (June to October 2026) spread their +# runs over at most 4 s, and the closest two events on one commit were 28 s +# apart. +EVENT_SKEW = re.compile(r"^\s*EVENT_SKEW_SECONDS=(?P\d+)\s*$", re.MULTILINE) +DOCUMENTED_EVENT_SKEW_SECONDS = 10 + +# -- the events a commit has seen ------------------------------------------- +# +# Every workflow run an event starts is created at that event, and a re-run +# keeps the run's created_at. The gate's own run (RUN_ID) was created by +# THIS_EVENT unless a test says otherwise. Check suites, one per workflow run, +# are numbered by event: 1xx earlier, 2xx this one, 3xx a later one. Each +# workflow's run_number grows by one per run. +GATE_RUN_ID = 12345 +EARLIER_EVENT = "2026-10-05T20:17:51Z" +THIS_EVENT = "2026-10-06T14:12:00Z" +LATER_EVENT = "2026-10-06T14:40:00Z" +CORE_WORKFLOW, FULL_WORKFLOW, RPC_DOCS_WORKFLOW = 7001, 7002, 7003 +CORE_EARLIER, FULL_EARLIER = 101, 102 +CORE_THIS, FULL_THIS, RPC_DOCS_THIS = 201, 202, 203 +CORE_LATER = 301 +# check suite: (workflow id, run_number, created_at) of the run that owns it. +# These are the workflow runs on the commit unless a test says otherwise; the +# later event's run is on it only in the tests that ask for it. +SUITE_RUNS = { + CORE_EARLIER: (CORE_WORKFLOW, 452, EARLIER_EVENT), + FULL_EARLIER: (FULL_WORKFLOW, 445, EARLIER_EVENT), + CORE_THIS: (CORE_WORKFLOW, 457, THIS_EVENT), + FULL_THIS: (FULL_WORKFLOW, 450, THIS_EVENT), + RPC_DOCS_THIS: (RPC_DOCS_WORKFLOW, 30, THIS_EVENT), +} +LATER_RUNS = {CORE_LATER: (CORE_WORKFLOW, 460, LATER_EVENT)} + +SHA = "0" * 40 +GATE_RUN_PATH = re.escape(f"/actions/runs/{GATE_RUN_ID}") + "$" + + +def suite_lookup(suite: int) -> str: + """The route for the lookup of the workflow run that owns ``suite``.""" + return re.escape(f"/actions/runs?check_suite_id={suite}&exclude_pull_requests=true&per_page=100") + "$" + + +def commit_runs_lookup(workflow: int) -> str: + """The route for the listing of ``workflow``'s runs on the commit.""" + return re.escape(f"/actions/workflows/{workflow}/runs?head_sha={SHA}&exclude_pull_requests=true&per_page=100") + "$" -def check_run(name: str, status: str, conclusion: str | None = None, **stamps: str) -> dict: + +def check_run( + name: str, status: str, conclusion: str | None = None, *, suite: int | None = None, **stamps: str +) -> dict: run = {"name": name, "status": status, "conclusion": conclusion} + if suite is not None: + run["check_suite"] = {"id": suite} run.update(stamps) return run +def workflow_run( + run_id: int, suite: int, created_at: str | None, *, workflow: int = CORE_WORKFLOW, number: int | None = 1, **fields +) -> dict: + run = { + "id": run_id, + "workflow_id": workflow, + "run_number": number, + "check_suite_id": suite, + "head_sha": SHA, + "created_at": created_at, + "run_started_at": created_at, + "run_attempt": 1, + } + run.update(fields) + return run + + +def workflow_runs(*runs: dict) -> str: + return json.dumps({"total_count": len(runs), "workflow_runs": list(runs)}) + + +def suite_run(suite: int, owner: tuple[int, int, str]) -> dict: + workflow, number, created_at = owner + return workflow_run(50_000 + suite, suite, created_at, workflow=workflow, number=number) + + +def event_routes( + gate_run: dict | None = None, suites: dict[int, tuple[int, int, str]] | None = None +) -> dict[str, tuple]: + """Answers for the gate's own run, for the run behind each check suite, and + for each workflow's runs on the commit: exactly the runs in ``suites``.""" + routes: dict[str, tuple] = { + GATE_RUN_PATH: ok(json.dumps(gate_run or workflow_run(GATE_RUN_ID, 999, THIS_EVENT))), + } + by_workflow: dict[int, list[dict]] = {} + for suite, owner in (SUITE_RUNS if suites is None else suites).items(): + routes[suite_lookup(suite)] = ok(workflow_runs(suite_run(suite, owner))) + by_workflow.setdefault(owner[0], []).append(suite_run(suite, owner)) + for workflow, runs in by_workflow.items(): + routes[commit_runs_lookup(workflow)] = ok(workflow_runs(*runs)) + return routes + + +def iso(moment: datetime) -> str: + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def parse_iso(stamp: str) -> datetime: + return datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + + def page(*runs: dict) -> dict: return {"total_count": len(runs), "check_runs": list(runs)} @@ -109,65 +221,99 @@ def ok(payload: str) -> tuple[int, str]: class FakeGh: - """A ``gh`` on PATH that scripts one response per call. - - ``responses`` is consumed in order; once exhausted, ``default`` answers - every further call. Each entry is ``(exit_code, stdout)``, optionally with - a third element written to stderr the way ``gh`` reports an HTTP error. - ``delay`` makes every call take that many seconds, so a test can show the - wait deadline counts time spent in the API and not only in ``sleep``. + """A ``gh`` on PATH that scripts its answers. + + Check-runs queries are answered in order from ``responses``; once those are + exhausted, ``default`` answers every further one. ``routes`` answers any + other call whose arguments match its regular expression, with one reply or + a list of replies whose last one repeats. A call nothing answers fails as + an unscripted call, which ``run_gate`` refuses. Each reply is + ``(exit_code, stdout)``, optionally with a third element written to stderr + the way ``gh`` reports an HTTP error. ``delay`` makes every call take that + many seconds, so a test can show the wait deadline counts time spent in the + API and not only in ``sleep``. """ + STUB = """#!/usr/bin/env python3 +import json, re, sys, time +from pathlib import Path + +state = Path(__file__).resolve().parent +config = json.loads((state / "config.json").read_text(encoding="utf8")) +args = " ".join(sys.argv[1:]) +with open(state / "calls", "a", encoding="utf8") as log: + log.write(args + "\\n") +counts_path = state / "counts.json" +counts = json.loads(counts_path.read_text(encoding="utf8")) if counts_path.exists() else {} +key, replies, fallback = None, [], None +for index, (pattern, route_replies) in enumerate(config["routes"]): + if re.search(pattern, args): + key, replies, fallback = f"route-{index}", route_replies, route_replies[-1] + break +if key is None and "/check-runs" in args: + key, replies, fallback = "check-runs", config["responses"], config["default"] +served = counts.get(key, 0) if key else 0 +reply = replies[served] if served < len(replies) else fallback +if reply is None: + sys.stderr.write(f"fake gh: unscripted call: {args}\\n") + sys.exit(97) +counts[key] = served + 1 +counts_path.write_text(json.dumps(counts), encoding="utf8") +time.sleep(config["delay"]) +sys.stdout.write(reply[1]) +sys.stderr.write(reply[2] if len(reply) > 2 and reply[2] else "") +sys.exit(reply[0]) +""" + def __init__( self, directory: Path, *, responses: list[tuple] | None = None, default: tuple | None = None, + routes: dict[str, tuple | list[tuple]] | None = None, delay: int = 0, ) -> None: directory.mkdir(parents=True, exist_ok=True) self.directory = directory self.calls_path = directory / "calls" - replies = directory / "replies" - replies.mkdir() - for name, reply in [(str(i), r) for i, r in enumerate(responses or [], start=1)]: - self._write_reply(replies, name, reply) - if default is not None: - self._write_reply(replies, "default", default) + config = { + "responses": list(responses or []), + "default": default, + "routes": [ + [pattern, list(reply) if isinstance(reply, list) else [reply]] + for pattern, reply in (routes or {}).items() + ], + "delay": delay, + } + (directory / "config.json").write_text(json.dumps(config), encoding="utf8") script = directory / "gh" - script.write_text( - "#!/usr/bin/env bash\n" - f'printf "%s\\n" "$*" >> {self.calls_path!s}\n' - f'call="$(wc -l < {self.calls_path!s})"\n' - f'reply="{replies!s}/${{call}}"\n' - f'[[ -f "${{reply}}" ]] || reply="{replies!s}/default"\n' - 'if [[ ! -f "${reply}" ]]; then\n' - ' echo "fake gh: unscripted call: $*" >&2\n' - " exit 97\n" - "fi\n" - f"{f'sleep {delay}' if delay else ''}\n" - 'code="$(head -n 1 "${reply}")"\n' - '[[ -f "${reply}.err" ]] && cat "${reply}.err" >&2\n' - 'tail -n +2 "${reply}"\n' - 'exit "${code}"\n', - encoding="utf8", - ) + script.write_text(self.STUB, encoding="utf8") script.chmod(0o755) - @staticmethod - def _write_reply(replies: Path, name: str, reply: tuple) -> None: - code, payload = reply[0], reply[1] - (replies / name).write_text(f"{code}\n{payload}", encoding="utf8") - if len(reply) > 2 and reply[2]: - (replies / f"{name}.err").write_text(reply[2], encoding="utf8") - @property def calls(self) -> list[str]: if not self.calls_path.exists(): return [] return self.calls_path.read_text(encoding="utf8").splitlines() + def calls_matching(self, pattern: str) -> list[str]: + return [call for call in self.calls if re.search(pattern, call)] + + @property + def check_run_calls(self) -> list[str]: + return [call for call in self.calls if "/check-runs" in call] + + @property + def workflow_run_calls(self) -> list[str]: + return [call for call in self.calls if "/actions/runs" in call] + + +def load_yaml(path: Path) -> dict: + import yaml + from test_classifier_ci_contract import WorkflowLoader # noqa: E402 keeps "on" a string key + return yaml.load(path.read_text(encoding="utf8"), Loader=WorkflowLoader) + class MergeGatePollerTest(unittest.TestCase): maxDiff = None @@ -192,6 +338,9 @@ def setUp(self) -> None: match.group("name"): int(match.group("default")) for match in TUNABLE.finditer(self.script) } + skews = EVENT_SKEW.findall(self.script) + self.assertEqual(len(skews), 1, "expected the gate to declare EVENT_SKEW_SECONDS once") + self.event_skew = int(skews[0]) # -- helpers ----------------------------------------------------------- @@ -215,7 +364,8 @@ def gate_env(self, fake: FakeGh, profile: str, **overrides: str) -> dict[str, st "SOURCE_VALIDATION_REQUIRED": "true", "GH_TOKEN": "fake-token", "REPOSITORY": "example/repo", - "SHA": "0" * 40, + "SHA": SHA, + "RUN_ID": str(GATE_RUN_ID), } declared = set(self.step["env"]) self.assertFalse( @@ -302,27 +452,28 @@ def test_completed_success_on_every_required_check_passes(self) -> None: self.assertIn("Full Validation Gate: status=completed conclusion=success", completed.stdout) def test_failed_conclusion_fails_as_a_failed_check_not_as_pending(self) -> None: - for conclusion in ("failure", "cancelled", "timed_out", "action_required", "neutral", "skipped"): + for conclusion in ("failure", "timed_out", "action_required", "neutral", "skipped", "stale"): with self.subTest(conclusion=conclusion): fake = FakeGh( self.tmp(f"concluded-{conclusion}"), default=ok( body( page( - check_run("Core Checks Gate", "completed", conclusion, started_at="1"), - check_run("Full Validation Gate", "completed", "success", started_at="1"), + check_run("Core Checks Gate", "completed", conclusion, suite=CORE_THIS, started_at="1"), + check_run("Full Validation Gate", "completed", "success", suite=FULL_THIS, started_at="1"), ) ) ), + routes=event_routes(), ) completed = self.run_gate(fake) self.assertGateFailed( completed, f"{ERROR_CHECK_FAILED}Core Checks Gate concluded {conclusion}.", - forbidden=(ERROR_TIMED_OUT, ERROR_UNREADABLE), + forbidden=(ERROR_TIMED_OUT, ERROR_UNREADABLE, ERROR_UNCORRELATED), ) # A verdict is reached on the first poll, not after a wait. - self.assertEqual(len(fake.calls), 1, fake.calls) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) def test_a_check_still_running_is_pending_and_the_timeout_names_it(self) -> None: fake = FakeGh( @@ -452,8 +603,14 @@ def test_non_numeric_tunable_fails_closed_before_polling(self) -> None: # -- selection --------------------------------------------------------- def test_the_newest_check_run_wins_when_a_check_was_rerun(self) -> None: - older_failed = check_run("rpc-docs", "completed", "failure", started_at="2026-09-17T00:00:00Z") - newer_green = check_run("rpc-docs", "completed", "success", started_at="2026-09-18T00:00:00Z") + # A re-run adds a check run to the same workflow run, and so to the + # same check suite: its attempts rank by started_at alone. + older_failed = check_run( + "rpc-docs", "completed", "failure", suite=RPC_DOCS_THIS, started_at="2026-09-17T00:00:00Z" + ) + newer_green = check_run( + "rpc-docs", "completed", "success", suite=RPC_DOCS_THIS, started_at="2026-09-18T00:00:00Z" + ) for label, runs in { "newest last": [older_failed, newer_green], @@ -464,24 +621,812 @@ def test_the_newest_check_run_wins_when_a_check_was_rerun(self) -> None: completed = self.run_gate(fake, profile="rpc-docs") self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) self.assertIn("rpc-docs: status=completed conclusion=success", completed.stdout) + self.assertEqual(fake.workflow_run_calls, []) # ... and the reverse: a green first attempt must not mask a red re-run. - older_green = check_run("rpc-docs", "completed", "success", started_at="2026-09-17T00:00:00Z") - newer_failed = check_run("rpc-docs", "completed", "failure", started_at="2026-09-18T00:00:00Z") - fake = FakeGh(self.tmp("rerun-red"), default=ok(body(page(older_green, newer_failed)))) + older_green = check_run( + "rpc-docs", "completed", "success", suite=RPC_DOCS_THIS, started_at="2026-09-17T00:00:00Z" + ) + newer_failed = check_run( + "rpc-docs", "completed", "failure", suite=RPC_DOCS_THIS, started_at="2026-09-18T00:00:00Z" + ) + fake = FakeGh( + self.tmp("rerun-red"), default=ok(body(page(older_green, newer_failed))), routes=event_routes() + ) self.assertGateFailed( self.run_gate(fake, profile="rpc-docs"), f"{ERROR_CHECK_FAILED}rpc-docs concluded failure.", ) + # -- which event a failed check belongs to ------------------------------- + # + # A reopen, a label or a re-run starts this gate within seconds, while the + # workflows it waits for create their gate checks only at their last job. + # Until then the newest check of a name can be an earlier event's, whose + # failure says nothing about the run under way. + + def failed_core(self, suite: int, **stamps: str) -> dict: + stamps.setdefault("started_at", "2026-10-06T14:20:00Z") + stamps.setdefault("completed_at", "2026-10-06T14:26:19Z") + return check_run("Core Checks Gate", "completed", "failure", suite=suite, **stamps) + + def green_validation(self) -> dict: + return check_run( + "Full Validation Gate", "completed", "success", suite=FULL_THIS, + started_at="2026-10-06T14:30:00Z", completed_at="2026-10-06T14:31:00Z", + ) + + def test_a_failure_from_this_event_fails_at_once(self) -> None: + # Core Checks, Full Validation and this gate are separate workflows the + # same event starts, so a free runner can let Core Checks fail before + # this gate's step, or even its run, has started. + for conclusion in ("failure", "timed_out"): + with self.subTest(conclusion=conclusion): + fake = FakeGh( + self.tmp(f"this-event-{conclusion}"), + default=ok(body(page( + check_run( + "Core Checks Gate", "completed", conclusion, suite=CORE_THIS, + started_at="2026-10-06T14:12:01Z", completed_at="2026-10-06T14:12:02Z", + ), + check_run("Full Validation Gate", "in_progress", None, suite=FULL_THIS, started_at="2026-10-06T14:12:01Z"), + ))), + routes=event_routes(), + ) + completed = self.run_gate(fake) + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded {conclusion}.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNREADABLE, ERROR_UNCORRELATED), + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + self.assertIn(f"This gate's workflow run was created at {THIS_EVENT}", completed.stdout) + # The workflow run, not one of its attempts: an attempt has its own created_at. + self.assertEqual(len(fake.calls_matching(GATE_RUN_PATH)), 1, fake.calls) + self.assertEqual(len(fake.calls_matching(suite_lookup(CORE_THIS))), 1, fake.calls) + + def test_an_earlier_events_failure_is_waited_on_and_the_timeout_names_it(self) -> None: + # It ended after this gate's event, as a failure that outlives a newer + # push or label can: when a check ended says nothing about its event. + for conclusion in ("failure", "timed_out", "action_required"): + with self.subTest(conclusion=conclusion): + fake = FakeGh( + self.tmp(f"earlier-{conclusion}"), + default=ok(body(page( + check_run( + "Core Checks Gate", "completed", conclusion, suite=CORE_EARLIER, + started_at=EARLIER_EVENT, completed_at=LATER_EVENT, + ), + self.green_validation(), + ))), + routes=event_routes(), + ) + completed = self.run_gate(fake, GATE_WAIT_TIMEOUT_SECONDS="3") + self.assertGateFailed( + completed, + f"{ERROR_TIMED_OUT}Required checks did not complete within 3s and are still pending: " + f"Core Checks Gate (last {conclusion} from an earlier event)", + forbidden=(ERROR_CHECK_FAILED, ERROR_UNREADABLE, ERROR_UNCORRELATED), + ) + self.assertIn( + f"Core Checks Gate: concluded {conclusion} in a workflow run created at {EARLIER_EVENT}, " + f"by an earlier event than this gate's ({THIS_EVENT}); waiting for a newer run", + completed.stdout, + ) + self.assertGreater(len(fake.check_run_calls), 2, "an earlier event's failure must be polled again") + # Dated once: the workflow run behind a check suite cannot change. + self.assertEqual(len(fake.calls_matching(suite_lookup(CORE_EARLIER))), 1, fake.calls) + self.assertEqual(len(fake.calls_matching(GATE_RUN_PATH)), 1, fake.calls) + + def test_the_newer_run_replaces_an_earlier_events_failure(self) -> None: + stale = self.failed_core(CORE_EARLIER, started_at=EARLIER_EVENT, completed_at=EARLIER_EVENT) + fresh_running = check_run("Core Checks Gate", "in_progress", None, suite=CORE_THIS, started_at="2026-10-06T14:30:00Z") + fresh_green = check_run( + "Core Checks Gate", "completed", "success", suite=CORE_THIS, + started_at="2026-10-06T14:30:00Z", completed_at="2026-10-06T14:44:00Z", + ) + validation = self.green_validation() + fake = FakeGh( + self.tmp("earlier-then-green"), + responses=[ + ok(body(page(stale, validation))), + ok(body(page(stale, validation))), + ok(body(page(stale, fresh_running, validation))), + ok(body(page(stale, fresh_running, validation))), + ], + default=ok(body(page(stale, fresh_green, validation))), + routes=event_routes(), + ) + completed = self.run_gate(fake) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertIn("waiting for a newer run", completed.stdout) + self.assertIn("Core Checks Gate: status=in_progress conclusion=pending", completed.stdout) + self.assertIn("Core Checks Gate: status=completed conclusion=success", completed.stdout) + + def test_a_rerun_of_only_this_gate_fails_at_once_on_this_events_failure(self) -> None: + # Re-running this gate alone starts no Core Checks or Full Validation, + # so no newer run is coming. The re-run keeps its workflow run and that + # run's created_at, and moves only run_attempt and run_started_at, so a + # failure that ended hours before the re-run is still this event's. + rerun = workflow_run(GATE_RUN_ID, 999, THIS_EVENT, run_attempt=4, run_started_at="2026-10-06T18:02:04Z") + fake = FakeGh( + self.tmp("gate-only-rerun"), + default=ok(body(page(self.failed_core(CORE_THIS), self.green_validation()))), + routes=event_routes(gate_run=rerun), + ) + completed = self.run_gate(fake) + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + + def test_a_rerun_of_an_earlier_events_workflow_is_still_that_events(self) -> None: + # Re-running an earlier event's Core Checks keeps its workflow run's + # created_at, so its failure, however recent, is still waited on: the + # check this event started will be newer. + rerun = workflow_run( + 50_101, CORE_EARLIER, EARLIER_EVENT, number=452, run_attempt=2, run_started_at="2026-10-06T15:31:19Z" + ) + routes = event_routes() + routes[suite_lookup(CORE_EARLIER)] = ok(workflow_runs(rerun)) + fake = FakeGh( + self.tmp("earlier-workflow-rerun"), + default=ok(body(page( + self.failed_core(CORE_EARLIER, started_at="2026-10-06T15:31:30Z", completed_at="2026-10-06T15:45:16Z"), + self.green_validation(), + ))), + routes=routes, + ) + completed = self.run_gate(fake, GATE_WAIT_TIMEOUT_SECONDS="2") + self.assertGateFailed( + completed, + f"{ERROR_TIMED_OUT}Required checks did not complete within 2s and are still pending: " + "Core Checks Gate (last failure from an earlier event)", + forbidden=(ERROR_CHECK_FAILED, ERROR_UNCORRELATED), + ) + + def test_a_label_event_fails_at_once_on_its_own_core_checks_failure(self) -> None: + # A label on a commit whose Core Checks had passed starts this gate, + # Core Checks and Full Validation again. This event's Core Checks + # failed before this gate first polled: it is the newest Core Checks + # Gate and the verdict, while Full Validation is still running. + earlier_green = check_run( + "Core Checks Gate", "completed", "success", suite=CORE_EARLIER, + started_at=EARLIER_EVENT, completed_at=EARLIER_EVENT, + ) + this_failed = self.failed_core(CORE_THIS, started_at="2026-10-06T14:12:40Z", completed_at="2026-10-06T14:13:10Z") + validation_running = check_run("Full Validation Gate", "in_progress", None, suite=FULL_THIS, started_at="2026-10-06T14:12:05Z") + fake = FakeGh( + self.tmp("label-this-event"), + default=ok(body(page(earlier_green, this_failed, validation_running))), + routes=event_routes(), + ) + completed = self.run_gate(fake) + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + + def test_a_later_events_failure_is_current(self) -> None: + # Nothing this gate could wait for is newer than a later event's run. + fake = FakeGh( + self.tmp("later-event"), + default=ok(body(page(self.failed_core(CORE_LATER, started_at=LATER_EVENT), self.green_validation()))), + routes=event_routes(suites={**SUITE_RUNS, **LATER_RUNS}), + ) + completed = self.run_gate(fake) + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + + # -- which of a check's runs decides ------------------------------------- + # + # A check has a run per event that ran CI on the commit, and a re-run adds + # another to the same workflow run. The newest workflow run's decides, at + # its latest attempt, so a manual re-run of an earlier event's workflow, + # which starts later, never outranks this event's result. + + def earlier_rerun(self, status: str, conclusion: str | None) -> dict: + if status == "queued": + return check_run("Core Checks Gate", status, conclusion, suite=CORE_EARLIER, created_at="2026-10-06T15:31:30Z") + stamps = {"started_at": "2026-10-06T15:31:30Z"} + if status == "completed": + stamps["completed_at"] = "2026-10-06T15:45:16Z" + return check_run("Core Checks Gate", status, conclusion, suite=CORE_EARLIER, **stamps) + + def this_events_core(self, status: str, conclusion: str | None) -> dict: + stamps = {"started_at": "2026-10-06T14:30:00Z"} + if status == "completed": + stamps["completed_at"] = "2026-10-06T14:44:00Z" + return check_run("Core Checks Gate", status, conclusion, suite=CORE_THIS, **stamps) + + def test_an_earlier_events_rerun_does_not_hide_this_events_success(self) -> None: + for status, conclusion in ( + ("completed", "failure"), + ("completed", "timed_out"), + ("completed", "cancelled"), + ("in_progress", None), + ("queued", None), + ): + with self.subTest(rerun=conclusion or status): + fake = FakeGh( + self.tmp(f"earlier-rerun-{conclusion or status}"), + default=ok(body(page( + self.this_events_core("completed", "success"), + self.earlier_rerun(status, conclusion), + self.green_validation(), + ))), + routes=event_routes(), + ) + completed = self.run_gate(fake) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertIn( + "Core Checks Gate: 2 check runs from 2 workflow runs; the newest workflow run's, " + f"created at {THIS_EVENT}, decides", + completed.stdout, + ) + self.assertIn("Core Checks Gate: status=completed conclusion=success", completed.stdout) + # A verdict on the first poll, from one lookup per check suite. + self.assertEqual(len(fake.check_run_calls), 2, fake.calls) + self.assertEqual(len(fake.calls_matching(suite_lookup(CORE_EARLIER))), 1, fake.calls) + self.assertEqual(len(fake.calls_matching(suite_lookup(CORE_THIS))), 1, fake.calls) + self.assertEqual(fake.calls_matching(GATE_RUN_PATH), [], "a success is never dated") + + def test_an_earlier_events_rerun_does_not_hide_this_events_failure(self) -> None: + fake = FakeGh( + self.tmp("earlier-rerun-green"), + default=ok(body(page( + self.this_events_core("completed", "failure"), + self.earlier_rerun("completed", "success"), + self.green_validation(), + ))), + routes=event_routes(), + ) + completed = self.run_gate(fake) + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + + def test_an_earlier_events_rerun_failure_waits_for_this_events_result(self) -> None: + # The re-run failed before this event's check existed, and stays the + # newest by started_at once it does: only this event's result decides. + rerun_failed = self.earlier_rerun("completed", "failure") + validation = self.green_validation() + fake = FakeGh( + self.tmp("earlier-rerun-then-green"), + responses=[ + ok(body(page(rerun_failed, validation))), + ok(body(page(rerun_failed, validation))), + ok(body(page(rerun_failed, self.this_events_core("in_progress", None), validation))), + ok(body(page(rerun_failed, self.this_events_core("in_progress", None), validation))), + ], + default=ok(body(page(rerun_failed, self.this_events_core("completed", "success"), validation))), + routes=event_routes(), + ) + completed = self.run_gate(fake) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertIn("by an earlier event than this gate's", completed.stdout) + self.assertIn("Core Checks Gate: status=in_progress conclusion=pending", completed.stdout) + self.assertGreater(len(fake.check_run_calls), 4, fake.calls) + # Cached per check suite across polls: a suite's workflow run cannot change. + self.assertEqual(len(fake.calls_matching(suite_lookup(CORE_EARLIER))), 1, fake.calls) + self.assertEqual(len(fake.calls_matching(suite_lookup(CORE_THIS))), 1, fake.calls) + self.assertEqual(len(fake.calls_matching(GATE_RUN_PATH)), 1, fake.calls) + + def test_check_runs_that_all_succeeded_are_never_ranked(self) -> None: + fake = FakeGh( + self.tmp("all-runs-green"), + default=ok(body(page( + self.this_events_core("completed", "success"), + self.earlier_rerun("completed", "success"), + self.green_validation(), + ))), + ) + completed = self.run_gate(fake) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertEqual(fake.workflow_run_calls, []) + + def test_runs_created_in_the_same_second_rank_by_run_number(self) -> None: + # Two labels applied at once start two runs of a workflow in the same + # second; the later one has the higher run_number, whichever check + # started last. + first, second = 401, 402 + suites = { + first: (CORE_WORKFLOW, 456, THIS_EVENT), + second: (CORE_WORKFLOW, 457, THIS_EVENT), + FULL_THIS: SUITE_RUNS[FULL_THIS], + } + for label, (newer, older) in { + "the newer run failed": ("failure", "success"), + "the newer run passed": ("success", "failure"), + }.items(): + with self.subTest(case=label): + fake = FakeGh( + self.tmp(f"same-second-{newer}"), + default=ok(body(page( + check_run("Core Checks Gate", "completed", newer, suite=second, started_at="2026-10-06T14:20:00Z"), + check_run("Core Checks Gate", "completed", older, suite=first, started_at="2026-10-06T15:31:30Z"), + self.green_validation(), + ))), + routes=event_routes(suites=suites), + ) + completed = self.run_gate(fake) + if newer == "failure": + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + else: + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertEqual(len(fake.check_run_calls), 1 if newer == "failure" else 2, fake.calls) + + def test_check_runs_that_cannot_be_ranked_fail_closed(self) -> None: + # Which of several workflow runs is newest must be read, never guessed. + server_error = (1, "", "gh: Server Error (HTTP 502)\n") + earlier_failed = self.failed_core(CORE_EARLIER, started_at=EARLIER_EVENT, completed_at=EARLIER_EVENT) + this_running = self.this_events_core("in_progress", None) + unranked = ( + f"{ERROR_UNCORRELATED}Core Checks Gate on {'0' * 40} has check runs from 2 workflow runs, but the " + "workflow runs that tell which event is newest could not be read; failing closed with no verdict " + "for: Core Checks Gate Full Validation Gate" + ) + no_number = workflow_run(50_201, CORE_THIS, THIS_EVENT, number=None) + cases = { + "a check suite's run cannot be read": ( + {suite_lookup(CORE_THIS): server_error}, this_running, unranked, suite_lookup(CORE_THIS), + ), + "a check suite's run has no run_number": ( + {suite_lookup(CORE_THIS): ok(workflow_runs(no_number))}, this_running, unranked, suite_lookup(CORE_THIS), + ), + "a check run names no check suite": ( + {}, + check_run("Core Checks Gate", "in_progress", None, started_at="2026-10-06T14:30:00Z"), + f"{ERROR_UNCORRELATED}Core Checks Gate on {'0' * 40} has 2 check runs, but not every one names " + "the workflow run that tells which event is newest; failing closed with no verdict for: " + "Core Checks Gate Full Validation Gate", + None, + ), + } + for index, (label, (overrides, other, expected, gave_up)) in enumerate(cases.items()): + with self.subTest(case=label): + routes = event_routes() + routes.update(overrides) + fake = FakeGh( + self.tmp(f"unranked-{index}"), + default=ok(body(page(earlier_failed, other, self.green_validation()))), + routes=routes, + ) + completed = self.run_gate(fake, GATE_QUERY_MAX_ATTEMPTS="2") + self.assertGateFailed( + completed, expected, forbidden=(ERROR_CHECK_FAILED, ERROR_TIMED_OUT, ERROR_UNREADABLE) + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + if gave_up is not None: + self.assertEqual(len(fake.calls_matching(gave_up)), 2, fake.calls) + + def test_the_event_skew_allowance_is_a_documented_constant(self) -> None: + self.assertEqual(self.event_skew, DOCUMENTED_EVENT_SKEW_SECONDS) + self.assertNotIn("EVENT_SKEW_SECONDS:-", self.script, "the allowance must not be overridable") + + def test_the_event_skew_boundary(self) -> None: + # Workflow runs one event starts are created within the allowance of + # each other; a run created further before this gate's is an earlier + # event's. The documented value, not the script's, sets the boundary. + gate = parse_iso(THIS_EVENT) + skew = timedelta(seconds=DOCUMENTED_EVENT_SKEW_SECONDS) + one = timedelta(seconds=1) + cases = { + "the same second": (gate, "current"), + "the allowance before": (gate - skew, "current"), + "one second more": (gate - skew - one, "earlier"), + "after this gate's run": (gate + skew + one, "current"), + } + for label, (created, expected) in cases.items(): + with self.subTest(case=label, created=iso(created)): + fake = FakeGh( + self.tmp(f"skew-{label}"), + default=ok(body(page(self.failed_core(777), self.green_validation()))), + routes=event_routes(suites={777: (CORE_WORKFLOW, 1, iso(created))}), + ) + completed = self.run_gate(fake, GATE_WAIT_TIMEOUT_SECONDS="2") + if expected == "current": + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + else: + self.assertGateFailed( + completed, + f"{ERROR_TIMED_OUT}Required checks did not complete within 2s and are still pending: " + "Core Checks Gate (last failure from an earlier event)", + forbidden=(ERROR_CHECK_FAILED, ERROR_UNCORRELATED), + ) + + # -- two events on one commit, close together ---------------------------- + # + # No marker names a workflow run's event, so events are told apart by when + # their runs were created and, closer together than the allowance, by a + # newer run of the same workflow on the commit. A newer event cancels the + # earlier event's run, whose always() gate job then concludes failure, + # after the newer run exists. + + def earlier_events_failure(self, suite: int) -> dict: + # Concluded after this event, as a cancelled run's gate job does. + return check_run( + "Core Checks Gate", "completed", "failure", suite=suite, + started_at="2026-10-06T14:12:40Z", completed_at="2026-10-06T14:12:45Z", + ) + + def test_an_earlier_event_seconds_before_waits_for_this_events_run(self) -> None: + # 0 s is two labels applied at once. Inside the allowance only the + # newer run of the workflow tells the earlier event's failure apart. + gate = parse_iso(THIS_EVENT) + first = 401 + for seconds in (0, 2, DOCUMENTED_EVENT_SKEW_SECONDS): + with self.subTest(seconds_apart=seconds): + created = iso(gate - timedelta(seconds=seconds)) + suites = { + first: (CORE_WORKFLOW, 456, created), + CORE_THIS: SUITE_RUNS[CORE_THIS], + FULL_THIS: SUITE_RUNS[FULL_THIS], + } + stale = self.earlier_events_failure(first) + validation = self.green_validation() + running = self.this_events_core("in_progress", None) + fake = FakeGh( + self.tmp(f"apart-{seconds}"), + responses=[ok(body(page(stale, validation)))] * 4 + [ok(body(page(stale, running, validation)))] * 2, + default=ok(body(page(stale, self.this_events_core("completed", "success"), validation))), + routes=event_routes(suites=suites), + ) + completed = self.run_gate(fake) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertIn( + f"Core Checks Gate: concluded failure in run #456 of its workflow, created at {created}, " + f"but run #457, created at {THIS_EVENT}, has since started on {SHA}; waiting for its result", + completed.stdout, + ) + self.assertIn("Core Checks Gate: status=in_progress conclusion=pending", completed.stdout) + # Listed once: a newer run, once seen, stays on the commit. + self.assertEqual(len(fake.calls_matching(commit_runs_lookup(CORE_WORKFLOW))), 1, fake.calls) + + def test_a_superseded_failure_is_named_by_the_timeout(self) -> None: + gate = parse_iso(THIS_EVENT) + suites = { + 401: (CORE_WORKFLOW, 456, iso(gate - timedelta(seconds=2))), + CORE_THIS: SUITE_RUNS[CORE_THIS], + FULL_THIS: SUITE_RUNS[FULL_THIS], + } + fake = FakeGh( + self.tmp("superseded-timeout"), + default=ok(body(page(self.earlier_events_failure(401), self.green_validation()))), + routes=event_routes(suites=suites), + ) + completed = self.run_gate(fake, GATE_WAIT_TIMEOUT_SECONDS="3") + self.assertGateFailed( + completed, + f"{ERROR_TIMED_OUT}Required checks did not complete within 3s and are still pending: " + "Core Checks Gate (last failure superseded by a newer run)", + forbidden=(ERROR_CHECK_FAILED, ERROR_UNCORRELATED, ERROR_UNREADABLE), + ) + + def test_an_earlier_event_30_seconds_before_is_told_apart_by_time(self) -> None: + # Outside the allowance the failure is an earlier event's whether or + # not this event's run of the workflow is listed yet, and no listing + # is read for it. + gate = parse_iso(THIS_EVENT) + first = 401 + for label, listed in {"this event's run listed": True, "not listed yet": False}.items(): + with self.subTest(case=label): + suites = { + first: (CORE_WORKFLOW, 456, iso(gate - timedelta(seconds=30))), + FULL_THIS: SUITE_RUNS[FULL_THIS], + } + if listed: + suites[CORE_THIS] = SUITE_RUNS[CORE_THIS] + fake = FakeGh( + self.tmp(f"apart-30-{listed}"), + default=ok(body(page(self.earlier_events_failure(first), self.green_validation()))), + routes=event_routes(suites=suites), + ) + completed = self.run_gate(fake, GATE_WAIT_TIMEOUT_SECONDS="2") + self.assertGateFailed( + completed, + f"{ERROR_TIMED_OUT}Required checks did not complete within 2s and are still pending: " + "Core Checks Gate (last failure from an earlier event)", + forbidden=(ERROR_CHECK_FAILED, ERROR_UNCORRELATED), + ) + self.assertEqual(fake.calls_matching(commit_runs_lookup(CORE_WORKFLOW)), [], fake.calls) + + def test_a_current_failure_with_no_newer_run_fails_at_once(self) -> None: + # One event's runs are created seconds apart, this gate's up to 3 s + # after another's: such a failure is this event's, and with no newer + # run of its workflow on the commit it fails at once, also when only + # this gate is re-run, hours later. + gate = parse_iso(THIS_EVENT) + rerun = workflow_run(GATE_RUN_ID, 999, THIS_EVENT, run_attempt=3, run_started_at="2026-10-06T19:40:00Z") + for label, gate_run in {"first attempt": None, "gate-only re-run": rerun}.items(): + for seconds in (3, DOCUMENTED_EVENT_SKEW_SECONDS): + with self.subTest(gate=label, seconds_before=seconds): + suites = { + CORE_EARLIER: SUITE_RUNS[CORE_EARLIER], + 401: (CORE_WORKFLOW, 457, iso(gate - timedelta(seconds=seconds))), + FULL_THIS: SUITE_RUNS[FULL_THIS], + } + fake = FakeGh( + self.tmp(f"current-{label}-{seconds}"), + default=ok(body(page(self.failed_core(401), self.green_validation()))), + routes=event_routes(gate_run=gate_run, suites=suites), + ) + completed = self.run_gate(fake) + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + self.assertEqual(len(fake.calls_matching(commit_runs_lookup(CORE_WORKFLOW))), 1, fake.calls) + + def test_runs_of_another_workflow_or_commit_never_supersede(self) -> None: + # Even were the listing's filters ignored. + routes = event_routes() + routes[commit_runs_lookup(CORE_WORKFLOW)] = ok(workflow_runs( + suite_run(CORE_THIS, SUITE_RUNS[CORE_THIS]), + workflow_run(60_001, 901, LATER_EVENT, workflow=FULL_WORKFLOW, number=999), + dict(workflow_run(60_002, 902, LATER_EVENT, number=999), head_sha="1" * 40), + )) + fake = FakeGh( + self.tmp("other-runs"), + default=ok(body(page(self.failed_core(CORE_THIS), self.green_validation()))), + routes=routes, + ) + completed = self.run_gate(fake) + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + + def test_a_later_events_run_decides_over_this_events_failure(self) -> None: + # An event after this gate's has already started a newer run of the + # workflow: this event's failure is waited on, and that run decides. + suites = {**SUITE_RUNS, **LATER_RUNS} + validation = self.green_validation() + this_failed = self.failed_core(CORE_THIS) + for conclusion in ("success", "failure"): + with self.subTest(later=conclusion): + later = check_run( + "Core Checks Gate", "completed", conclusion, suite=CORE_LATER, + started_at="2026-10-06T14:50:00Z", completed_at="2026-10-06T15:04:00Z", + ) + fake = FakeGh( + self.tmp(f"later-decides-{conclusion}"), + responses=[ok(body(page(this_failed, validation)))] * 2, + default=ok(body(page(this_failed, later, validation))), + routes=event_routes(suites=suites), + ) + completed = self.run_gate(fake) + self.assertIn("but run #460, created at " + LATER_EVENT, completed.stdout) + if conclusion == "success": + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + else: + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_TIMED_OUT, ERROR_UNCORRELATED), + ) + + def test_a_success_from_an_earlier_event_still_counts(self) -> None: + # A success on this commit is a verdict whichever event produced it, + # so it is never dated: no workflow run is read for it. + fake = FakeGh( + self.tmp("earlier-success"), + default=ok(body(page( + check_run( + "Core Checks Gate", "completed", "success", suite=CORE_EARLIER, + started_at=EARLIER_EVENT, completed_at=EARLIER_EVENT, + ), + self.green_validation(), + ))), + ) + completed = self.run_gate(fake) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + self.assertEqual(fake.workflow_run_calls, []) + + def test_a_cancelled_check_is_never_a_verdict(self) -> None: + # A newer push, reopen or label cancels the running Full Validation, + # and that cancellation can complete after this gate run started. + # Whichever event's it is, it is waited on and never dated. + core = check_run( + "Core Checks Gate", "completed", "success", suite=CORE_THIS, + started_at="2026-10-06T14:12:05Z", completed_at="2026-10-06T14:26:00Z", + ) + for label, suite in {"earlier": FULL_EARLIER, "this": FULL_THIS}.items(): + with self.subTest(event=label): + cancelled = check_run( + "Full Validation Gate", "completed", "cancelled", suite=suite, + started_at=EARLIER_EVENT, completed_at=LATER_EVENT, + ) + fake = FakeGh(self.tmp(f"cancelled-{label}"), default=ok(body(page(core, cancelled)))) + completed = self.run_gate(fake, GATE_WAIT_TIMEOUT_SECONDS="3") + self.assertGateFailed( + completed, + f"{ERROR_TIMED_OUT}Required checks did not complete within 3s and are still pending: " + "Full Validation Gate (cancelled)", + forbidden=(ERROR_CHECK_FAILED, ERROR_UNREADABLE, ERROR_UNCORRELATED), + ) + self.assertEqual(fake.workflow_run_calls, []) + + cancelled = check_run( + "Full Validation Gate", "completed", "cancelled", suite=FULL_THIS, + started_at="2026-10-06T14:12:05Z", completed_at="2026-10-06T14:13:00Z", + ) + newer_green = check_run( + "Full Validation Gate", "completed", "success", suite=FULL_THIS, + started_at="2026-10-06T14:20:00Z", completed_at="2026-10-06T15:00:00Z", + ) + fake = FakeGh( + self.tmp("cancelled-then-green"), + responses=[ok(body(page(core, cancelled))), ok(body(page(core, cancelled)))], + default=ok(body(page(core, cancelled, newer_green))), + ) + completed = self.run_gate(fake) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + + def test_a_failure_that_cannot_be_dated_fails_closed_with_a_distinct_error(self) -> None: + # Not knowing a failure's event is never success, never a wait for a + # run that may not come, and never reported as the check's own verdict. + gate_run = ok(json.dumps(workflow_run(GATE_RUN_ID, 999, THIS_EVENT))) + core_run = workflow_run(60_201, CORE_THIS, THIS_EVENT) + server_error = (1, "", "gh: Server Error (HTTP 502)\n") + forbidden = (1, "", "gh: Resource not accessible by integration (HTTP 403)\n") + failed = self.failed_core(CORE_THIS) + suite_route = suite_lookup(CORE_THIS) + own_run = suite_run(CORE_THIS, SUITE_RUNS[CORE_THIS]) + dated = {GATE_RUN_PATH: gate_run, suite_route: ok(workflow_runs(own_run))} + listing = commit_runs_lookup(CORE_WORKFLOW) + # case: (routes, check run, env overrides, the route read until it gave up) + cases = { + "the check's run cannot be read": ({GATE_RUN_PATH: gate_run, suite_route: server_error}, failed, {}, suite_route), + "the token cannot read workflow runs": ({GATE_RUN_PATH: gate_run, suite_route: forbidden}, failed, {}, suite_route), + "no run owns the check suite": ({GATE_RUN_PATH: gate_run, suite_route: ok(workflow_runs())}, failed, {}, suite_route), + "only another suite's run": ( + {GATE_RUN_PATH: gate_run, suite_route: ok(workflow_runs(workflow_run(1, 555, THIS_EVENT)))}, + failed, {}, suite_route, + ), + "two runs claim the check suite": ( + {GATE_RUN_PATH: gate_run, suite_route: ok(workflow_runs(core_run, workflow_run(2, CORE_THIS, EARLIER_EVENT)))}, + failed, {}, suite_route, + ), + "the check's run has no created_at": ( + {GATE_RUN_PATH: gate_run, suite_route: ok(workflow_runs(workflow_run(1, CORE_THIS, None)))}, + failed, {}, suite_route, + ), + "the check's run has no run_number": ( + {GATE_RUN_PATH: gate_run, suite_route: ok(workflow_runs(workflow_run(1, CORE_THIS, THIS_EVENT, number=None)))}, + failed, {}, suite_route, + ), + "the check's run names no workflow": ( + {GATE_RUN_PATH: gate_run, suite_route: ok(workflow_runs(workflow_run(1, CORE_THIS, THIS_EVENT, workflow=None)))}, + failed, {}, suite_route, + ), + "the check's run has fractional seconds": ( + {GATE_RUN_PATH: gate_run, suite_route: ok(workflow_runs(workflow_run(1, CORE_THIS, "2026-10-06T14:12:00.5Z")))}, + failed, {}, suite_route, + ), + "the response is not json": ({GATE_RUN_PATH: gate_run, suite_route: ok("502")}, failed, {}, suite_route), + "this gate's run cannot be read": ({GATE_RUN_PATH: server_error}, failed, {}, GATE_RUN_PATH), + "this gate's run is another run": ( + {GATE_RUN_PATH: ok(json.dumps(workflow_run(GATE_RUN_ID + 1, 999, THIS_EVENT)))}, failed, {}, GATE_RUN_PATH, + ), + "this gate's run has no created_at": ( + {GATE_RUN_PATH: ok(json.dumps(workflow_run(GATE_RUN_ID, 999, "")))}, failed, {}, GATE_RUN_PATH, + ), + "this gate's run id is missing": ({}, failed, {"RUN_ID": ""}, None), + # Whether a newer run of the check's workflow is on the commit. + "its workflow's runs cannot be listed": ({**dated, listing: server_error}, failed, {}, listing), + "the token cannot list its workflow's runs": ({**dated, listing: forbidden}, failed, {}, listing), + "the listing is not json": ({**dated, listing: ok("502")}, failed, {}, listing), + "the listing is empty": ({**dated, listing: ok(workflow_runs())}, failed, {}, listing), + "the listing lacks the check's own run": ( + {**dated, listing: ok(workflow_runs(suite_run(CORE_EARLIER, SUITE_RUNS[CORE_EARLIER])))}, + failed, {}, listing, + ), + "the listing has the run under another workflow": ( + {**dated, listing: ok(workflow_runs(dict(own_run, workflow_id=FULL_WORKFLOW)))}, failed, {}, listing, + ), + "the listing has the run under another commit": ( + {**dated, listing: ok(workflow_runs(dict(own_run, head_sha="1" * 40)))}, failed, {}, listing, + ), + "a listed run has no run_number": ( + {**dated, listing: ok(workflow_runs(own_run, workflow_run(1, 999, LATER_EVENT, number=None)))}, + failed, {}, listing, + ), + "the check run names no check suite": ( + {GATE_RUN_PATH: gate_run}, + check_run("Core Checks Gate", "completed", "failure", started_at="2026-10-06T14:20:00Z"), + {}, None, + ), + } + for index, (label, (routes, core, env, gave_up)) in enumerate(cases.items()): + with self.subTest(case=label): + fake = FakeGh( + self.tmp(f"undatable-{index}"), + default=ok(body(page(core, self.green_validation()))), + routes=routes, + ) + completed = self.run_gate(fake, GATE_QUERY_MAX_ATTEMPTS="2", **env) + self.assertGateFailed( + completed, + f"{ERROR_UNCORRELATED}Core Checks Gate on {'0' * 40} concluded failure, but the workflow " + "runs that tell whether it belongs to this event could not be read; failing closed with " + "no verdict for: Core Checks Gate Full Validation Gate", + forbidden=(ERROR_CHECK_FAILED, ERROR_TIMED_OUT, ERROR_UNREADABLE), + ) + self.assertEqual(len(fake.check_run_calls), 1, fake.calls) + if gave_up is not None: + # Bounded by the same retries as the check-runs query. + self.assertEqual(len(fake.calls_matching(gave_up)), 2, fake.calls) + self.assertIn(f"{WARNING_RETRYING}Core Checks Gate: workflow-run", completed.stdout) + + def test_a_transient_lookup_failure_is_retried(self) -> None: + routes = event_routes() + routes[suite_lookup(CORE_THIS)] = [ + (1, "", "gh: Server Error (HTTP 502)\n"), + ok(workflow_runs(suite_run(CORE_THIS, SUITE_RUNS[CORE_THIS]))), + ] + fake = FakeGh( + self.tmp("lookup-transient"), + default=ok(body(page(self.failed_core(CORE_THIS), self.green_validation()))), + routes=routes, + ) + completed = self.run_gate(fake) + self.assertGateFailed( + completed, + f"{ERROR_CHECK_FAILED}Core Checks Gate concluded failure.", + forbidden=(ERROR_UNCORRELATED, ERROR_TIMED_OUT), + ) + self.assertIn(f"{WARNING_RETRYING}Core Checks Gate: workflow-run query failed (exit 1)", completed.stdout) + self.assertEqual(len(fake.calls_matching(suite_lookup(CORE_THIS))), 2, fake.calls) + + def test_every_gate_event_can_start_the_checks_it_waits_for(self) -> None: + # The gate waits for this event's run instead of trusting an earlier + # event's failure, so each event that starts the gate must also start + # the gated workflows. + gate_types = set(self.workflow["on"]["pull_request"]["types"]) + for name in ("core-checks.yml", "ci.yml"): + other = load_yaml(REPO_ROOT / ".github" / "workflows" / name) + types = set(other["on"]["pull_request"].get("types", ["opened", "synchronize", "reopened"])) + self.assertEqual(gate_types - types, set(), f"{name} misses events that start the Required Merge Gate") + + def test_the_workflow_token_can_read_workflow_runs(self) -> None: + # Getting or listing workflow runs needs Actions: read; without it every + # failure would fail closed as undatable. + permissions = self.workflow["permissions"] + self.assertEqual(permissions.get("actions"), "read", permissions) + self.assertEqual(permissions.get("checks"), "read", permissions) + def test_a_queued_rerun_falls_back_to_created_at_and_is_pending(self) -> None: # A re-run that has not started yet has no started_at at all; ordering # by created_at keeps it, so an old green run cannot pass the gate. old_green = check_run( - "rpc-docs", "completed", "success", + "rpc-docs", "completed", "success", suite=RPC_DOCS_THIS, started_at="2026-09-17T00:00:00Z", created_at="2026-09-17T00:00:00Z", ) - queued_rerun = check_run("rpc-docs", "queued", None, created_at="2026-09-18T00:00:00Z") + queued_rerun = check_run("rpc-docs", "queued", None, suite=RPC_DOCS_THIS, created_at="2026-09-18T00:00:00Z") fake = FakeGh(self.tmp("queued-rerun"), default=ok(body(page(old_green, queued_rerun)))) completed = self.run_gate(fake, profile="rpc-docs", GATE_WAIT_TIMEOUT_SECONDS="3") self.assertIn("rpc-docs: status=queued conclusion=pending", completed.stdout) diff --git a/ci/checks/test_mlkem_build_policy.py b/ci/checks/test_mlkem_build_policy.py new file mode 100755 index 0000000000..807afb1e34 --- /dev/null +++ b/ci/checks/test_mlkem_build_policy.py @@ -0,0 +1,750 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +"""Regression tests for qbit's mlkem-native build policy (cmake/mlkem-native.cmake). + +Three layers: +- the WITH_MLKEM_NATIVE decision across the architecture, toolchain and + sanitizer matrix, run in CMake script mode, so every target is covered on + any host; +- configure and build of the mlkem_native library with each compiler found on + this host (cross compilers included), checking which sources and flags are + used; +- the guards in src/crypto/mlkem_config.h that stop a build from compiling + native code where the policy forbids it. + +Cases that need a missing compiler are skipped, unless the compiler is listed +in MLKEM_POLICY_REQUIRED_COMPILERS (space-separated), as CI does for the ones +it installs: then a missing one fails. "cc-m32" stands for cc -m32 with 32-bit +headers. Failures print the signature, cause and fix. + +A fourth layer links qbit's glue and wrapper with every assembly routine +wrapped by a call counter, and checks that the portable override reaches each +of them: the x86_64 backend on x86_64 hosts, the AArch64 backend under +qemu-aarch64 (listed as qemu-aarch64 and aarch64-linux-gnu-g++ in +MLKEM_POLICY_REQUIRED_COMPILERS when required). +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE = REPO_ROOT / "cmake" / "mlkem-native.cmake" +REQUIRED_COMPILERS = frozenset(os.environ.get("MLKEM_POLICY_REQUIRED_COMPILERS", "").split()) + + +def skip_missing_compiler(test: unittest.TestCase, executable: str, problem: str = "not found") -> None: + if executable in REQUIRED_COMPILERS: + test.fail(f"FAIL: {executable} {problem}\nCause: MLKEM_POLICY_REQUIRED_COMPILERS lists it\n" + f"Fix: install it, or drop it from MLKEM_POLICY_REQUIRED_COMPILERS") + test.skipTest(f"{executable} {problem}") + +SUPPORTED_ARCHS = ("x86_64", "aarch64-macho") +UNSUPPORTED_ARCHS = ( + "aarch64-elf", + "this target (riscv64)", + "this target (ppc64)", + "this target (powerpc64le)", + "this target (armv7)", + "this target (s390x)", + "this target (i686)", +) +SANITIZER_SETTINGS = ("", "address,undefined", "thread", "memory", "fuzzer,memory", "memory,undefined") + +POLICY_SCRIPT = r""" +include("${MODULE}") +mlkem_native_policy("${MODE}" "${ARCH}" "${MSVC}" "${SANITIZERS}") +message(NOTICE "RESULT enabled=${MLKEM_NATIVE_ENABLED} no_asm=${MLKEM_NATIVE_NO_ASM} reason=${MLKEM_NATIVE_REASON}") +""" + +FIXTURE_CMAKELISTS = r""" +cmake_minimum_required(VERSION 3.22) +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) +project(qbit_mlkem_policy LANGUAGES NONE) + +if(NOT DEFINED QBIT_SOURCE_DIR) + message(FATAL_ERROR "QBIT_SOURCE_DIR is required") +endif() +set(PROJECT_SOURCE_DIR "${QBIT_SOURCE_DIR}") +set(WITH_MLKEM_NATIVE "AUTO" CACHE STRING "") + +# Stand-ins for qbit's interface libraries: the hardening marker must reach +# mlkem_native, the C++-only warning must not. +add_library(core_interface INTERFACE) +# Like core_interface's -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3, which the MSan +# jobs undo with APPEND_CPPFLAGS=-U_FORTIFY_SOURCE. +target_compile_options(core_interface INTERFACE -DQBIT_POLICY_CORE_FLAG -UQBIT_POLICY_FORTIFY -DQBIT_POLICY_FORTIFY=3) +add_library(warn_interface INTERFACE) +target_compile_options(warn_interface INTERFACE -Wsuggest-override) +target_link_libraries(core_interface INTERFACE warn_interface) +add_library(sanitize_interface INTERFACE) + +include("${QBIT_SOURCE_DIR}/cmake/mlkem-native.cmake") +mlkem_native_configure() +# Like CMakeLists.txt on MinGW, where the compiler accepts it (GCC's assembler +# does, Clang's integrated one does not). +if(MINGW AND CMAKE_C_COMPILER_ID STREQUAL "GNU") + target_compile_options(core_interface INTERFACE -Wa,-muse-unaligned-vector-move) + message(NOTICE "CORE_UNALIGNED_VECTOR_MOVE") +endif() +message(NOTICE "SUMMARY ${MLKEM_NATIVE_SUMMARY}") +message(NOTICE "ENABLE_MLKEM_NATIVE=${ENABLE_MLKEM_NATIVE}") +add_mlkem_native() +""" + +# Compiler setups for the configure-and-build layer: (name, executable, +# extra CMake definitions, target architecture, whether AUTO builds native code). +# A setup without extra definitions targets this host. AArch64 ELF is portable +# only (no BTI landing pads in the assembly); macOS arm64, which builds native +# code, needs an SDK and is covered by the CI job on Apple hardware. +COMPILERS = ( + ("host cc", "cc", (), "x86_64", True), + ("clang-21", "clang-21", (), "x86_64", True), + ("aarch64 gcc", "aarch64-linux-gnu-gcc", ("-DCMAKE_SYSTEM_NAME=Linux", "-DCMAKE_SYSTEM_PROCESSOR=aarch64"), "aarch64", False), + ("clang-21 --target=aarch64-linux-gnu", "clang-21", + ("-DCMAKE_SYSTEM_NAME=Linux", "-DCMAKE_SYSTEM_PROCESSOR=aarch64", "-DCMAKE_C_COMPILER_TARGET=aarch64-linux-gnu"), "aarch64", False), + ("mingw-w64 gcc", "x86_64-w64-mingw32-gcc-posix", ("-DCMAKE_SYSTEM_NAME=Windows", "-DCMAKE_SYSTEM_PROCESSOR=x86_64"), "x86_64", True), + ("clang-21 --target=x86_64-w64-mingw32", "clang-21", + ("-DCMAKE_SYSTEM_NAME=Windows", "-DCMAKE_SYSTEM_PROCESSOR=x86_64", "-DCMAKE_C_COMPILER_TARGET=x86_64-w64-mingw32"), "x86_64", True), +) + + +def run(command: list[str], **kwargs) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, check=False, capture_output=True, text=True, **kwargs) + + +def host_arch() -> str: + machine = run(["uname", "-m"]).stdout.strip() + return {"amd64": "x86_64", "arm64": "aarch64"}.get(machine, machine) + + +class MlkemPolicyMatrixTest(unittest.TestCase): + """WITH_MLKEM_NATIVE decisions, in CMake script mode.""" + + def policy(self, mode: str, arch: str, msvc: bool, sanitizers: str) -> subprocess.CompletedProcess[str]: + cmake = shutil.which("cmake") + self.assertIsNotNone(cmake, "cmake is required") + assert cmake is not None + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "policy.cmake" + script.write_text(POLICY_SCRIPT, encoding="utf8") + return run([ + cmake, + f"-DMODULE={MODULE}", + f"-DMODE={mode}", + f"-DARCH={arch}", + f"-DMSVC={'TRUE' if msvc else 'FALSE'}", + f"-DSANITIZERS={sanitizers}", + "-P", str(script), + ]) + + def expect(self, mode: str, arch: str, msvc: bool, sanitizers: str) -> tuple[bool, bool, str | None]: + """The policy as documented in cmake/mlkem-native.cmake: (enabled, no_asm, configure error).""" + no_asm = msvc + blocked = msvc or arch not in SUPPORTED_ARCHS or re.search(r"(^|,)memory($|,)", sanitizers) is not None + if mode.upper() == "OFF": + return False, no_asm, None + if blocked: + if mode.upper() == "ON": + return False, no_asm, "WITH_MLKEM_NATIVE=ON, but" + return False, no_asm, None + return True, no_asm, None + + def test_matrix(self) -> None: + for mode in ("AUTO", "ON", "OFF", "auto"): + for arch in SUPPORTED_ARCHS + UNSUPPORTED_ARCHS: + for msvc in (False, True): + for sanitizers in SANITIZER_SETTINGS: + with self.subTest(mode=mode, arch=arch, msvc=msvc, sanitizers=sanitizers): + result = self.policy(mode, arch, msvc, sanitizers) + combined = result.stdout + result.stderr + enabled, no_asm, error = self.expect(mode, arch, msvc, sanitizers) + if error: + self.assertNotEqual(result.returncode, 0, + f"FAIL: WITH_MLKEM_NATIVE={mode} configured for {arch}, msvc={msvc}, " + f"SANITIZERS={sanitizers!r}\nCause: ON must refuse targets without native code\n" + f"Fix: mlkem_native_policy in {MODULE}\n{combined}") + self.assertIn(error, combined) + continue + self.assertEqual(result.returncode, 0, combined) + match = re.search(r"RESULT enabled=(\w+) no_asm=(\w+) reason=(.*)", combined) + self.assertIsNotNone(match, combined) + assert match is not None + self.assertEqual(match.group(1) == "TRUE", enabled, + f"FAIL: native={match.group(1)} for {mode}/{arch}/msvc={msvc}/{sanitizers!r}\n" + f"Cause: policy disagrees with the documented matrix\nFix: mlkem_native_policy in {MODULE}") + self.assertEqual(match.group(2) == "TRUE", no_asm, combined) + self.assertTrue(match.group(3).strip(), "every decision states its reason") + + def test_invalid_mode_fails(self) -> None: + for mode in ("YES", "1", "native", ""): + with self.subTest(mode=mode): + result = self.policy(mode, "x86_64", False, "") + self.assertNotEqual(result.returncode, 0) + self.assertIn("WITH_MLKEM_NATIVE must be AUTO, ON or OFF", result.stdout + result.stderr) + + +class MlkemBuildTest(unittest.TestCase): + """Configure and build mlkem_native with the compilers on this host.""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.tmp_path = Path(self.tmp.name) + self.source = self.tmp_path / "source" + self.source.mkdir() + (self.source / "CMakeLists.txt").write_text(FIXTURE_CMAKELISTS, encoding="utf8") + self.cmake = shutil.which("cmake") + self.assertIsNotNone(self.cmake, "cmake is required") + self.assertIsNotNone(shutil.which("ninja"), "ninja is required") + + def configure(self, name: str, compiler: str, *definitions: str, + env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + assert self.cmake is not None + return run([ + self.cmake, "-S", str(self.source), "-B", str(self.tmp_path / name), "-G", "Ninja", + f"-DQBIT_SOURCE_DIR={REPO_ROOT}", f"-DCMAKE_C_COMPILER={compiler}", "-DCMAKE_BUILD_TYPE=RelWithDebInfo", + *definitions, + ], env=env) + + def compile_flags(self, name: str) -> dict[str, list[str]]: + """The flags of mlkem_native's compile commands by source name, without the file arguments.""" + commands = run(["ninja", "-C", str(self.tmp_path / name), "-t", "commands", "mlkem_native"]) + self.assertEqual(commands.returncode, 0, commands.stdout + commands.stderr) + flags: dict[str, list[str]] = {} + for line in commands.stdout.splitlines(): + words = line.split() + if "-c" not in words: + continue + kept = [] + skip = False + for word in words[1:]: + if skip: + skip = False + elif word in ("-MT", "-MF", "-o", "-c"): + skip = True + elif word != "-MD": + kept.append(word) + flags[Path(words[-1]).name] = kept + return flags + + def build(self, name: str) -> subprocess.CompletedProcess[str]: + assert self.cmake is not None + return run([self.cmake, "--build", str(self.tmp_path / name), "--target", "mlkem_native", "--verbose"]) + + def check_build(self, label: str, compiler: str, extra: tuple[str, ...], mode: str, expect_native: bool) -> str: + name = re.sub(r"\W+", "_", f"{label}_{mode}") + configured = self.configure(name, compiler, f"-DWITH_MLKEM_NATIVE={mode}", + "-DAPPEND_CPPFLAGS=-UQBIT_POLICY_FORTIFY", "-DAPPEND_CFLAGS=-DQBIT_POLICY_APPEND_CFLAG", *extra) + self.assertEqual(configured.returncode, 0, configured.stdout + configured.stderr) + self.assertIn("SUMMARY native" if expect_native else "SUMMARY portable", configured.stdout + configured.stderr) + self.assertIn(f"ENABLE_MLKEM_NATIVE={'TRUE' if expect_native else 'FALSE'}", configured.stdout + configured.stderr) + unaligned_moves = "CORE_UNALIGNED_VECTOR_MOVE" in configured.stdout + configured.stderr + if "-DCMAKE_SYSTEM_NAME=Windows" in extra and Path(compiler).name.endswith(("gcc", "gcc-posix")): + self.assertTrue(unaligned_moves, "the fixture must pass -Wa,-muse-unaligned-vector-move for MinGW GCC, as CMakeLists.txt does") + built = self.build(name) + log = built.stdout + built.stderr + self.assertEqual(built.returncode, 0, + f"FAIL: mlkem_native does not build with {label}, WITH_MLKEM_NATIVE={mode}\n{log}") + compile_lines = [line for line in log.splitlines() if " -c " in line] + self.assertTrue(compile_lines, log) + for line in compile_lines: + self.assertNotRegex(line, r"(^|\s)-m(avx|avx2|arch=|cpu=)", + f"FAIL: an ISA flag reached an mlkem_native source with {label}\n" + f"Cause: native code must be selected at run time\nFix: remove the flag from {MODULE}\n{line}") + self.assertIn("-DQBIT_POLICY_CORE_FLAG", line, "core_interface's own options must apply to C and assembly") + self.assertNotIn("-Wsuggest-override", line, "qbit's C++ warning set must not reach the vendored C") + # APPEND_CPPFLAGS and APPEND_CFLAGS come after core_interface's options, so they can undo one + # (the MSan jobs pass -U_FORTIFY_SOURCE against core_interface's -D_FORTIFY_SOURCE=3). + words = line.split() + last_undefine = len(words) - 1 - words[::-1].index("-UQBIT_POLICY_FORTIFY") + self.assertGreater(last_undefine, words.index("-DQBIT_POLICY_FORTIFY=3"), + f"FAIL: APPEND_CPPFLAGS did not reach an mlkem_native compile after core_interface's options with {label}\n" + f"Cause: the C and assembly sources do not get the APPEND flags, or CMake de-duplicated them\n" + f"Fix: add_mlkem_native in {MODULE}\n{line}") + self.assertIn("-DQBIT_POLICY_APPEND_CFLAG", words, line) + asm_lines = [line for line in compile_lines if "mlkem_native_asm.S" in line] + if unaligned_moves: + # The flag would re-encode the proved assembly; the C sources keep it. + for line in compile_lines: + self.assertEqual("-Wa,-muse-unaligned-vector-move" in line.split(), line not in asm_lines, + f"FAIL: -Wa,-muse-unaligned-vector-move must reach mlkem_native's C sources and not its assembly ({label})\n" + f"Cause: gas would encode the assembly's vmovdqa as vmovdqu, which the HOL-Light proofs do not cover\n" + f"Fix: add_mlkem_native in {MODULE}\n{line}") + if expect_native: + self.assertEqual(len(asm_lines), 1, log) + for line in compile_lines: + self.assertIn("-DQBIT_MLKEM_NATIVE", line, line) + else: + self.assertEqual(asm_lines, [], log) + for line in compile_lines: + self.assertNotIn("-DQBIT_MLKEM_NATIVE", line, line) + return log + + def test_compilers(self) -> None: + found = 0 + for label, executable, extra, arch, native in COMPILERS: + compiler = shutil.which(executable) + with self.subTest(compiler=label): + if compiler is None: + skip_missing_compiler(self, executable) + if not extra and arch != host_arch(): + self.skipTest(f"{label} targets {arch}, not this host") + assert compiler is not None + found += 1 + self.check_build(label, compiler, extra, "OFF", False) + log = self.check_build(label, compiler, extra, "AUTO", native) + target = next((d.split("=", 1)[1] for d in extra if d.startswith("-DCMAKE_C_COMPILER_TARGET=")), None) + if native: + self.check_build(label, compiler, extra, "ON", True) + if target: + asm_line = next(line for line in log.splitlines() if "mlkem_native_asm.S" in line) + self.assertRegex(asm_line, rf"(--target=|-target ){re.escape(target)}\b", + "FAIL: assembly built without the C compiler's target\n" + "Cause: CMAKE_ASM_COMPILER_TARGET not propagated\nFix: mlkem_native_configure") + else: + on = self.configure(re.sub(r"\W+", "_", f"{label}_ON"), compiler, "-DWITH_MLKEM_NATIVE=ON", *extra) + combined = re.sub(r"\s+", " ", on.stdout + on.stderr) + self.assertNotEqual(on.returncode, 0, combined) + self.assertIn("WITH_MLKEM_NATIVE=ON, but", combined) + if arch == "aarch64": + self.assertIn("no BTI landing pads", combined) + self.assertGreater(found, 0, "no usable C compiler found") + + def test_append_flags_change_the_detected_target(self) -> None: + """APPEND_CPPFLAGS/APPEND_CFLAGS reach the library, so the target probes must see them: -m32 is not x86_64.""" + if host_arch() != "x86_64": + self.skipTest("needs an x86_64 host") + compiler = shutil.which("cc") + assert compiler is not None + if run([compiler, "-m32", "-x", "c", "-c", "-o", os.devnull, "-"], input="#include \nint x;\n").returncode != 0: + skip_missing_compiler(self, "cc-m32", "has no 32-bit headers") + name = "append_m32" + configured = self.configure(name, compiler, "-DWITH_MLKEM_NATIVE=AUTO", "-DAPPEND_CPPFLAGS=-m32", "-DAPPEND_CFLAGS=-m32") + output = configured.stdout + configured.stderr + self.assertEqual(configured.returncode, 0, output) + self.assertIn("SUMMARY portable", output, + f"FAIL: APPEND_CFLAGS=-m32 still detected x86_64\nCause: the target probes ran without the appended flags\nFix: mlkem_native_detect_arch in {MODULE}") + built = self.build(name) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + # A reconfigure without the flag probes again and goes back to native. + configured = self.configure(name, compiler, "-DWITH_MLKEM_NATIVE=AUTO", "-DAPPEND_CPPFLAGS=", "-DAPPEND_CFLAGS=") + self.assertIn("SUMMARY native", configured.stdout + configured.stderr, configured.stdout + configured.stderr) + + def test_memory_sanitizer(self) -> None: + compiler = shutil.which("cc") + self.assertIsNotNone(compiler) + assert compiler is not None + auto = self.configure("msan_auto", compiler, "-DSANITIZERS=memory") + self.assertEqual(auto.returncode, 0, auto.stdout + auto.stderr) + self.assertIn("ENABLE_MLKEM_NATIVE=FALSE", auto.stdout + auto.stderr) + self.assertIn("MemorySanitizer", auto.stdout + auto.stderr) + built = self.build("msan_auto") + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + self.assertNotIn("mlkem_native_asm.S", built.stdout + built.stderr) + + on = self.configure("msan_on", compiler, "-DSANITIZERS=fuzzer,memory", "-DWITH_MLKEM_NATIVE=ON") + self.assertNotEqual(on.returncode, 0, on.stdout + on.stderr) + self.assertIn("WITH_MLKEM_NATIVE=ON, but MemorySanitizer", re.sub(r"\s+", " ", on.stdout + on.stderr)) + + def test_asm_flags_keep_explicit_configuration(self) -> None: + """The assembly gets the C flags, followed by the ASM flags the user, the + environment or the toolchain set; per configuration, only CMake's own + default gives way to the C flags.""" + if host_arch() != "x86_64": + self.skipTest("the host compiler builds no native ML-KEM code on this host") + compiler = shutil.which("cc") + self.assertIsNotNone(compiler) + assert compiler is not None + toolchain = self.tmp_path / "asm-toolchain.cmake" + toolchain.write_text('set(CMAKE_ASM_FLAGS_INIT "-DTOOLCHAIN_ASM_FLAG")\n' + 'set(CMAKE_ASM_FLAGS_RELWITHDEBINFO_INIT "-DTOOLCHAIN_ASM_RELWITHDEBINFO")\n', encoding="utf8") + c_flags = ("-DCMAKE_C_FLAGS=-DQBIT_POLICY_C_FLAG", "-DCMAKE_C_FLAGS_RELWITHDEBINFO=-O2 -g -DQBIT_POLICY_C_RELWITHDEBINFO") + clean_env = {key: value for key, value in os.environ.items() if key != "ASMFLAGS"} + # (name, definitions, ASMFLAGS, flags the assembly gets after the C flags, RelWithDebInfo flag) + cases = ( + ("default", (), None, (), "-DQBIT_POLICY_C_RELWITHDEBINFO"), + ("user", ("-DCMAKE_ASM_FLAGS=-DUSER_ASM_FLAG",), None, ("-DUSER_ASM_FLAG",), "-DQBIT_POLICY_C_RELWITHDEBINFO"), + ("environment", (), "-DENV_ASM_FLAG", ("-DENV_ASM_FLAG",), "-DQBIT_POLICY_C_RELWITHDEBINFO"), + ("toolchain", (f"-DCMAKE_TOOLCHAIN_FILE={toolchain}",), None, ("-DTOOLCHAIN_ASM_FLAG",), "-DTOOLCHAIN_ASM_RELWITHDEBINFO"), + ("user per-config", ("-DCMAKE_ASM_FLAGS_RELWITHDEBINFO=-DUSER_ASM_RELWITHDEBINFO",), None, (), "-DUSER_ASM_RELWITHDEBINFO"), + ) + for label, definitions, asmflags, asm_only, config_flag in cases: + with self.subTest(case=label): + name = re.sub(r"\W+", "_", f"asm_flags_{label}") + env = dict(clean_env, ASMFLAGS=asmflags) if asmflags else clean_env + configured = self.configure(name, compiler, *c_flags, *definitions, env=env) + self.assertEqual(configured.returncode, 0, configured.stdout + configured.stderr) + flags = self.compile_flags(name) + asm = flags["mlkem_native_asm.S"] + for flag in ("-DQBIT_POLICY_C_FLAG", *asm_only, config_flag): + self.assertEqual(asm.count(flag), 1, + f"FAIL: {flag} reaches mlkem_native_asm.S {asm.count(flag)} times ({label})\n" + f"Cause: mlkem_native_configure replaced or repeated the ASM flags\nFix: {MODULE}\n{asm}") + for flag in asm_only: + self.assertGreater(asm.index(flag), asm.index("-DQBIT_POLICY_C_FLAG"), + f"FAIL: {flag} comes before the C flags ({label})\n" + f"Cause: explicit ASM flags must win on conflict\nFix: {MODULE}\n{asm}") + if config_flag != "-DQBIT_POLICY_C_RELWITHDEBINFO": + self.assertNotIn("-DQBIT_POLICY_C_RELWITHDEBINFO", asm, + f"FAIL: the C RelWithDebInfo flags replaced an explicit ASM value ({label})\nFix: {MODULE}\n{asm}") + else: + # Apart from explicit ASM flags, the assembly compiles exactly like the C. + self.assertEqual([word for word in asm if word not in asm_only], flags["mlkem_native.c"]) + # A reconfigure, with or without the environment, must not accumulate flags. + for again in (env, clean_env): + reconfigured = self.configure(name, compiler, *c_flags, *definitions, env=again) + self.assertEqual(reconfigured.returncode, 0, reconfigured.stdout + reconfigured.stderr) + self.assertEqual(self.compile_flags(name), flags, + f"FAIL: a reconfigure changed the mlkem_native compile commands ({label})\n" + f"Cause: mlkem_native_configure wrote the ASM flags back to the cache\nFix: {MODULE}") + + def test_asm_flags_with_depends_toolchain(self) -> None: + """A toolchain rendered from depends/toolchain.cmake.in, as depends/Makefile + does, assembles with exactly the flags of the C sources.""" + # (name, C compiler, tool prefix, cross: (system name, arch, host), whether AUTO builds native code) + setups = ( + ("native", "cc", "", None, True), + ("mingw-w64", "x86_64-w64-mingw32-gcc-posix", "x86_64-w64-mingw32-", ("Windows", "x86_64", "x86_64-w64-mingw32"), True), + ("aarch64 linux", "aarch64-linux-gnu-gcc", "aarch64-linux-gnu-", ("Linux", "aarch64", "aarch64-linux-gnu"), False), + ) + template = (REPO_ROOT / "depends" / "toolchain.cmake.in").read_text(encoding="utf8") + clean_env = {key: value for key, value in os.environ.items() if key not in ("ASMFLAGS", "CFLAGS")} + for label, executable, prefix, cross, native in setups: + with self.subTest(toolchain=label): + compiler = shutil.which(executable) + if compiler is None: + skip_missing_compiler(self, executable) + assert compiler is not None + if cross is None and host_arch() != "x86_64": + self.skipTest("the host compiler builds no native ML-KEM code on this host") + system, arch, host = cross or ("", "", "") + values = { + "depends_crosscompiling": "TRUE" if cross else "FALSE", "host": host, + "host_system_name": system, "host_system_version": "", "host_arch": arch, + "CC": compiler, "CXX": compiler, "OSX_SDK": "", + "CFLAGS": "-pipe -DQBIT_DEPENDS_CFLAG", "CFLAGS_RELEASE": "-O2 -DQBIT_DEPENDS_RELEASE_CFLAG", + "CFLAGS_DEBUG": "-O1", "CXXFLAGS": "", "CXXFLAGS_RELEASE": "", "CXXFLAGS_DEBUG": "", + "CPPFLAGS": "", "CPPFLAGS_RELEASE": "", "CPPFLAGS_DEBUG": "", + "LDFLAGS": "", "LDFLAGS_RELEASE": "", "LDFLAGS_DEBUG": "", + **{tool.upper(): shutil.which(prefix + tool) or prefix + tool + for tool in ("ar", "ranlib", "strip", "objcopy", "objdump")}, + } + rendered = re.sub(r"@(\w+)@", lambda match: values.get(match.group(1), ""), template) + name = re.sub(r"\W+", "_", f"depends_{label}") + toolchain_dir = self.tmp_path / f"{name}_prefix" + toolchain_dir.mkdir() + (toolchain_dir / "toolchain.cmake").write_text(rendered, encoding="utf8") + configured = self.configure(name, compiler, f"-DCMAKE_TOOLCHAIN_FILE={toolchain_dir / 'toolchain.cmake'}", + env=clean_env) + self.assertEqual(configured.returncode, 0, configured.stdout + configured.stderr) + self.assertIn(f"ENABLE_MLKEM_NATIVE={'TRUE' if native else 'FALSE'}", configured.stdout + configured.stderr) + flags = self.compile_flags(name) + for flag in ("-DQBIT_DEPENDS_CFLAG", "-DQBIT_DEPENDS_RELEASE_CFLAG"): + self.assertIn(flag, flags["mlkem_native.c"], flags) + if not native: + self.assertNotIn("mlkem_native_asm.S", flags) + continue + # Except the MinGW flag that would re-encode the proved assembly (check_build). + c_flags = [flag for flag in flags["mlkem_native.c"] if flag != "-Wa,-muse-unaligned-vector-move"] + self.assertEqual(flags["mlkem_native_asm.S"], c_flags, + f"FAIL: with the {label} depends toolchain the assembly flags differ from the C flags\n" + f"Cause: mlkem_native_configure changed what depends builds pass to the assembler\nFix: {MODULE}") + + +class MlkemConfigHeaderTest(unittest.TestCase): + """src/crypto/mlkem_config.h refuses native code the policy forbids.""" + + def compile_backend(self, compiler: str, *flags: str) -> subprocess.CompletedProcess[str]: + return run([ + compiler, "-fsyntax-only", '-DMLK_CONFIG_FILE="crypto/mlkem_config.h"', + f"-I{REPO_ROOT / 'src'}", f"-I{REPO_ROOT / 'src' / 'mlkem-native'}", + *flags, str(REPO_ROOT / "src" / "crypto" / "mlkem_backend.c"), + ]) + + def test_guards(self) -> None: + cc = shutil.which("cc") + self.assertIsNotNone(cc) + assert cc is not None + self.assertEqual(self.compile_backend(cc).returncode, 0) + both = self.compile_backend(cc, "-DQBIT_MLKEM_NATIVE", "-DMLK_CONFIG_NO_ASM") + self.assertNotEqual(both.returncode, 0) + self.assertIn("QBIT_MLKEM_NATIVE and MLK_CONFIG_NO_ASM", both.stderr) + + cases = ( + ("clang-21", ("-fsanitize=memory", "-DQBIT_MLKEM_NATIVE"), "MemorySanitizer cannot see writes"), + ("aarch64-linux-gnu-gcc", ("-DQBIT_MLKEM_NATIVE",), "no BTI landing pads"), + ("cc", ("-m32", "-DQBIT_MLKEM_NATIVE"), "no native ML-KEM backend"), + ) + for executable, flags, message in cases: + compiler = shutil.which(executable) + with self.subTest(compiler=executable, flags=flags): + if compiler is None: + skip_missing_compiler(self, executable) + assert compiler is not None + if flags[0] == "-m32" and self.compile_backend(compiler, "-m32").returncode != 0: + skip_missing_compiler(self, "cc-m32", "has no 32-bit headers") + result = self.compile_backend(compiler, *flags) + self.assertNotEqual(result.returncode, 0, f"FAIL: {executable} {flags} compiled native code") + self.assertIn(message, result.stderr) + + + +COVERAGE_HARNESS = r""" +#include +#include +#include +#include +#include +#include +unsigned long AsmCalls(); +using namespace mlkem; +static std::array Seed() +{ + std::array seed{}; + for (size_t i = 0; i < seed.size(); ++i) seed[i] = uint8_t(i * 7 + 1); + return seed; +} +static const std::array COINS{}; +static std::vector RoundTrip() +{ + PublicKey ek; DecapsulationKey dk; Ciphertext ct; SharedSecret sent, received; + if (KeyGen(Seed(), ek, dk) != Error::NONE || CheckPublicKey(ek) != Error::NONE || + Encaps(ek, COINS, ct, sent) != Error::NONE || Decaps(dk, ct, received) != Error::NONE) return {}; + std::vector out(ek.begin(), ek.end()); + out.insert(out.end(), ct.begin(), ct.end()); + out.insert(out.end(), received.Bytes().begin(), received.Bytes().end()); + return out; +} +// The assembly calls of each entry point, run alone on a new thread, whose +// copy of the override is unset until the entry point takes it. +static std::array EachOperationOnAFreshThread(const PublicKey& ek, const DecapsulationKey& dk, const Ciphertext& ct) +{ + const std::function operations[4]{ + [] { PublicKey e; DecapsulationKey d; (void)KeyGen(Seed(), e, d); }, + [&] { (void)CheckPublicKey(ek); }, + [&] { Ciphertext c; SharedSecret s; (void)Encaps(ek, COINS, c, s); }, + [&] { SharedSecret s; (void)Decaps(dk, ct, s); }, + }; + std::array calls{}; + for (size_t i = 0; i < calls.size(); ++i) { + AsmCalls(); + std::thread{operations[i]}.join(); + calls[i] = AsmCalls(); + } + return calls; +} +int main() +{ + const auto detected{RoundTrip()}; + const unsigned long detected_calls{AsmCalls()}; + const auto active{GetBackendNames().arith}; + PublicKey ek; DecapsulationKey dk; Ciphertext ct; SharedSecret ss; + const bool inputs{KeyGen(Seed(), ek, dk) == Error::NONE && Encaps(ek, COINS, ct, ss) == Error::NONE}; + const auto fresh_detected{EachOperationOnAFreshThread(ek, dk, ct)}; + std::vector portable; + unsigned long portable_calls; + std::array fresh_portable; + { + ForcePortableForTesting force; + AsmCalls(); + portable = RoundTrip(); + portable_calls = AsmCalls(); + fresh_portable = EachOperationOnAFreshThread(ek, dk, ct); + } + std::printf("active=%.*s detected_calls=%lu portable_calls=%lu equal=%d ok=%d " + "fresh_detected=%lu,%lu,%lu,%lu fresh_portable=%lu,%lu,%lu,%lu\n", + int(active.size()), active.data(), detected_calls, portable_calls, int(detected == portable), + int(!detected.empty() && inputs), fresh_detected[0], fresh_detected[1], fresh_detected[2], fresh_detected[3], + fresh_portable[0], fresh_portable[1], fresh_portable[2], fresh_portable[3]); +} +""" + +# The entry points EachOperationOnAFreshThread runs, in its order. +OPERATIONS = ("KeyGen", "CheckPublicKey", "Encaps", "Decaps") + +# Test-only edits that let qbit's AArch64 glue build for AArch64 ELF, which +# release builds refuse (src/crypto/mlkem_config.h: no BTI landing pads), so +# that it can run under qemu-aarch64 on a Linux host. Each must match exactly once. +AARCH64_ELF_CONFIG_EDITS = ( + ("#if defined(MLK_SYS_AARCH64) && defined(__ELF__)\n" + "#error \"mlkem-native AArch64 assembly has no BTI landing pads or GNU property note; AArch64 ELF builds are portable only.\"\n" + "#endif\n", ""), + ("(defined(MLK_SYS_AARCH64) && defined(MLK_SYS_AARCH64_NEON) && defined(MLK_SYS_APPLE))", + "(defined(MLK_SYS_AARCH64) && defined(MLK_SYS_AARCH64_NEON))"), +) +# The negative control: upstream's single-lane Keccak glue, which runs its +# assembly without asking the capability hook, in place of qbit's mirror. +UPSTREAM_X1_EDIT = ( + re.compile(r"#define MLK_USE_NATIVE_FIPS202_X1\n.*?#endif // !__ASSEMBLER__\n", re.S), + "#include \n", +) + + +def edited(source: Path, edits: tuple) -> str: + text = source.read_text(encoding="utf8") + for old, new in edits: + count = len(old.findall(text)) if isinstance(old, re.Pattern) else text.count(old) + if count != 1: + raise AssertionError( + f"FAIL: test edit matched {count} times in {source}\n" + f"Cause: {source.name} changed, so the test can no longer derive its AArch64 ELF build\n" + f"Fix: update the edits in {Path(__file__).name} to the new text: {old if isinstance(old, str) else old.pattern!r}") + text = old.sub(new, text) if isinstance(old, re.Pattern) else text.replace(old, new) + return text + + +class MlkemOverrideCoverageTest(unittest.TestCase): + """The portable override reaches every assembly routine of each native backend. + + Every global symbol the assembly object defines is wrapped (ld --wrap) by a + counter. A missed entry point would still give identical outputs, so only + counting the calls can show it. Each wrapper entry point is also run alone + on a fresh thread, which shows one that runs the library without taking + the override first. + + x86_64 runs on x86_64 hosts. AArch64 is cross-built as a static Linux + binary and run under qemu-aarch64; release builds enable it only on macOS, + so the test builds a copy of src/crypto/mlkem_config.h without its AArch64 + ELF refusal. CI requires the AArch64 tools through + MLKEM_POLICY_REQUIRED_COMPILERS (aarch64-linux-gnu-g++ and qemu-aarch64). + """ + + def build_harness(self, out: Path, *, cc: str, cxx: str, nm: str, args: int, overrides: dict[str, str], + link_flags: tuple[str, ...] = ()) -> Path: + """Compile the library, qbit's glue and the counting harness; return the binary.""" + src = REPO_ROOT / "src" + include = out / "include" + for name, text in overrides.items(): + (include / "crypto").mkdir(parents=True, exist_ok=True) + (include / "crypto" / name).write_text(text, encoding="utf8") + # Overridden glue headers come first; qbit's own files fill in the rest. + flags = ['-DMLK_CONFIG_FILE="crypto/mlkem_config.h"', "-DQBIT_MLKEM_NATIVE", f"-I{include}", f"-I{src}", + f"-I{src / 'mlkem-native'}", "-O1"] + + def compile_(compiler: str, source: Path, *extra: str) -> Path: + obj = out / (source.name + ".o") + result = run([compiler, *flags, *extra, "-c", str(source), "-o", str(obj)]) + self.assertEqual(result.returncode, 0, result.stderr) + return obj + + objects = [ + compile_(cc, src / "mlkem-native" / "mlkem" / "mlkem_native.c"), + compile_(cc, src / "mlkem-native" / "mlkem" / "mlkem_native_asm.S"), + compile_(cc, src / "crypto" / "mlkem_backend.c"), + ] + asm_symbols = sorted(line.split()[2] for line in run([nm, "--defined-only", "-g", str(objects[1])]).stdout.splitlines() + if len(line.split()) == 3 and line.split()[1] == "T") + self.assertGreater(len(asm_symbols), 0, "no assembly routines: is the native backend compiled?") + # Every routine takes only integer or pointer arguments, at most as many + # as the ABI passes in registers (args), so a generic forwarder keeps them. + params = ", ".join("void*" for _ in range(args)) + named = ", ".join(f"void* a{i}" for i in range(args)) + forwarded = ", ".join(f"a{i}" for i in range(args)) + wrappers = ["#include ", "#include ", "static std::atomic g_calls{0};", 'extern "C" {'] + for symbol in asm_symbols: + wrappers.append(f"uint64_t __real_{symbol}({params});") + wrappers.append(f"uint64_t __wrap_{symbol}({named}) {{ ++g_calls; return __real_{symbol}({forwarded}); }}") + wrappers += ["}", "unsigned long AsmCalls() { return g_calls.exchange(0); }"] + (out / "wrap.cpp").write_text("\n".join(wrappers) + "\n", encoding="utf8") + (out / "harness.cpp").write_text(COVERAGE_HARNESS, encoding="utf8") + for source in (src / "crypto" / "mlkem.cpp", src / "compat" / "cpu_features.cpp", src / "support" / "cleanse.cpp", + out / "harness.cpp", out / "wrap.cpp"): + objects.append(compile_(cxx, source, "-std=c++20", "-pthread")) + binary = out / "harness" + link = run([cxx, "-pthread", *link_flags, "-o", str(binary), *map(str, objects), + *(f"-Wl,--wrap={symbol}" for symbol in asm_symbols)]) + self.assertEqual(link.returncode, 0, link.stderr) + return binary + + def run_harness(self, command: list[str]) -> dict[str, str]: + result = run(command) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + values = dict(item.split("=", 1) for item in result.stdout.split()) + self.assertEqual(values["ok"], "1", result.stdout) + self.assertEqual(values["equal"], "1", "the backends disagree") + return values + + def check_coverage(self, values: dict[str, str], native: str, mirror_hint: str) -> None: + self.assertEqual(values["active"], native, values) + self.assertGreater(int(values["detected_calls"]), 0, values) + self.assertEqual(values["portable_calls"], "0", + f"FAIL: assembly ran {values['portable_calls']} times with portable C forced\n" + "Cause: a native entry point does not ask mlk_sys_check_capability\n" + f"Fix: mirror its dispatch glue with a capability check, {mirror_hint}") + fresh_detected = [int(count) for count in values["fresh_detected"].split(",")] + fresh_portable = [int(count) for count in values["fresh_portable"].split(",")] + for operation, native_calls, portable_calls in zip(OPERATIONS, fresh_detected, fresh_portable): + # Without native calls of its own, an operation's portable count proves nothing. + self.assertGreater(native_calls, 0, f"{operation} runs no assembly natively: {values}") + self.assertEqual(portable_calls, 0, + f"FAIL: mlkem::{operation} ran assembly {portable_calls} times on a fresh thread with portable C forced\n" + "Cause: it enters the library without BeginOperation(), so this thread's copy of the override is stale\n" + "Fix: call BeginOperation() first in every entry point of src/crypto/mlkem.cpp") + + def test_x86_64_override_reaches_every_assembly_routine(self) -> None: + if host_arch() != "x86_64": + self.skipTest("the x86_64 backend runs only on x86_64 hosts") + cc, cxx, nm = shutil.which("cc"), shutil.which("c++"), shutil.which("nm") + if not (cc and cxx and nm): + self.skipTest("cc, c++ and nm are required") + assert cc is not None and cxx is not None and nm is not None + with tempfile.TemporaryDirectory() as tmp: + binary = self.build_harness(Path(tmp), cc=cc, cxx=cxx, nm=nm, args=6, overrides={}) + values = self.run_harness([str(binary)]) + if values["active"] != "x86_64-avx2": + # A CPU without the extensions runs portable C only: nothing to count. + self.assertEqual(values["detected_calls"], "0", values) + self.assertEqual(values["portable_calls"], "0", values) + self.skipTest("this CPU cannot run the x86_64 backend") + self.check_coverage(values, "x86_64-avx2", "as for AArch64's x1 Keccak in src/crypto/mlkem_fips202_backend.h") + + def aarch64_tools(self) -> tuple[str, str, str, str]: + tools = [] + for executable in ("aarch64-linux-gnu-gcc", "aarch64-linux-gnu-g++", "aarch64-linux-gnu-nm", "qemu-aarch64"): + path = shutil.which(executable) + if path is None: + skip_missing_compiler(self, executable) + assert path is not None + tools.append(path) + return tools[0], tools[1], tools[2], tools[3] + + def test_aarch64_override_reaches_every_assembly_routine(self) -> None: + cc, cxx, nm, qemu = self.aarch64_tools() + src = REPO_ROOT / "src" + config = edited(src / "crypto" / "mlkem_config.h", AARCH64_ELF_CONFIG_EDITS) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "qbit" + out.mkdir() + binary = self.build_harness(out, cc=cc, cxx=cxx, nm=nm, args=8, overrides={"mlkem_config.h": config}, + link_flags=("-static",)) + values = self.run_harness([qemu, str(binary)]) + self.check_coverage(values, "aarch64-neon", + "as src/crypto/mlkem_fips202_backend.h does for upstream's x1_scalar.h") + + # Negative control: with upstream's x1 glue in place of qbit's + # mirror, forced portable runs leak assembly calls, and the count sees them. + upstream = Path(tmp) / "upstream-x1" + upstream.mkdir() + fips202 = edited(src / "crypto" / "mlkem_fips202_backend.h", (UPSTREAM_X1_EDIT,)) + binary = self.build_harness(upstream, cc=cc, cxx=cxx, nm=nm, args=8, + overrides={"mlkem_config.h": config, "mlkem_fips202_backend.h": fips202}, + link_flags=("-static",)) + control = self.run_harness([qemu, str(binary)]) + self.assertGreater(int(control["portable_calls"]), 0, + f"FAIL: upstream's x1 glue leaked no assembly calls with portable C forced: {control}\n" + "Cause: the counter no longer sees the x1 Keccak routine, so this test cannot catch a missing capability check\n" + "Fix: check that the --wrap list covers every global symbol of mlkem_native_asm.S") + + +if __name__ == "__main__": + unittest.main() diff --git a/ci/checks/test_pq_merge_gate_contract.py b/ci/checks/test_pq_merge_gate_contract.py new file mode 100755 index 0000000000..dc2096f769 --- /dev/null +++ b/ci/checks/test_pq_merge_gate_contract.py @@ -0,0 +1,591 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +"""Contract for the PQ jobs in Full Validation (#184 part 3, work item C3; #222). + +Pull requests that touch the ML-KEM library, the v2 transport, or their build +and CI wiring must pass the aarch64-unit, macos-arm64-unit and +macos-x86_64-unit jobs of ci-pq.yml before they merge; others skip all three. +Those, and the pull requests that can break hybrid post-quantum v2 transport +without touching what those jobs build, must also pass ci-pq.yml's +pq-functional job, the whole functional suite with hybrid transport. These +tests run the real pieces of that chain: + +* ci/checks/classify_merge_profile.py, as the classify-changes job runs it, + reports pq_unit_required and pq_functional_required; +* the pq-unit job of ci.yml calls ci-pq.yml with those three jobs on the + change's own commit (a pull request's merge commit) exactly when + pq_unit_required is true, and ci-pq.yml then runs exactly those three, so one + output and one call make each of them required exactly when the others are; + the pq-functional job of ci.yml does the same with pq-functional alone and + pq_functional_required; +* full-validation-gate's shell accepts a skipped call only when the classifier + says it is not required, and fails on a missing classifier output; +* a pull request's changed files reach each job's if and the gate's env + through classify-changes' outputs; +* Full Validation, the Required Merge Gate and Core Checks list the changed + files for the classifier with the same git commands, and every list of + changed files in those workflows names both sides of a rename; +* every CMake file, workflow or ci/ file that mentions ML-KEM classifies as + requiring the jobs, so new wiring cannot be missed silently, and every path + the classifier names exists; +* every document that a source check reads classifies as source, so that a + change to it alone runs the check. + +GitHub itself is not called. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +import unittest +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import classify_merge_profile +import test_scheduled_validation_contract as svc + +REPO_ROOT = svc.REPO_ROOT +CI_WORKFLOW = svc.WORKFLOW_DIR / "ci.yml" +PQ_WORKFLOW = svc.WORKFLOW_DIR / "ci-pq.yml" +GATE_WORKFLOW = svc.WORKFLOW_DIR / "required-merge-gate.yml" +CORE_CHECKS_WORKFLOW = svc.WORKFLOW_DIR / "core-checks.yml" +CLASSIFIER = REPO_ROOT / "ci" / "checks" / "classify_merge_profile.py" +CLASSIFY_JOB = "classify-changes" +CLASSIFY_STEP_ID = "classify" +PQ_JOB = "pq-unit" +FUNCTIONAL_JOB = "pq-functional" +GATE_JOB = "full-validation-gate" +GATE_STEP = "Check validation results" +AARCH64_JOB = "aarch64-unit" +# The ci-pq.yml jobs Full Validation requires: matrix entries of its unit job, +# and jobs of their own (the macOS ones and pq-functional), by the name the +# jobs input uses. +REQUIRED_PQ_JOBS = [AARCH64_JOB, "macos-arm64-unit", "macos-x86_64-unit"] +PQ_UNIT_MATRIX_JOB = "unit" +PQ_FUNCTIONAL_JOB = "pq-functional" +OUTPUT = "pq_unit_required" +FUNCTIONAL_OUTPUT = "pq_functional_required" +MERGE_SHA = "1234567890abcdef1234567890abcdef12345678" +HEAD_SHA = "fedcba0987654321fedcba0987654321fedcba09" +CHANGED_FILES_STEP = "Write changed file list" +# The workflows whose classify-changes job lists the changed files for the classifier. The Required Merge +# Gate trusts Core Checks Gate and Full Validation Gate for the checks only they run, so all three must list +# the same files: Core Checks seeing only the new side of a rename into public docs would skip all its jobs. +CHANGED_FILES_WORKFLOWS = (CI_WORKFLOW, GATE_WORKFLOW, CORE_CHECKS_WORKFLOW) +# Core Checks' release validator tests list the changed files again, for release paths. +RELEASE_TESTS_JOB = "release-validator-tests" +RELEASE_CHANGES_STEP = "Detect release file changes" +# Every list of changed files in those workflows known today, by workflow and (job, step): the classify-changes +# jobs, the Required Merge Gate's jobs that confirm a lightweight profile, and the release-file detector. +KNOWN_FILE_LISTS = { + CI_WORKFLOW: [(CLASSIFY_JOB, CHANGED_FILES_STEP)], + GATE_WORKFLOW: [(job_id, CHANGED_FILES_STEP) for job_id in ( + CLASSIFY_JOB, "release-policy-validation", "rpc-docs-validation", "public-docs-validation", + "github-metadata-validation")], + CORE_CHECKS_WORKFLOW: [(CLASSIFY_JOB, CHANGED_FILES_STEP), (RELEASE_TESTS_JOB, RELEASE_CHANGES_STEP)], +} +# A git command, and the options that make it list files. git diff, log and show detect renames unless given +# --no-renames; the plumbing commands only when asked to. +GIT_COMMAND_RE = re.compile(r"\bgit\s+(diff(?:-tree|-index|-files)?|log|show|whatchanged)\b[^\n]*") +FILE_LIST_OPTIONS = {"--name-only", "--name-status"} +RENAME_DETECTING_COMMANDS = {"diff", "log", "show", "whatchanged"} +RENAME_OPTIONS = ("-M", "-C", "--find-renames", "--find-copies") +# Renames that a changed-file list without --no-renames reduces to their new path, and what the classifier +# reports for both sides: out of the PQ unit jobs' paths, and out of source and release policy into public +# docs, whose profile alone runs no Core Checks job. +RENAMES = [ + ("src/crypto/mlkem.cpp", "src/crypto/kem/kyber.cpp", {OUTPUT: "true", FUNCTIONAL_OUTPUT: "true"}), + ("src/foo.cpp", "doc/user/foo.cpp", {"profile": "source", "source_validation_required": "true"}), + ("ci/release/mainnet_ci_posture.json", "doc/reference/mainnet_ci_posture.json", + {"profile": "source", "source_validation_required": "true"}), +] +# Markers that a source check reads from a document, with the check and a document known to have them. A +# document with one must classify as source, so that a change to it alone runs the check. +CHECKED_DOC_MARKERS = { + # In the lint job. + "\n```text\n(.*?)\n```", re.DOTALL) + +PQ_MLKEM1024 = 0xF0 +# Network magic in wire order (src/kernel/chainparams.cpp); bip324_tests checks it against the chain. +MAGIC = {"main": bytes.fromhex("444f24a8"), "regtest": bytes.fromhex("a66b1fda")} +# First application messages: a v2 short-id ping (18) and the matching pong (19). +PING = bytes([18]) + bytes.fromhex("0123456789abcdef") +PONG = bytes([19]) + bytes.fromhex("0123456789abcdef") + + +def load_reference(): + """Import the test framework's crypto modules from the source tree.""" + sys.path.insert(0, str(FUNCTIONAL_DIR)) + names = ("bip324_cipher", "ellswift", "hkdf", "mlkem", "secp256k1") + return {name: importlib.import_module(f"test_framework.crypto.{name}") for name in names} + + +def seed(label, size=32): + """Deterministic input bytes for a label.""" + out = b"" + counter = 0 + while len(out) < size: + out += hashlib.sha256(f"qbit pq transport vectors/{label}/{counter}".encode()).digest() + counter += 1 + return out[:size] + + +def tagged_hash(tag, data): + tag_hash = hashlib.sha256(tag.encode()).digest() + return hashlib.sha256(tag_hash + tag_hash + data).digest() + + +def make_key(ref, label): + """Deterministic private key and ElligatorSwift encoding of its public key.""" + secp, ellswift = ref["secp256k1"], ref["ellswift"] + priv = int.from_bytes(seed(f"{label}/privkey"), "big") % (secp.GE.ORDER - 1) + 1 + x = (priv * secp.G).x + counter = 0 + while True: + u_seed = seed(f"{label}/ellswift/{counter}", 33) + u = secp.FE(int.from_bytes(u_seed[:32], "big")) + t = ellswift.xswiftec_inv(x, u, u_seed[32] & 7) if u != 0 else None + if t is not None: + assert ellswift.xswiftec(u, t) == x + return priv.to_bytes(32, "big"), u.to_bytes() + t.to_bytes() + counter += 1 + + +def bip324_ecdh(ref, priv, ellswift_theirs, ellswift_ours, initiating): + """The BIP324 ECDH secret (what CKey::ComputeBIP324ECDHSecret returns).""" + x = ref["ellswift"].ellswift_ecdh_xonly(ellswift_theirs, priv) + pubkeys = ellswift_ours + ellswift_theirs if initiating else ellswift_theirs + ellswift_ours + return tagged_hash("bip324_ellswift_xonly_ecdh", pubkeys + x) + + +def record(ref, header, payload): + """One version-contents record: CompactSize(1 + len(payload)) || header || payload.""" + return ref["bip324_cipher"].ser_compact_size(1 + len(payload)) + bytes([header]) + payload + + +class Exchange: + """One side's view of a hybrid handshake, from the version contents onwards.""" + + def __init__(self, ref, ss_ecdh, ellswift_i, ellswift_r, contents_first, contents_second, ss_mlkem, magic): + cipher = ref["bip324_cipher"] + # The transcript always lists the responder's contents first; a misbehaving side may not. + self.transcript_input = cipher.hybrid_transcript_input(ellswift_i, ellswift_r, contents_first, contents_second) + self.transcript_hash = hashlib.sha256(self.transcript_input).digest() + self.keys = cipher.hybrid_derive_keys(ss_ecdh, ss_mlkem, self.transcript_hash, magic) + + def ciphers(self, ref, initiator): + return ref["bip324_cipher"].hybrid_ciphers(self.keys, initiator) + + +def first_packets(ref, exchange, initiator, contents): + """The key confirmation and the first application packet one side sends.""" + cipher = ref["bip324_cipher"] + send_l, send_p, _, _ = exchange.ciphers(ref, initiator) + confirmation = cipher.encrypt_packet(send_l, send_p, b"", ignore=True) + assert len(confirmation) == 20 + return confirmation, cipher.encrypt_packet(send_l, send_p, contents) + + +def receive(ref, exchange, initiator, packets): + """Decrypt packets in order as one side; return each result of decrypt_packet.""" + _, _, recv_l, recv_p = exchange.ciphers(ref, initiator) + return [ref["bip324_cipher"].decrypt_packet(recv_l, recv_p, packet) for packet in packets] + + +def make_vector(ref, index, chain, comment, shape): + mlkem, hkdf = ref["mlkem"], ref["hkdf"] + magic = MAGIC[chain] + priv_i, ellswift_i = make_key(ref, f"{index}/initiator") + priv_r, ellswift_r = make_key(ref, f"{index}/responder") + ss_ecdh = bip324_ecdh(ref, priv_i, ellswift_r, ellswift_i, initiating=True) + assert ss_ecdh == bip324_ecdh(ref, priv_r, ellswift_i, ellswift_r, initiating=False) + + # Today's BIP324 values, which the switch must leave alone (garbage terminators) or replace + # (session id). + salt_ecdh = b"bitcoin_v2_shared_secret" + magic + garbage_terminators = hkdf.hkdf_sha256(length=32, ikm=ss_ecdh, salt=salt_ecdh, info=b"garbage_terminators") + ecdh_session_id = hkdf.hkdf_sha256(length=32, ikm=ss_ecdh, salt=salt_ecdh, info=b"session_id") + + keygen_seed = seed(f"{index}/mlkem_keygen", mlkem.SEED_SIZE) + encaps_m = seed(f"{index}/mlkem_encaps", mlkem.MSG_SIZE) + ek, dk = mlkem.mlkem1024_keygen(keygen_seed) + assert mlkem.mlkem1024_check_ek(ek) + ss_mlkem, ct = mlkem.mlkem1024_encaps(ek, encaps_m) + assert mlkem.mlkem1024_decaps(dk, ct) == ss_mlkem + + offer = record(ref, PQ_MLKEM1024, ek) + accept = record(ref, PQ_MLKEM1024, ct) + assert offer[:4] == accept[:4] == bytes.fromhex("fd2106f0") and len(offer) == len(accept) == 1572 + if shape == "single": + contents_r, contents_i = offer, accept + elif shape == "extra_records": + # The first 0xF0 record wins, wherever it sits: the initiator encapsulates to the offer's ek + # and ignores the second 0xF0 record's (different, valid) ek. Unknown headers are ignored + # too, but the transcript covers every record of both contents. + other_ek, _ = mlkem.mlkem1024_keygen(seed(f"{index}/mlkem_keygen_duplicate", mlkem.SEED_SIZE)) + contents_r = offer + record(ref, PQ_MLKEM1024, other_ek) + record(ref, 0xF1, bytes.fromhex("0001")) + contents_i = record(ref, 0x01, b"qbit") + accept + else: + # A 65,535-byte unknown record: its record length (65,536) and the responder's contents + # length in the transcript (67,113) both take the 0xfe CompactSize form. + assert shape == "large_contents" + contents_r = offer + record(ref, 0xF1, bytes(65535)) + contents_i = accept + assert contents_r[1572] == 0xFE and ref["bip324_cipher"].ser_compact_size(len(contents_r))[0] == 0xFE + + exchange = Exchange(ref, ss_ecdh, ellswift_i, ellswift_r, contents_r, contents_i, ss_mlkem, magic) + if shape == "single": + assert len(exchange.transcript_input) == 3278 + init_confirmation, init_first = first_packets(ref, exchange, True, PING) + resp_confirmation, resp_first = first_packets(ref, exchange, False, PONG) + assert receive(ref, exchange, False, [init_confirmation, init_first]) == [(0, (True, b"")), (len(PING), (False, PING))] + assert receive(ref, exchange, True, [resp_confirmation, resp_first]) == [(0, (True, b"")), (len(PONG), (False, PONG))] + + vector = { + "comment": comment, + "chain": chain, + "magic": magic.hex(), + "initiator_privkey": priv_i.hex(), + "initiator_ellswift": ellswift_i.hex(), + "responder_privkey": priv_r.hex(), + "responder_ellswift": ellswift_r.hex(), + "ss_ecdh": ss_ecdh.hex(), + "ecdh_session_id": ecdh_session_id.hex(), + "initiator_garbage_terminator": garbage_terminators[:16].hex(), + "responder_garbage_terminator": garbage_terminators[16:].hex(), + "mlkem_keygen_seed": keygen_seed.hex(), + "mlkem_encaps_m": encaps_m.hex(), + "ek": ek.hex(), + "ct": ct.hex(), + "ss_mlkem": ss_mlkem.hex(), + "contents_responder": contents_r.hex(), + "contents_initiator": contents_i.hex(), + "transcript_input": exchange.transcript_input.hex(), + "transcript_hash": exchange.transcript_hash.hex(), + "salt": exchange.keys["salt"].hex(), + "ikm": exchange.keys["ikm"].hex(), + "prk": exchange.keys["prk"].hex(), + } + for label in ref["bip324_cipher"].HYBRID_KEY_LABELS: + vector[label] = exchange.keys[label].hex() + vector.update({ + "initiator_confirmation": init_confirmation.hex(), + "responder_confirmation": resp_confirmation.hex(), + "initiator_first_contents": PING.hex(), + "initiator_first_packet": init_first.hex(), + "responder_first_contents": PONG.hex(), + "responder_first_packet": resp_first.hex(), + }) + state = {"ss_ecdh": ss_ecdh, "ellswift_i": ellswift_i, "ellswift_r": ellswift_r, "dk": dk, "ct": ct, + "ss_mlkem": ss_mlkem, "contents_r": contents_r, "contents_i": contents_i, "magic": magic, + "exchange": exchange, "confirmations": {True: init_confirmation, False: resp_confirmation}} + return vector, state + + +def make_negative(ref, index, state, comment, initiator, contents_first, contents_second, ss_mlkem): + """One side derives from a different transcript or secret than its honest peer. + + Records what that side derives and sends, and what each side decodes from the other's key + confirmation: a nonzero length fails at once (reason length), a zero length on the tag.""" + bad = Exchange(ref, state["ss_ecdh"], state["ellswift_i"], state["ellswift_r"], contents_first, contents_second, + ss_mlkem, state["magic"]) + honest = state["exchange"] + assert bad.keys["session_id"] != honest.keys["session_id"] + confirmation, _ = first_packets(ref, bad, initiator, b"") + [(length, result)] = receive(ref, bad, initiator, [state["confirmations"][not initiator]]) + [(peer_length, peer_result)] = receive(ref, honest, not initiator, [confirmation]) + assert result is None and peer_result is None + negative = { + "comment": comment, + "vector": index, + "role": "initiator" if initiator else "responder", + "transcript_contents": [contents_first.hex(), contents_second.hex()], + "ss_mlkem": ss_mlkem.hex(), + "session_id": bad.keys["session_id"].hex(), + "confirmation": confirmation.hex(), + "decrypted_length": length, + "failure": "length" if length else "tag", + "peer_decrypted_length": peer_length, + "peer_failure": "length" if peer_length else "tag", + } + return negative + + +def make_confirmation_failure(ref, index, state, comment, receiver_is_initiator, packet): + """A first hybrid packet that fails the receiver's key confirmation check under the honest keys.""" + [(length, result)] = receive(ref, state["exchange"], receiver_is_initiator, [packet]) + if length: + failure = "length" + elif result is None: + failure = "tag" + else: + assert result == (False, b"") + failure = "not_decoy" + return { + "comment": comment, + "vector": index, + "receiver": "initiator" if receiver_is_initiator else "responder", + "packet": packet.hex(), + "decrypted_length": length, + "failure": failure, + } + + +def generate(): + ref = load_reference() + cipher, mlkem = ref["bip324_cipher"], ref["mlkem"] + vectors = [] + states = [] + for index, (chain, comment, shape) in enumerate(( + ("main", "Mainnet, one offer record and one accept record (the ordinary exchange).", "single"), + ("regtest", "Regtest, one offer record and one accept record.", "single"), + ("main", "Mainnet, extra records. The responder's contents are its offer, a second 0xf0 record with a " + "different valid ek, and an unknown 0xf1 record; the initiator encapsulates to the ek of the " + "first 0xf0 record and ignores the rest. The initiator's contents are an unknown 0x01 record " + "followed by its accept, which is still the first 0xf0 record there. The transcript covers every " + "record of both contents.", "extra_records"), + ("main", "Mainnet; the responder's contents are its offer followed by an unknown 0xf1 record with a " + "65,535-byte zero payload, so that record's length (65,536) and the responder's contents length " + "in the transcript (67,113) both use the 0xfe CompactSize form.", "large_contents"), + )): + vector, state = make_vector(ref, index, chain, comment, shape) + vectors.append(vector) + states.append(state) + + state = states[0] + bad_ct = bytes([state["ct"][0] ^ 0x01]) + state["ct"][1:] + bad_ss = mlkem.mlkem1024_decaps(state["dk"], bad_ct) + assert bad_ss != state["ss_mlkem"] + flipped_ss = bytes([state["ss_mlkem"][0] ^ 0xFF]) + state["ss_mlkem"][1:] + negatives = [ + make_negative(ref, 0, state, "Bit 0 of the ciphertext flipped in transit: the responder hashes the " + "damaged accept and decapsulation returns the implicit-rejection secret.", + False, state["contents_r"], record(ref, PQ_MLKEM1024, bad_ct), bad_ss), + make_negative(ref, 0, state, "The initiator's ML-KEM shared secret has byte 0 inverted after " + "encapsulation (a local fault); EK and CT are untouched.", + True, state["contents_r"], state["contents_i"], flipped_ss), + make_negative(ref, 0, state, "The responder hashes the two contents in the wrong order.", + False, state["contents_i"], state["contents_r"], state["ss_mlkem"]), + ] + + # Damaged first packets under the honest keys of vector 0, one for each confirmation failure reason. + init_confirmation = state["confirmations"][True] + resp_send_l, resp_send_p, _, _ = state["exchange"].ciphers(ref, False) + confirmation_failures = [ + make_confirmation_failure(ref, 0, state, "The initiator's confirmation with bit 0 of its first byte flipped: " + "the length decrypts to 1.", + False, bytes([init_confirmation[0] ^ 0x01]) + init_confirmation[1:]), + make_confirmation_failure(ref, 0, state, "The initiator's confirmation with bit 0 of its last tag byte flipped: " + "the length decrypts to 0 and the tag fails.", + False, init_confirmation[:-1] + bytes([init_confirmation[-1] ^ 0x01])), + make_confirmation_failure(ref, 0, state, "The responder's first hybrid packet is an authenticated empty packet " + "without the ignore bit.", + True, cipher.encrypt_packet(resp_send_l, resp_send_p, b"", ignore=False)), + ] + assert [failure["failure"] for failure in confirmation_failures] == ["length", "tag", "not_decoy"] + + document = { + "comment": [ + "Hybrid post-quantum BIP324 v2 transport: the ML-KEM-1024 key recipe under version-contents " + "header 0xf0, specified in doc/design/pq-transport.md.", + "Generated by contrib/devtools/generate-pq-transport-vectors.py from the Python reference in " + "test/functional/test_framework/crypto. Do not edit by hand.", + "Byte strings are lowercase hex. Packets are BIP324 packets: encrypted length (3) || encrypted " + "header and contents || tag (16). Each side's key confirmation is its hybrid packet 0, and its " + "first application packet is hybrid packet 1.", + "confirmation_failures are first hybrid packets that the named receiver must reject under the honest " + "keys of the vector; negative_vectors are sides that derived from different inputs than their peer.", + ], + "header": f"{PQ_MLKEM1024:02x}", + "salt_label": cipher.HYBRID_SALT_LABEL.decode(), + "labels": list(cipher.HYBRID_KEY_LABELS), + "vectors": vectors, + "confirmation_failures": confirmation_failures, + "negative_vectors": negatives, + } + return document + + +def check_spec(document): + """Return every value the specification quotes that differs from the vectors.""" + spec = SPEC.relative_to(ROOT) + blocks = SPEC_BLOCK.findall(SPEC.read_text(encoding="utf8")) + if not blocks: + return [f"{spec}: no quoted vector block found"] + errors = [] + for index, body in blocks: + if int(index) >= len(document["vectors"]): + errors.append(f"{spec}: quotes vector {index}, which does not exist") + continue + vector = document["vectors"][int(index)] + for line in body.splitlines(): + name, _, value = line.partition(" = ") + if vector.get(name) != value: + errors.append(f"{spec}: vector {index} quotes {name} = {value}, which does not match the vectors") + return errors + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="store_true", help=f"fail if {OUTPUT.relative_to(ROOT)} is stale") + args = parser.parse_args() + + document = generate() + text = json.dumps(document, indent=2) + "\n" + spec_errors = check_spec(document) + for error in spec_errors: + print(error, file=sys.stderr) + if args.check: + current = OUTPUT.read_text(encoding="utf8") if OUTPUT.exists() else None + if current != text: + print(f"{OUTPUT.relative_to(ROOT)} does not match the Python reference. If the recipe change is " + "intended, it needs a new version-contents header (doc/design/pq-transport.md); then run " + f"{Path(__file__).relative_to(ROOT)} to regenerate it.", file=sys.stderr) + sys.exit(1) + if spec_errors: + sys.exit(1) + print(f"{OUTPUT.relative_to(ROOT)} and the values {SPEC.relative_to(ROOT)} quotes match the Python reference.") + return + OUTPUT.write_text(text, encoding="utf8") + print(f"Wrote {OUTPUT.relative_to(ROOT)}") + if spec_errors: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/contrib/devtools/mlkem-native.pin b/contrib/devtools/mlkem-native.pin new file mode 100644 index 0000000000..5bef7bd4e1 --- /dev/null +++ b/contrib/devtools/mlkem-native.pin @@ -0,0 +1,8 @@ +# mlkem-native vendored into src/mlkem-native. Written by +# contrib/devtools/update-mlkem-native.sh; checked by +# test/lint/mlkem-native-check.sh. See doc/subtrees/mlkem-native.md. +url=https://github.com/pq-code-package/mlkem-native.git +tag=v2.0.0 +commit=d1b2fe782888bdb761a50336012923180be7f502 +mlkem_tree=d9d581290ea1e6fa37462bfbc61b9b4266a2e9e5 +license_blob=79c71b1fdcc53cc17b1df99a0f4d4d48121ac174 diff --git a/contrib/devtools/update-mlkem-native.sh b/contrib/devtools/update-mlkem-native.sh new file mode 100755 index 0000000000..34212b7c6e --- /dev/null +++ b/contrib/devtools/update-mlkem-native.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit. + +export LC_ALL=C +set -euo pipefail + +readonly PREFIX="src/mlkem-native" +readonly PIN_FILE="contrib/devtools/mlkem-native.pin" + +usage() { + cat <] + +Replace ${PREFIX} with the upstream mlkem/ directory and LICENSE of an +mlkem-native release, unmodified, rewrite ${PIN_FILE} from the fetched +upstream object ids, and stage both. + + tag or commit to vendor (default: REMOTE_REF, else the pinned tag) + +Environment overrides: + REMOTE_URL default: the url= entry of ${PIN_FILE} + REMOTE_REF default: the tag= entry of ${PIN_FILE} + +See doc/subtrees/mlkem-native.md. +EOF +} + +fail() { + local signature="$1" + local cause="$2" + local fix="$3" + echo "FAIL: ${signature}" >&2 + echo " Cause: ${cause}" >&2 + echo " Fix: ${fix}" >&2 + exit 1 +} + +pin_value() { + awk -F= -v key="$1" '$1 == key { print substr($0, length(key) + 2); exit }' "${PIN_FILE}" +} + +if [[ "${1-}" == "--help" || "${1-}" == "-h" ]]; then + usage + exit 0 +fi + +if [[ $# -gt 1 ]]; then + usage >&2 + exit 2 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + fail "not inside a git work tree" \ + "the update stages the vendored tree with git" \ + "run it from the root of a qbit checkout" +fi +cd "$(git rev-parse --show-toplevel)" + +pinned_url="$(pin_value url)" +pinned_tag="$(pin_value tag)" +pinned_commit="$(pin_value commit)" + +REMOTE_URL="${REMOTE_URL:-${pinned_url}}" +REMOTE_REF="${1:-${REMOTE_REF:-${pinned_tag}}}" +if [[ -z "${REMOTE_URL}" || -z "${REMOTE_REF}" ]]; then + fail "no upstream url or ref" \ + "${PIN_FILE} lacks url= or tag= and no override was given" \ + "restore ${PIN_FILE} from git, or set REMOTE_URL and pass a tag" +fi + +# Fetch into a temporary repository: a depth-1 fetch into this one would +# write .git/shallow and turn it into a shallow clone. +upstream_git="$(mktemp -d "${TMPDIR:-/tmp}/update-mlkem-native.XXXXXX")" +trap 'rm -rf -- "${upstream_git}"' EXIT +git init --quiet --bare "${upstream_git}" +upstream() { + git --git-dir="${upstream_git}" "$@" +} + +echo "Fetching mlkem-native ${REMOTE_REF} from ${REMOTE_URL}" +if ! upstream fetch --quiet --depth=1 --no-tags "${REMOTE_URL}" "${REMOTE_REF}"; then + fail "git fetch ${REMOTE_URL} ${REMOTE_REF} failed" \ + "the ref does not exist upstream, or the network is unavailable" \ + "check the tag name at ${REMOTE_URL}, or retry with network access" +fi +commit="$(upstream rev-parse "FETCH_HEAD^{commit}")" + +# A tag that resolves to a different commit than the pin means upstream moved +# it; never follow that silently. +if [[ "${REMOTE_REF}" == "${pinned_tag}" && "${commit}" != "${pinned_commit}" ]]; then + fail "${REMOTE_REF} resolves to ${commit}, but ${PIN_FILE} pins ${pinned_commit}" \ + "the upstream tag was moved or ${REMOTE_URL} is not the pinned repository" \ + "stop and investigate upstream; to vendor a different commit deliberately, pass it explicitly" +fi + +tag="${REMOTE_REF}" +if [[ "${REMOTE_REF}" =~ ^[0-9a-f]{40}$ ]]; then + tag="$(git ls-remote --tags "${REMOTE_URL}" | + awk -v c="${commit}" '$1 == c { sub("^refs/tags/", "", $2); sub("\\^\\{\\}$", "", $2); print $2; exit }')" + if [[ -z "${tag}" ]]; then + echo "warning: ${commit} has no upstream tag; releases must vendor a tagged commit" >&2 + tag="untagged" + fi +fi + +upstream_tree="$(upstream rev-parse "${commit}:mlkem")" +upstream_license="$(upstream rev-parse "${commit}:LICENSE")" + +echo "Replacing ${PREFIX} with ${commit}:mlkem and ${commit}:LICENSE" +rm -rf -- "${PREFIX}" +mkdir -p -- "${PREFIX}" +upstream archive --format=tar "${commit}" mlkem LICENSE | tar -xf - -C "${PREFIX}" + +cat > "${PIN_FILE}" <`; +`--pinned` takes the same `address:port`, as `getpeerinfo` shows it: + +``` +*/5 * * * * /opt/qbit/pq-canary/pq-canary-sample.sh --host pool --pinned 203.0.113.5:8333 --out /var/lib/qbit-canary -- /usr/local/bin/qbit-cli -datadir=/var/lib/qbit -rpcclienttimeout=30 +``` + +Control node (v1.0.0): the archive line with `--host control`. + +With a systemd timer instead of cron, run the same command from a oneshot +service and set `OnCalendar=*:0/5` on the timer. + +Each RPC call is also bounded by `timeout 60` when `timeout(1)` is installed, so +a hung node cannot pile up sampler runs. The sampler exits 0 for a complete +sample, 2 when `sample_ok=0`, and 1 on a usage error. + +## What a sample contains + +Each run appends one row to `samples.csv` (the header is written once) and, +when `getpqtransportinfo` answers, one line to `failures.jsonl`. + +`samples.csv` columns: + +- `time` (Unix seconds), `utc`, `host`, `uptime`, `version` (from `getnetworkinfo`); +- `sample_ok`: 1 when `uptime`, `getnetworkinfo` and, on the pool node, + `getpeerinfo` answered, else 0; +- from `getpqtransportinfo`: `pq_enabled`, `instance_id`, `arith_backend`, + `keccak_backend`, `since`, the inbound counters `in_switched` ... + `in_shed`, the outbound counters `out_switched` ... `out_fallback`, + `load_shedding_active`, and each ring's `*_ring_last_sequence` and + `*_ring_dropped`; +- `connections_in` and `connections_pq` from `getnetworkinfo`; +- on the pool node, from the `getpeerinfo` entry for the pinned address: + `pinned_present` (1 or 0), `pinned_connection_type`, `pinned_transport_pq` + and `pinned_transport_pq_status`. + +Booleans are written as 1 or 0. A failed call, or a field the node does not +report, is `NA`, never 0; a real 0 stays 0. So is a value of the wrong type: a +count that is not a non-negative integer (`true` is never written as 1), a flag +that is not a boolean, or an `instance_id` that is not 64 lowercase hex digits. A v1.0.0 node therefore writes `NA` +for every `getpqtransportinfo` column and for `connections_pq`, with +`sample_ok=1`. The CSV holds no peer addresses. + +`failures.jsonl` holds each sample's raw `recent_failures` and `fallback_set`, +with the sample time, host label, `instance_id` and `since`. It contains peer +addresses: keep it on the node, and run the evaluator there. + +## Known-good endpoints + +The failure figures only count entries between known-good endpoints: the canary +nodes and their pinned peers. Keep the list in a file on the evaluating node, +one entry per line: the endpoint, then an optional label (letters, digits, `.`, +`_`, `-`). `#` starts a comment. + +``` +# The archive node, as the pool node dials it. +203.0.113.5:8333 archive +# The pool node, as the archive node sees it: inbound entries carry the +# peer's source port, so list the address alone to match any port. +198.51.100.7 pool +# IPv6 entries go in brackets. Without a label this one is known-good-3. +[2001:db8::5]:8333 +``` + +The report names a known-good peer by its label and never prints an address, +so it can be posted. Unlabeled entries are `known-good-1`, `known-good-2`, ... +in file order. Errors about the list name the line, not the address. + +An IP address matches however it is written: `[2001:0db8:0:0:0:0:0:5]:8333` is +the same entry as `[2001:db8::5]:8333`, and `[::ffff:203.0.113.5]:8333` the same +as `203.0.113.5:8333`, as the node reports them. + +## Running the evaluator + +Figure 2 needs the failures of every monitored canary node, by default the +hosts of `--pool` and `--archive`: one node's file cannot pass it. Copy the +pool node's `failures.jsonl` to the archive node, or the other way round, over +a private channel between the two team nodes (for example `scp`), and run the +evaluator there. The CSV files hold no addresses. For example, on the archive +node: + +``` +pq-canary-eval.py --canary-start 2026-11-02T00:00:00Z \ + --pool pool/samples.csv --archive /var/lib/qbit-canary/samples.csv --control control/samples.csv \ + --failures pool/failures.jsonl --failures /var/lib/qbit-canary/failures.jsonl \ + --known-good /etc/qbit-canary/known-good.txt +``` + +Samples before `--canary-start` are the baseline (R0); the canary window runs +from there to `--end`, or to the latest sample. Other options: + +- `--baseline-ratio R` sets the archive/control connection ratio instead of + measuring it from the baseline. +- `--min-coverage S` (default 0.9) is the share of the expected 5-minute samples + that a figure, node or day needs before it is judged. +- `--monitored HOST,...` names the nodes whose failures must be present, instead + of the hosts of `--pool` and `--archive`. +- `--interval` (default 300 s) and `--restart-grace` (default 600 s). + +Unknown, misspelled or abbreviated options, malformed times or numbers, missing +files, `--failures` without `--known-good` (or the reverse) and an `--end` +before `--canary-start` are rejected with exit status 2 and a message. So are +samples that would compare a node with itself: `--pool`, `--archive` and +`--control` must each be one node, so a file given for two roles, two roles +with the same host label or `instance_id`, or a file whose rows carry more than +one host label (or none) is rejected. + +Output: one line per figure, `
: samples= coverage=
`, +where the result is `pass`, `fail` or `unknown`, followed by indented details, +then the verdict over figures 1, 2 and 4: + +1. **Pinned link**: the pool node's pinned peer is connected as `manual` with + `transport_pq` true in at least 99% of samples, excluding restarts. +2. **Failure entries**: no `malformed_record` or `first_packet_failed` entry, + no `fallback` entry and no fallback-set entry between known-good endpoints, + on every monitored node. Its details give each node's coverage. +3. **closed_after_switch**: such entries between known-good endpoints, listed + for triage; not part of the verdict. +4. **Inbound connections**: per UTC day, the archive node's `connections_in` + divided by the control's stays within ±20% of the baseline ratio, excluding + the 48 hours after a restart of either node. Each day shows its coverage. A + baseline in which either node's `connections_in` sums to 0 gives no usable + ratio, so the figure is unknown unless `--baseline-ratio` is given. + +The exit status is 0 when the verdict is pass, 1 on fail, 3 on unknown and 2 for +bad arguments or input. + +### How evidence is judged + +- **Restarts.** A jump in a node's boot time (`time - uptime`), or a new + `instance_id`, is a restart. For the pinned link, the samples from the last + sample before the restart to 10 minutes after it (`--restart-grace`) are a + gap: neither pass nor fail. The restarts are listed at the top of the report. + An uptime whose boot time differs from the one most samples with the same + `instance_id` agree on contradicts it: that uptime is unknown, not a restart. +- **Coverage.** Coverage is the share of the expected 5-minute samples that are + present and usable. Below `--min-coverage`, the pinned link, a monitored + node's failure evidence and an inbound-connections day are unknown, not + judged. +- **One sample per expected sample.** The expected samples are `--interval` + apart from `--canary-start`, and a sample stands for the expected one nearest + to it. However many samples stand for one expected sample (a repeated row, + overlapping files, a manual run beside cron), every figure counts it once, as + the least favorable of them: for the pinned link a miss, then unknown, then + excluded, then hybrid; for inbound connections unknown, then excluded, then a + measurement, where two measurements that disagree are unknown. A row + repeated exactly is one sample. Give `--canary-start` on the sampler's + 5-minute schedule, such as a whole hour, so that each sample is near the + expected sample it stands for. +- **Unknown, never clean.** A sample with `sample_ok=0`, an `NA` where the + figure needs a value, or a missing sample (for example while cron was not + running) is unknown. So is a malformed value: a `time`, `uptime` or + `connections_in` that is not a plain non-negative integer (a row without a + usable `time` counts as missing), a row with more or fewer fields than the + header (such as a line cut short by a crash), and a connected pinned peer + whose connection type, `transport_pq` or `transport_pq_status` is missing or + malformed, or whose `transport_pq` is not 1 exactly when the status is + `hybrid`. The pinned link passes only if it would pass with every + unknown sample counted as a failure, and fails only if it would fail with + every unknown sample counted as a success; otherwise it is unknown. +- **Rings.** Failure entries are read from each ring (`inbound`, `outbound`) by + its per-ring sequence. A ring holds the latest 256 entries, so it wraps: that + is fine as long as the entries read reach back to the sequence after the last + one already processed, and the ring stays judged. Entries are lost, and that + stretch is unknown for that ring, when a wrap between two samples evicted + entries never read (the oldest entry read is past that point), across a + changed `instance_id`, or when a ring is malformed or goes backwards. A flood + of inbound failures therefore cannot hide an outbound one: the rings are + separate. Unrelated endpoints never fail the canary, but only a well-formed + endpoint is unrelated: a `malformed_record`, `first_packet_failed`, + `fallback` or `closed_after_switch` entry whose endpoint is missing or + malformed makes its figure unknown. +- **Malformed evidence is unknown.** Only values of the `getpqtransportinfo` + types count as evidence. A record whose `instance_id` or `since` is missing + or malformed, whose `since` is after the sample, or whose `since` changed + within one `instance_id`; a ring that is not an object with non-negative + integer counters; a failure entry whose `outcome` is outside the vocabulary, + or whose `time` is missing, malformed, before its process started or more + than 5 minutes after the sample that read it; and a fallback-set entry + without a well-formed endpoint: each makes that stretch unknown. A + `failures.jsonl` line without a time in Unix seconds and a valid host label + is bad input (exit status 2). A known-good failure whose other fields are + malformed is still a failure, and those fields print as `malformed`, so a + malformed field can never put an address in the report. + +### When the pinned link is not manual + +If the pool node already has an automatic connection to the archive node's +address, `GetAddedNodeInfo` skips the address and the `-addnode` thread does +not open its manual connection, so the figure fails and the report says so. The +remedy: run `qbit-cli disconnectnode ` once on the pool +node; the `-addnode` thread reconnects it as manual within about 60 seconds. + +## Tests + +``` +python3 contrib/pq-canary/test/test_pq_canary.py +``` + +The Required Merge Gate runs them, and `test/functional/tool_pq_canary.py` runs +the sampler against a regtest node, and against v1.0.0 when previous releases +are downloaded. diff --git a/contrib/pq-canary/pq-canary-eval.py b/contrib/pq-canary/pq-canary-eval.py new file mode 100755 index 0000000000..1521acd9b1 --- /dev/null +++ b/contrib/pq-canary/pq-canary-eval.py @@ -0,0 +1,1012 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +"""Turn PQ canary samples into the R5 canary figures (#184). + +Each figure is printed with its sample count, its coverage and pass, fail or +unknown: + +1. the pool node's pinned link to the archive node is manual and hybrid in at + least 99% of samples, excluding restarts; +2. no malformed_record, first_packet_failed or fallback entry between + known-good endpoints, on every monitored canary node; +3. closed_after_switch entries between known-good endpoints, listed for triage + (not part of the canary verdict); +4. the canary archive node's connections_in stays within +-20% of the control + node's, relative to their baseline ratio, per UTC day, excluding the 48 hours + after a restart of either node. + +Evidence rules: +- A jump in a node's boot time (time - uptime) is a restart. The samples from + the last sample before it to the end of the grace period after it are a gap, + not a failure. An uptime whose boot time contradicts the one most samples of + the same instance_id agree on is unknown, not a restart. +- Coverage is the share of the expected 5-minute samples that are present and + usable. A figure, node or day below --min-coverage (default 90%) is unknown, + not judged. +- The expected samples are --interval apart from --canary-start (so give one on + the sampler's schedule, such as a whole hour), and a sample stands for the + expected one nearest to it. Every figure counts an expected sample once, + however many samples stand for it: several count as the least favorable of + them, so a repeated row, overlapping files or an extra sampler run never add + evidence. A row repeated exactly is one sample. +- Failure rings are read by their per-ring sequence. A ring that wrapped is + still complete when the entries read continue from the last sequence already + processed; entries are lost only when the oldest entry read is past that point + (a wrap between samples that hid unread entries), and across a changed + instance_id. A lost stretch makes that interval unknown for that ring. +- Missing evidence is unknown, never clean: the pinned link passes only if it + would pass with every unknown sample counted against it. A malformed value is + missing: a time, uptime or connections_in that is not a plain non-negative + integer, a samples.csv row with the wrong number of fields, and a connected + pinned peer whose fields are malformed or disagree. +- Only well-formed evidence is clean. A malformed ring, a record whose + instance_id or since is missing, malformed or contradictory, a failure entry + whose outcome, time or endpoint is missing, malformed or implausible, and a + fallback-set entry without a well-formed endpoint make that stretch unknown. + A failure entry is implausible when it is dated before its process started or + more than CLOCK_SLACK after the sample that read it. +- The report names known-good peers by their label, never by address; peer + addresses stay in failures.jsonl and the known-good list on the node. +- --pool, --archive and --control are three different nodes: samples that share + a file, a host label or an instance_id, or one file with several host labels, + are rejected as bad input rather than compared with themselves. + +Exit status: 0 when the verdict (figures 1, 2 and 4) is pass, 1 when any of +them fails, 3 when none fails but one is unknown, 2 for bad arguments or input. +""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import re +import sys +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from fractions import Fraction +from pathlib import Path +from typing import Any, Iterable, TypeGuard + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import pq_canary # noqa: E402 + +PASS, FAIL, UNKNOWN = "pass", "fail", "unknown" +PINNED_THRESHOLD = 0.99 +CONNECTIONS_TOLERANCE = Fraction(1, 5) +CONNECTIONS_RESTART_EXCLUSION = 48 * 3600 +DEFAULT_MIN_COVERAGE = 0.90 +RESTART_TOLERANCE = 120 # seconds of boot-time jitter that is not a restart +MONITORED_OUTCOMES = {"malformed_record", "first_packet_failed", "fallback"} +TRIAGE_OUTCOMES = {"closed_after_switch"} +OUTCOMES = MONITORED_OUTCOMES | TRIAGE_OUTCOMES | {"legacy_peer", "abandoned", "internal_error"} +# Seconds a node's own times may lead the sample time: the sampler takes the +# time, then makes up to three RPC calls of at most 60 s each. +CLOCK_SLACK = 300 +MAX_TIME = 253402300799 # 9999-12-31T23:59:59Z, the latest time the report can print +RINGS = ("inbound", "outbound") +TRANSPORT_STATUSES = {"pending", "hybrid", "legacy_peer", "fallback", "off", "v1"} # getpeerinfo transport_pq_status +ENDPOINT_KINDS = {"address", "name_proxy"} +NETWORKS = {"ipv4", "ipv6", "onion", "i2p", "cjdns", "name_proxy"} +LABEL = re.compile(r"[A-Za-z0-9._-]+") # use with fullmatch +TOKEN = re.compile(r"[a-z_-]{1,40}") # a vocabulary word, such as a reason or a connection type +REMEDY = ("an automatic connection held the pinned address in {count} sample(s), so the -addnode thread " + "(GetAddedNodeInfo) skipped it. Remedy: run `qbit-cli disconnectnode ` once; " + "the -addnode thread reconnects it as manual within about 60 s.") + + +def parse_time(text: str) -> int: + """Epoch seconds from Unix seconds or an ISO 8601 time such as 2026-10-20T00:00:00Z (UTC unless it says otherwise).""" + text = text.strip() + if re.fullmatch(r"[0-9]+", text): + value = int(text) + else: + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + raise ValueError(f"expected Unix seconds or an ISO 8601 time such as 2026-11-02T00:00:00Z, got {text!r}") from None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + value = int(parsed.timestamp()) + if not is_time(value): + raise ValueError(f"expected a time from 1970 to 9999, got {text!r}") + return value + + +def utc(epoch: int | float) -> str: + return datetime.fromtimestamp(epoch, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def is_count(value: Any) -> TypeGuard[int]: + """Whether a JSON value is a non-negative integer (not a boolean).""" + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def is_time(value: Any) -> TypeGuard[int]: + """Whether a JSON value is Unix seconds that the report can print.""" + return is_count(value) and value <= MAX_TIME + + +def when(value: Any) -> str: + return utc(value) if is_time(value) else "malformed" + + +def token(value: Any) -> str: + """A vocabulary word for the report, or "malformed": a malformed value may hold an address.""" + return value if isinstance(value, str) and TOKEN.fullmatch(value) else "malformed" + + +def number(value: Any) -> str: + return str(value) if is_count(value) else "malformed" + + +def as_count(value: str | None) -> int | None: + """A CSV value that is a plain non-negative decimal integer, else None.""" + return int(value) if value is not None and re.fullmatch(r"[0-9]{1,18}", value) else None + + +def percent(share: float | None) -> str: + return "n/a" if share is None else f"{100 * share:.1f}%" + + +def expected_samples(begin: int, end: int, interval: int, phase: int | None = None) -> int: + """Samples expected in [begin, end] at phase + k * interval (phase defaults to begin).""" + phase = begin if phase is None else phase + first = phase + -(-(begin - phase) // interval) * interval + return 0 if end < first else (end - first) // interval + 1 + + +@dataclass +class Figure: + name: str + result: str + samples: int + coverage: float | None + summary: str = "" + details: list[str] = field(default_factory=list) + + +@dataclass +class Window: + start: int + end: int + + def contains(self, time: int) -> bool: + return self.start <= time <= self.end + + def overlaps(self, begin: int, finish: int) -> bool: + return begin <= self.end and finish >= self.start + + +def slot_of(time: int, window: Window, interval: int) -> int | None: + """The expected sample window.start + k * interval that a sample at time stands for, as k, or None. + + A sample stands for the expected sample nearest to it; k < 0 is before the + window (the connections baseline). A sample on the other side of the + window's start or end from that expected sample stands for none. + """ + slot = (time - window.start + interval // 2) // interval + if (slot < 0) != (time < window.start) or time > window.end or slot > (window.end - window.start) // interval: + return None + return slot + + +# A sample's state, from the most to the least favorable: several samples that stand for one +# expected sample count as the least favorable of them. Figure 1 uses all four; figure 4 the +# first three, where the first is a connections_in measurement rather than a hybrid link. +HYBRID = MEASURED = 0 +EXCLUDED, UNUSABLE, NOT_HYBRID = 1, 2, 3 + + +# --------------------------------------------------------------------------- +# Restarts +# --------------------------------------------------------------------------- + + +@dataclass +class Restart: + last_seen: int # time of the last sample of the previous run + boot: int # boot time of the new run + + +Row = dict[str, str | None] # a samples.csv row, with NA as None + + +def sample_rows(rows: Iterable[Row]) -> list[tuple[int, Row]]: + """The rows that are samples with their times, in time order, with the values the figures read checked. + + A row without a time in Unix seconds is not a sample: it counts as missing. + A row repeated exactly, as in overlapping or concatenated files, is one + sample, so repeating rows cannot outvote the others' boot time. + An uptime that is not a non-negative integer, or whose boot time (time - + uptime) contradicts the boot time of its instance_id, becomes None: unknown, + never a restart. A process's boot time is the median over its samples, so + neither a malformed uptime nor a restart between the sampler's uptime and + getpqtransportinfo calls (the old uptime with the new instance_id) moves it. + """ + timed = [] + seen: set[frozenset[tuple[str, str | None]]] = set() + for row in rows: + time, key = as_count(row["time"]), frozenset(row.items()) + if is_time(time) and key not in seen: + seen.add(key) + timed.append((time, row)) + timed.sort(key=lambda item: item[0]) + boots: dict[str, list[int]] = defaultdict(list) + for time, row in timed: + uptime, instance = as_count(row["uptime"]), row["instance_id"] + if uptime is not None and valid_instance_id(instance): + boots[instance].append(time - uptime) + boot_of = {instance: sorted(values)[len(values) // 2] for instance, values in boots.items()} + checked = [] + for time, row in timed: + row = dict(row) + uptime, instance = as_count(row["uptime"]), row["instance_id"] + if uptime is not None and valid_instance_id(instance) and abs(time - uptime - boot_of[instance]) > RESTART_TOLERANCE: + uptime = None + row["time"], row["uptime"] = str(time), None if uptime is None else str(uptime) + checked.append((time, row)) + return checked + + +def restarts(rows: list[Row]) -> list[Restart]: + """Restarts seen as a jump in boot time (time - uptime) or a new instance_id.""" + found = [] + previous: tuple[int, int, str | None] | None = None + for time, row in sample_rows(rows): + uptime = as_count(row["uptime"]) + if uptime is None: + continue + boot, instance = time - uptime, row["instance_id"] + if previous is not None: + last_time, last_boot, last_instance = previous + new_instance = instance is not None and last_instance is not None and instance != last_instance + if boot > last_boot + RESTART_TOLERANCE or new_instance: + found.append(Restart(last_seen=last_time, boot=boot)) + previous = (time, boot, instance) + return found + + +def merged_gaps(node_restarts: Iterable[Restart], grace: int) -> list[tuple[int, int]]: + """Restart gaps as merged (start, end] intervals.""" + merged: list[tuple[int, int]] = [] + for low, high in sorted((restart.last_seen, restart.boot + grace) for restart in node_restarts): + if merged and low <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], high)) + else: + merged.append((low, high)) + return merged + + +def missing_samples(after: int, before: int, start: int, interval: int, gaps: list[tuple[int, int]]) -> tuple[int, int]: + """(unknown, excluded) counts of the expected samples start + k * interval with after < k < before.""" + count = before - after - 1 + if count <= 0: + return 0, 0 + excluded = 0 + for low, high in gaps: + # Expected times inside (low, high]. + first = max(after + 1, (low - start) // interval + 1) + last = min(before - 1, (high - start) // interval) + excluded += max(0, last - first + 1) + return count - excluded, excluded + + +# --------------------------------------------------------------------------- +# Figure 1: the pinned link +# --------------------------------------------------------------------------- + + +def pinned_peer_ok(row: Row) -> bool: + """Whether a connected pinned peer's fields are well formed and agree: transport_pq is 1 + exactly when transport_pq_status is hybrid.""" + connection_type, transport_pq, status = (row[name] for name in + ("pinned_connection_type", "pinned_transport_pq", "pinned_transport_pq_status")) + return (connection_type is not None and TOKEN.fullmatch(connection_type) is not None and transport_pq in ("0", "1") + and status in TRANSPORT_STATUSES and (transport_pq == "1") == (status == "hybrid")) + + +def evaluate_pinned(pool: list[Row] | None, archive: list[Row] | None, + window: Window, interval: int, grace: int, min_coverage: float) -> Figure: + name = "pinned link manual and hybrid in >= 99% of samples, excluding restarts" + if not pool: + return Figure(name, UNKNOWN, 0, None, "no pool node samples") + gaps = merged_gaps(restarts(pool) + (restarts(archive) if archive else []), grace) + # Each expected sample once: several samples for it count as the least favorable. + states: dict[int, int] = {} + automatic: set[int] = set() + for time, row in sample_rows(pool): + slot = slot_of(time, window, interval) + if slot is None or slot < 0: + continue + uptime = as_count(row["uptime"]) + if any(low < time <= high for low, high in gaps) or (uptime is not None and uptime < grace): + state = EXCLUDED + elif row["sample_ok"] != "1" or row["pinned_present"] not in ("0", "1"): + state = UNUSABLE + elif row["pinned_present"] == "0": + state = NOT_HYBRID + elif not pinned_peer_ok(row): + state = UNUSABLE + elif row["pinned_connection_type"] == "manual" and row["pinned_transport_pq"] == "1": + state = HYBRID + else: + state = NOT_HYBRID + if row["pinned_connection_type"] != "manual": + automatic.add(slot) + states[slot] = max(states.get(slot, state), state) + good, excluded, unknown, bad = (sum(state == value for state in states.values()) + for value in (HYBRID, EXCLUDED, UNUSABLE, NOT_HYBRID)) + # Expected samples without a sample are unknown, or excluded inside a restart gap. + previous = -1 + for slot in sorted(states) + [(window.end - window.start) // interval + 1]: + missing, skipped = missing_samples(previous, slot, window.start, interval, gaps) + unknown += missing + excluded += skipped + previous = slot + total = good + bad + unknown + if total == 0: + return Figure(name, UNKNOWN, 0, None, f"no samples outside restarts (excluded={excluded})") + coverage = (good + bad) / total + if coverage < min_coverage: + result = UNKNOWN + elif good / total >= PINNED_THRESHOLD: + result = PASS + elif (good + unknown) / total < PINNED_THRESHOLD: + result = FAIL + else: + result = UNKNOWN + figure = Figure(name, result, total, coverage, + f"hybrid={good} not_hybrid={bad} unknown={unknown} excluded={excluded} ({100 * good / total:.2f}% hybrid)") + if automatic: + figure.details.append(REMEDY.format(count=len(automatic))) + return figure + + +# --------------------------------------------------------------------------- +# Figures 2 and 3: failure entries between known-good endpoints +# --------------------------------------------------------------------------- + + +def canonical_address(address: str) -> str: + """An IP address in one spelling, compressed, and an IPv4-mapped IPv6 address + as IPv4, as the node reports it; any other address lower-cased.""" + address = address.strip().lower() + try: + ip = ipaddress.ip_address(address) + except ValueError: + return address + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: + ip = ip.ipv4_mapped + return str(ip) + + +def endpoint_key(address: str, port: Any) -> str: + address = canonical_address(address) + return f"[{address}]:{port}" if ":" in address else f"{address}:{port}" + + +def valid_endpoint(endpoint: Any) -> bool: + """Whether endpoint has the getpqtransportinfo Endpoint shape: a kind and network that + agree, an address of that network without a port, and a port from 0 to 65535.""" + if not isinstance(endpoint, dict): + return False + kind, network, address, port = (endpoint.get(key) for key in ("kind", "network", "address", "port")) + if not (isinstance(kind, str) and isinstance(network, str) and isinstance(address, str)): + return False + if not is_count(port) or port > 65535: + return False + if kind not in ENDPOINT_KINDS or network not in NETWORKS or (kind == "name_proxy") != (network == "name_proxy"): + return False + if network in ("ipv4", "ipv6", "cjdns"): + try: + return ipaddress.ip_address(address).version == (4 if network == "ipv4" else 6) + except ValueError: + return False + return LABEL.fullmatch(address) is not None + + +class KnownGood: + """Known-good endpoints, each with a label that the report prints instead of the address. + + An entry is address:port, or an address alone to match any port: inbound + failures carry the peer's source port, which changes per connection. + """ + + def __init__(self, endpoints: dict[str, str], addresses: dict[str, str]) -> None: + self.endpoints = endpoints # address:port -> label + self.addresses = addresses # address -> label + + def __bool__(self) -> bool: + return bool(self.endpoints or self.addresses) + + def label(self, endpoint: Any) -> str | None: + """The label of a known-good endpoint, else None.""" + if not isinstance(endpoint, dict) or not isinstance(endpoint.get("address"), str): + return None + key = endpoint_key(endpoint["address"], endpoint.get("port")) + return self.endpoints.get(key) or self.addresses.get(canonical_address(endpoint["address"])) + + +def read_known_good(path: Path) -> KnownGood: + """One endpoint per line, then an optional label: address:port, [ipv6]:port, an address or [ipv6]. + + # starts a comment. Unlabeled entries are known-good-1, known-good-2, ... in file + order. An IP address matches however it is written. Errors name the line, never + its address. + """ + endpoints: dict[str, str] = {} + addresses: dict[str, str] = {} + count = 0 + for number, raw in enumerate(path.read_text(encoding="utf8").splitlines(), 1): + fields = raw.split("#", 1)[0].split() + if not fields: + continue + if len(fields) > 2: + raise ValueError(f"{path}:{number}: expected an endpoint and at most one label") + line = fields[0].lower() + if line.startswith("["): + address, close, rest = line[1:].partition("]") + port = rest[1:] if rest.startswith(":") else None + if not close or (rest and port is None): + raise ValueError(f"{path}:{number}: expected [ipv6] or [ipv6]:port") + elif line.count(":") == 1: + address, _, port = line.partition(":") + elif ":" in line: + raise ValueError(f"{path}:{number}: write an IPv6 endpoint as [address] or [address]:port") + else: + address, port = line, None + if not address or (port is not None and not (port.isdigit() and 0 < int(port) < 65536)): + raise ValueError(f"{path}:{number}: expected address or address:port with a port from 1 to 65535") + count += 1 + label = fields[1] if len(fields) == 2 else f"known-good-{count}" + if not LABEL.fullmatch(label): + raise ValueError(f"{path}:{number}: a label is letters, digits, '.', '_' or '-'") + target = addresses if port is None else endpoints + key = canonical_address(address) if port is None else endpoint_key(address, int(port)) + if key in target: + raise ValueError(f"{path}:{number}: endpoint listed twice") + target[key] = label + return KnownGood(endpoints, addresses) + + +def valid_instance_id(value: Any) -> TypeGuard[str]: + """Whether value is the 64-hex-digit instance_id that getpqtransportinfo reports.""" + return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None + + +def ring_of(record: dict[str, Any], direction: str) -> dict[str, Any] | None: + """A structurally valid ring: non-negative integer counters and entries with consecutive + sequences from 1 up, ending at last_sequence.""" + rings = record.get("recent_failures") + ring = rings.get(direction) if isinstance(rings, dict) else None + if not isinstance(ring, dict) or not isinstance(ring.get("entries"), list): + return None + last = ring.get("last_sequence") + if not is_count(last) or not is_count(ring.get("dropped")): + return None + sequences: list[Any] = [entry.get("sequence") if isinstance(entry, dict) else None for entry in ring["entries"]] + if any(not is_count(sequence) or sequence == 0 for sequence in sequences): + return None + if any(after != before + 1 for before, after in zip(sequences, sequences[1:])): + return None + if sequences and sequences[-1] != last: + return None + return ring + + +def identity_problem(record: dict[str, Any], started: dict[str, int]) -> str | None: + """Why a record's process identity, its instance_id and since, cannot be used, or None. + + started holds the since of each instance_id seen so far. + """ + instance, since = record.get("instance_id"), record.get("since") + if not valid_instance_id(instance): + return "instance_id missing or malformed" + if not is_time(since) or since > record["time"] + CLOCK_SLACK: + return "since missing, malformed or after the sample" + if started.setdefault(instance, since) != since: + return "since changed within one instance_id" + return None + + +@dataclass +class RingState: + instance: Any + last_sequence: int + dropped: int + time: int + + +def unknown_intervals(records: list[dict[str, Any]], end: int, interval: int) -> list[tuple[str, str, int, int, str]]: + """(host, ring, begin, end, reason) for every stretch whose failure entries may be incomplete.""" + found = [] + by_host: dict[str, list[dict[str, Any]]] = defaultdict(list) + for record in records: + by_host[str(record.get("host"))].append(record) + for host, host_records in by_host.items(): + host_records.sort(key=lambda record: record["time"]) + state: dict[str, RingState] = {} + started: dict[str, int] = {} # instance_id -> since + previous_time: int | None = None + # Time and reason of the latest record without a usable process identity, until a usable one follows. + identity_lost: tuple[int, str] | None = None + for record in host_records: + time = record["time"] + if not isinstance(record.get("fallback_set"), list): + # Missing fallback evidence is unknown, never an empty set. + found.append((host, "fallback_set", time if previous_time is None else previous_time, time, + "fallback_set missing or malformed")) + problem = identity_problem(record, started) + if problem is not None: + # Without the process identity a restart can't be told apart from + # a continuing run, so neither ring is judged across this record. + for direction in RINGS: + found.append((host, direction, time if previous_time is None else previous_time, time, problem)) + state.clear() + identity_lost = (time, problem) + previous_time = time + continue + if identity_lost is not None: + for direction in RINGS: + found.append((host, direction, identity_lost[0], time, identity_lost[1])) + identity_lost = None + instance, since = record["instance_id"], record["since"] + for direction in RINGS: + ring = ring_of(record, direction) + before = state.get(direction) + if ring is None: + found.append((host, direction, before.time if before else time, time, "malformed ring")) + continue + entries = ring["entries"] + oldest_sequence = entries[0]["sequence"] if entries else ring["last_sequence"] + 1 + oldest_time = entries[0]["time"] if entries and is_time(entries[0].get("time")) else time + if before is None or before.instance != instance: + if before is not None: + found.append((host, direction, before.time, max(before.time, since), + "instance_id changed; the previous run's later entries are lost")) + if ring["last_sequence"] > 0 and oldest_sequence > 1: + found.append((host, direction, since, max(since, oldest_time), + f"sequences 1..{oldest_sequence - 1} were dropped before the first sample")) + elif ring["last_sequence"] < before.last_sequence or ring["dropped"] < before.dropped: + found.append((host, direction, before.time, time, "last_sequence or dropped went backwards")) + elif ring["last_sequence"] == before.last_sequence: + if ring["dropped"] != before.dropped: + found.append((host, direction, before.time, time, "dropped changed without new entries")) + elif oldest_sequence > before.last_sequence + 1: + # The ring wrapped past entries never read: they lie between the + # previous sample and the oldest entry still held. + found.append((host, direction, before.time, max(before.time, oldest_time), + f"sequences {before.last_sequence + 1}..{oldest_sequence - 1} were lost when the ring wrapped")) + state[direction] = RingState(instance, ring["last_sequence"], ring["dropped"], time) + previous_time = time + if host_records and host_records[-1]["time"] < end - 2 * interval: + for direction in RINGS: + found.append((host, direction, host_records[-1]["time"], end, "no samples since")) + return found + + +def fingerprint(value: Any) -> str: + """A hashable stand-in for a JSON value.""" + return json.dumps(value, sort_keys=True) + + +def describe(host: str, direction: str, entry: dict[str, Any], label: str) -> str: + return (f"{utc(entry['time'])} {host} {direction} {entry['outcome']}/{token(entry.get('reason'))} " + f"conn_type={token(entry.get('connection_type'))} peer_id={number(entry.get('peer_id'))} known_good={label}") + + +def evaluate_failures(records: list[dict[str, Any]] | None, known_good: KnownGood | None, monitored: list[str], + window: Window, interval: int, min_coverage: float) -> tuple[Figure, Figure]: + monitored_name = "no malformed_record, first_packet_failed or fallback entry between known-good endpoints" + triage_name = "closed_after_switch entries between known-good endpoints (triage, not in the verdict)" + + def both_unknown(reason: str) -> tuple[Figure, Figure]: + return Figure(monitored_name, UNKNOWN, 0, None, reason), Figure(triage_name, UNKNOWN, 0, None, reason) + + if records is None: + return both_unknown("no failures files") + if not known_good: + return both_unknown("no known-good endpoint list") + if not monitored: + return both_unknown("no monitored canary nodes named (--monitored, or the hosts of --pool and --archive)") + + # Every monitored node must have its own evidence: one node's file cannot pass the figure. + expected = expected_samples(window.start, window.end, interval) + slots: dict[str, set[int]] = defaultdict(set) + for record in records: + slot = slot_of(record["time"], window, interval) + if slot is not None and slot >= 0: + slots[str(record.get("host"))].add(slot) + coverage = {host: min(1.0, len(slots[host]) / expected) if expected else 0.0 for host in monitored} + low = [host for host in monitored if coverage[host] < min_coverage] + + seen: set[tuple[Any, ...]] = set() + found_monitored: list[str] = [] + found_triage: list[str] = [] + # Entries that may be between known-good endpoints but cannot be told apart, per figure. + unknown_monitored: list[str] = [] + unknown_triage: list[str] = [] + for record in records: + host = str(record.get("host")) + instance, since = record.get("instance_id"), record.get("since") + for direction in RINGS: + ring = ring_of(record, direction) + for entry in (ring or {}).get("entries", []): + key: tuple[Any, ...] = (host, instance if valid_instance_id(instance) else None, direction, entry["sequence"]) + if key in seen: + continue + seen.add(key) + outcome, time = entry.get("outcome"), entry.get("time") + outcome = outcome if isinstance(outcome, str) else None + # The figures the entry may count towards, as (found, unknown); an + # entry with a malformed outcome may belong to either. + if outcome in MONITORED_OUTCOMES: + targets = [(found_monitored, unknown_monitored)] + elif outcome in TRIAGE_OUTCOMES: + targets = [(found_triage, unknown_triage)] + elif outcome in OUTCOMES: + continue + else: + targets = [(found_monitored, unknown_monitored), (found_triage, unknown_triage)] + # An entry is recorded after its process started and before the sample that read it. + earliest = since - CLOCK_SLACK if is_time(since) else 0 + if not is_time(time) or time > record["time"] + CLOCK_SLACK or time < earliest: + if not window.overlaps(earliest, record["time"] + CLOCK_SLACK): + continue # whenever it was recorded, it was outside the window + problem = f"unknown {host} {direction}: entry {entry['sequence']} has a missing or implausible time" + elif not window.contains(time): + continue + elif outcome not in OUTCOMES: + problem = f"unknown {host} {direction} {utc(time)}: entry {entry['sequence']} has a missing or malformed outcome" + else: + label = known_good.label(entry.get("endpoint")) + if label is not None: + targets[0][0].append(describe(host, direction, entry, label)) + continue + if valid_endpoint(entry.get("endpoint")): + continue + # Not a valid unrelated peer either: the entry may be between known-good endpoints. + problem = (f"unknown {host} {direction} {utc(time)}: {outcome} entry {entry['sequence']} " + "has a missing or malformed endpoint") + for _, unknown in targets: + unknown.append(problem) + if not window.contains(record["time"]): + continue + fallback_set = record.get("fallback_set") + for item in fallback_set if isinstance(fallback_set, list) else []: + endpoint = item.get("endpoint") if isinstance(item, dict) else None + label = known_good.label(endpoint) + if label is None: + key = (host, "fallback_set", fingerprint(item)) + if not valid_endpoint(endpoint) and key not in seen: + seen.add(key) + unknown_monitored.append(f"unknown {host} fallback_set {utc(record['time'])}: " + "an entry without a well-formed endpoint") + continue + key = (host, "fallback_set", label, fingerprint(item.get("entered"))) + if key not in seen: + seen.add(key) + found_monitored.append(f"{when(item.get('entered'))} {host} fallback_set cause={token(item.get('cause'))}/" + f"{token(item.get('reason'))} streak={number(item.get('streak'))} " + f"expires={when(item.get('expires'))} known_good={label}") + + gaps = [gap for gap in unknown_intervals(records, window.end, interval) if window.overlaps(gap[2], gap[3])] + notes = [f"unknown {host} {ring} {utc(max(begin, window.start))}..{utc(min(finish, window.end))}: {reason}" + for host, ring, begin, finish, reason in gaps] + notes += [f"unknown {host}: coverage {percent(coverage[host])} is below {percent(min_coverage)}" for host in low] + samples = sum(len(slots[host]) for host in monitored) + per_host = ",".join(f"{host}:{percent(coverage[host])}" for host in monitored) + + def figure(name: str, found: list[str], unknown: list[str]) -> Figure: + result = FAIL if found else (UNKNOWN if gaps or low or unknown else PASS) + return Figure(name, result, samples, min(coverage.values()), + f"entries={len(found)} unknown_intervals={len(gaps) + len(unknown)} nodes={per_host}", + found + unknown + notes) + + return figure(monitored_name, found_monitored, unknown_monitored), figure(triage_name, found_triage, unknown_triage) + + +# --------------------------------------------------------------------------- +# Figure 4: inbound connections against the control +# --------------------------------------------------------------------------- + + +def evaluate_connections(archive: list[Row] | None, control: list[Row] | None, + canary_start: int, window: Window, interval: int, baseline_ratio: Fraction | None, + min_coverage: float) -> Figure: + """Daily archive/control connections_in ratios, relative to the baseline ratio. + + Samples are paired by the expected sample they stand for. A day is judged + only when its valid pairs cover at least min_coverage of the samples + expected outside restart exclusions; otherwise the day is unknown. + """ + name = "archive connections_in within +-20% of control, relative to the baseline ratio, excluding 48 h after restarts" + if not archive or not control: + return Figure(name, UNKNOWN, 0, None, "needs both archive and control samples") + + def by_slot(rows: list[Row]) -> dict[int, tuple[int, int | None]]: + """(state, connections_in) per expected sample: EXCLUDED within 48 h of a restart. Several + samples for one count as the least favorable, and measurements that disagree as unusable.""" + slots: dict[int, tuple[int, int | None]] = {} + for time, row in sample_rows(rows): + slot = slot_of(time, window, interval) + if slot is None: + continue + uptime, count = as_count(row["uptime"]), as_count(row["connections_in"]) + if uptime is not None and uptime < CONNECTIONS_RESTART_EXCLUSION: + sample: tuple[int, int | None] = (EXCLUDED, None) + elif row["sample_ok"] == "1" and uptime is not None and count is not None: + sample = (MEASURED, count) + else: + sample = (UNUSABLE, None) + held = slots.setdefault(slot, sample) + if held != sample: + slots[slot] = max(held, sample) if held[0] != sample[0] else (UNUSABLE, None) + return slots + + archive_slots, control_slots = by_slot(archive), by_slot(control) + baseline = [0, 0, 0] # archive sum, control sum, pairs + days: dict[str, dict[str, int]] = defaultdict(lambda: {"archive": 0, "control": 0, "pairs": 0, "excluded": 0}) + for slot in sorted(set(archive_slots) | set(control_slots)): + pair = [node_slots.get(slot, (UNUSABLE, None)) for node_slots in (archive_slots, control_slots)] + counts = [count for _, count in pair] + restarted = any(state == EXCLUDED for state, _ in pair) + valid = all(state == MEASURED for state, _ in pair) + time = window.start + slot * interval + if time < canary_start: + if valid: + baseline[0] += counts[0] # type: ignore[operator] + baseline[1] += counts[1] # type: ignore[operator] + baseline[2] += 1 + continue + if slot < 0: + continue + day = days[utc(time)[:10]] + if restarted: + day["excluded"] += 1 + elif valid: + day["archive"] += counts[0] # type: ignore[operator] + day["control"] += counts[1] # type: ignore[operator] + day["pairs"] += 1 + + if baseline_ratio is None: + # Every day is compared against this ratio, so a zero on either side is + # no evidence: the ratio is undefined, or every day divides by zero. + if baseline[2] == 0: + return Figure(name, UNKNOWN, 0, None, "no baseline: no valid paired samples before the canary start") + for node, total in (("archive", baseline[0]), ("control", baseline[1])): + if total == 0: + return Figure(name, UNKNOWN, 0, None, + f"no baseline: {node} connections_in summed to 0 over {baseline[2]} valid pre-canary pairs") + baseline_ratio = Fraction(baseline[0], baseline[1]) + baseline_text = f"baseline_ratio={float(baseline_ratio):.3f} baseline_pairs={baseline[2]}" + else: + baseline_text = f"baseline_ratio={float(baseline_ratio):.3f} (given)" + details = [] + results = [] + pairs = judged_expected = 0 + day_start = datetime.fromtimestamp(window.start, timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + while int(day_start.timestamp()) <= window.end: + label = day_start.date().isoformat() + begin = max(window.start, int(day_start.timestamp())) + finish = min(window.end, int((day_start + timedelta(days=1)).timestamp()) - 1) + day_start += timedelta(days=1) + day = days.get(label) or {"archive": 0, "control": 0, "pairs": 0, "excluded": 0} + expected = expected_samples(begin, finish, interval, phase=window.start) - day["excluded"] + if expected <= 0: + details.append(f"{label} excluded: excluded={day['excluded']}") + continue + pairs += day["pairs"] + judged_expected += expected + coverage = min(1.0, day["pairs"] / expected) + counts_text = f"pairs={day['pairs']} expected={expected} excluded={day['excluded']} coverage={percent(coverage)}" + if coverage < min_coverage or day["control"] == 0: + details.append(f"{label} unknown: {counts_text}") + results.append(UNKNOWN) + continue + # Exact, so a day exactly 20% off passes: in floats, 24/10 against a + # baseline of 3 is 0.7999999999999999. + relative = Fraction(day["archive"], day["control"]) / baseline_ratio + state = PASS if abs(relative - 1) <= CONNECTIONS_TOLERANCE else FAIL + results.append(state) + details.append(f"{label} {state}: relative={float(relative):.3f} {counts_text}") + if FAIL in results: + result = FAIL + elif UNKNOWN in results or PASS not in results: + result = UNKNOWN + else: + result = PASS + overall = min(1.0, pairs / judged_expected) if judged_expected else None + return Figure(name, result, pairs, overall, f"{baseline_text} days={len(results)}", details) + + +# --------------------------------------------------------------------------- +# Command line +# --------------------------------------------------------------------------- + + +def verdict(figures: list[Figure]) -> str: + results = [figure.result for figure in figures] + if FAIL in results: + return FAIL + return UNKNOWN if UNKNOWN in results else PASS + + +def time_arg(text: str) -> int: + try: + return parse_time(text) + except ValueError as e: + raise argparse.ArgumentTypeError(str(e)) from None + + +def positive_int(text: str) -> int: + if not text.isdigit() or int(text) == 0: + raise argparse.ArgumentTypeError(f"expected a positive whole number of seconds, got {text!r}") + return int(text) + + +def non_negative_int(text: str) -> int: + if not text.isdigit(): + raise argparse.ArgumentTypeError(f"expected a whole number of seconds, got {text!r}") + return int(text) + + +def share_arg(text: str) -> float: + try: + value = float(text) + except ValueError: + value = -1.0 + if not 0 < value <= 1: + raise argparse.ArgumentTypeError(f"expected a share greater than 0 and at most 1, such as 0.9, got {text!r}") + return value + + +def ratio_arg(text: str) -> Fraction: + """The ratio as written, exactly: 1.2 is 6/5, not the float nearest it.""" + try: + # float() rejects nan and infinity, and keeps the exponent within its range. + value = Fraction(text) if 0 < float(text) < float("inf") else Fraction(0) + except ValueError: + value = Fraction(0) + if value <= 0: + raise argparse.ArgumentTypeError(f"expected a positive ratio, got {text!r}") + return value + + +def file_arg(text: str) -> Path: + path = Path(text) + if not path.is_file(): + raise argparse.ArgumentTypeError(f"no such file: {text}") + return path + + +def role_error(roles: list[tuple[str, Path, list[Row]]]) -> str | None: + """Why the samples given for --pool, --archive and --control are not each one distinct node, or None. + + A role's samples carry one host label; no two roles share a file, a host + label or an instance_id, so no node is compared with itself. + """ + hosts: dict[str, str] = {} + instances: dict[str, str] = {} + for index, (role, path, rows) in enumerate(roles): + for other, other_path, _ in roles[:index]: + if path.samefile(other_path): + return f"--{other} and --{role} are the same file; each role needs its own node's samples" + labels = {row["host"] for row in rows} + if any(label is None or not LABEL.fullmatch(label) for label in labels): + return f"--{role} has a row without a valid host label" + if len(labels) > 1: + return f"--{role} mixes the host labels {', '.join(sorted(map(str, labels)))}; give one node's samples per role" + for label in map(str, labels): + if label in hosts: + return f"--{hosts[label]} and --{role} both have the host label {label}; each role needs its own node" + hosts[label] = role + for instance in {row["instance_id"] for row in rows if row["instance_id"] is not None}: + if instances.setdefault(instance, role) != role: + return f"--{instances[instance]} and --{role} report the same instance_id; each role needs its own node" + return None + + +def hosts_arg(text: str) -> list[str]: + hosts = [host.strip() for host in text.split(",")] + if not all(LABEL.fullmatch(host) for host in hosts) or len(set(hosts)) != len(hosts): + raise argparse.ArgumentTypeError(f"expected distinct comma-separated host labels, got {text!r}") + return hosts + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) + parser.add_argument("--canary-start", required=True, type=time_arg, + help="start of the canary window (Unix seconds or ISO 8601); earlier samples are the baseline") + parser.add_argument("--end", type=time_arg, help="end of the window (default: the latest sample)") + parser.add_argument("--pool", type=file_arg, help="the pool node's samples.csv") + parser.add_argument("--archive", type=file_arg, help="the canary archive node's samples.csv") + parser.add_argument("--control", type=file_arg, help="the control archive node's samples.csv") + parser.add_argument("--failures", type=file_arg, action="append", default=[], + help="a canary node's failures.jsonl (repeatable; every monitored node needs one)") + parser.add_argument("--known-good", type=file_arg, help="known-good endpoints with labels, required with --failures") + parser.add_argument("--monitored", type=hosts_arg, + help="host labels whose failures must all be present (default: the hosts of --pool and --archive)") + parser.add_argument("--baseline-ratio", type=ratio_arg, + help="archive/control connections_in ratio to use instead of the samples before --canary-start") + parser.add_argument("--min-coverage", type=share_arg, default=DEFAULT_MIN_COVERAGE, + help="share of expected samples a figure, node or day needs to be judged (default: 0.9)") + parser.add_argument("--interval", type=positive_int, default=300, help="sampling interval in seconds (default: 300)") + parser.add_argument("--restart-grace", type=non_negative_int, default=600, + help="seconds after a restart excluded from the pinned-link figure (default: 600)") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if not (args.pool or args.archive or args.control or args.failures): + parser.error("give at least one of --pool, --archive, --control or --failures") + if args.failures and not args.known_good: + parser.error("--failures needs --known-good") + if args.known_good and not args.failures: + parser.error("--known-good is only used with --failures") + if args.monitored and not args.failures: + parser.error("--monitored is only used with --failures") + if args.end is not None and args.end < args.canary_start: + parser.error("--end is before --canary-start") + + try: + pool = pq_canary.read_rows(args.pool) if args.pool else None + archive = pq_canary.read_rows(args.archive) if args.archive else None + control = pq_canary.read_rows(args.control) if args.control else None + records = [record for path in args.failures for record in pq_canary.read_failures(path)] if args.failures else None + known_good = read_known_good(args.known_good) if args.known_good else None + except (OSError, ValueError) as e: + print(f"Error: {e}", file=sys.stderr) + return 2 + error = role_error([(role, path, rows) for role, path, rows in + (("pool", args.pool, pool), ("archive", args.archive, archive), ("control", args.control, control)) + if path is not None and rows is not None]) + if error is not None: + print(f"Error: {error}", file=sys.stderr) + return 2 + for record in records or []: + if not isinstance(record, dict) or not is_time(record.get("time")) or not isinstance(record.get("host"), str) \ + or not LABEL.fullmatch(record["host"]): + print("Error: a failures.jsonl line lacks a time in Unix seconds or a host label", file=sys.stderr) + return 2 + + monitored = args.monitored + if monitored is None: + monitored = sorted({row["host"] for rows in (pool, archive) if rows for row in rows if row["host"]}) + + start = args.canary_start + latest = [time for rows in (pool, archive, control) if rows for time, _ in sample_rows(rows)] + latest += [record["time"] for record in records or []] + end = args.end if args.end is not None else max(latest, default=start) + window = Window(start, end) + + pinned = evaluate_pinned(pool, archive, window, args.interval, args.restart_grace, args.min_coverage) + failures, triage = evaluate_failures(records, known_good, monitored, window, args.interval, args.min_coverage) + connections = evaluate_connections(archive, control, start, window, args.interval, args.baseline_ratio, args.min_coverage) + + print(f"PQ canary evaluation: {utc(start)} to {utc(end)}, minimum coverage {percent(args.min_coverage)}") + for label, rows in (("pool", pool), ("archive", archive), ("control", control)): + if rows is None: + print(f"restarts: {label} no samples") + continue + node_restarts = [restart for restart in restarts(rows) if window.overlaps(restart.last_seen, restart.boot)] + print(f"restarts: {label} {len(node_restarts)}" + "".join(f" {utc(restart.boot)}" for restart in node_restarts)) + print() + for figure in (pinned, failures, triage, connections): + print(f"{figure.result:<8} {figure.name}: samples={figure.samples} coverage={percent(figure.coverage)} {figure.summary}".rstrip()) + for detail in figure.details: + print(f" {detail}") + final = verdict([pinned, failures, connections]) + print() + print(f"canary verdict (figures 1, 2 and 4): {final}") + return {PASS: 0, FAIL: 1, UNKNOWN: 3}[final] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/contrib/pq-canary/pq-canary-sample.sh b/contrib/pq-canary/pq-canary-sample.sh new file mode 100755 index 0000000000..59bbfe2a99 --- /dev/null +++ b/contrib/pq-canary/pq-canary-sample.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +# +# Record one post-quantum transport sample for the mainnet canary (#184, R5). +# Run every 5 minutes from cron or a systemd timer on each canary node and on +# the control node; see README.md. +# +# pq-canary-sample.sh --host LABEL --out DIR [--pinned ADDR:PORT] -- QBIT_CLI [ARGS...] +# +# Appends one row to DIR/samples.csv and, when getpqtransportinfo answers, one +# line to DIR/failures.jsonl. A failed call or a missing field is written as NA, +# never 0. --pinned names the archive node the pool node is pinned to; it makes +# getpeerinfo part of the sample. The addresses in failures.jsonl stay on the node. +# Exit status: 0 for a complete sample, 2 when sample_ok=0, 1 on a usage error. + +export LC_ALL=C +set -o nounset -o pipefail + +usage() { + echo "Usage: $0 --host LABEL --out DIR [--pinned ADDR:PORT] -- QBIT_CLI [ARGS...]" >&2 + exit 1 +} + +host="" +out="" +pinned="" +while [ $# -gt 0 ]; do + case "$1" in + --host) [ $# -ge 2 ] || usage; host="$2"; shift 2 ;; + --out) [ $# -ge 2 ] || usage; out="$2"; shift 2 ;; + --pinned) [ $# -ge 2 ] || usage; pinned="$2"; shift 2 ;; + --) shift; break ;; + *) usage ;; + esac +done +[ -n "$host" ] && [ -n "$out" ] && [ $# -gt 0 ] || usage +case "$host" in + *[!A-Za-z0-9._-]*) echo "Error: --host must be letters, digits, '.', '_' or '-'" >&2; exit 1 ;; + NA) echo "Error: --host NA would read back as a missing value; pick another label" >&2; exit 1 ;; +esac +# The pinned peer as getpeerinfo shows it: host:port or [ipv6]:port (I2P peers show port 0). +if [ -n "$pinned" ]; then + if ! [[ "$pinned" =~ ^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+):([0-9]{1,5})$ ]] || [ "${BASH_REMATCH[2]}" -gt 65535 ]; then + echo "Error: --pinned must be ADDRESS:PORT or [IPV6]:PORT, as getpeerinfo shows the pinned peer" >&2 + exit 1 + fi +fi + +mkdir -p "$out" || exit 1 +rpc_dir="$(mktemp -d)" || exit 1 +trap 'rm -rf "$rpc_dir"' EXIT + +# One timestamp for the whole sample. +now="$(date -u +%s)" +utc="$(date -u -d "@$now" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$now" +%Y-%m-%dT%H:%M:%SZ)" + +cli=("$@") +# A hung node must not stall cron: bound each call when timeout(1) exists. +limit=() +if command -v timeout > /dev/null; then + limit=(timeout 60) +fi + +# Run one RPC; a failure leaves a marker so the formatter writes NA, not 0. +rpc() { + local method="$1" + if ! ${limit[@]+"${limit[@]}"} "${cli[@]}" "$method" > "$rpc_dir/$method" 2> "$rpc_dir/$method.err"; then + : > "$rpc_dir/$method.failed" + fi +} + +rpc uptime +rpc getnetworkinfo +rpc getpqtransportinfo +pinned_args=() +if [ -n "$pinned" ]; then + rpc getpeerinfo + pinned_args=(--pinned "$pinned") +fi + +python3 "$(dirname "$0")/pq_canary.py" sample \ + --rpc-dir "$rpc_dir" --out "$out" --host "$host" --time "$now" --utc "$utc" \ + ${pinned_args[@]+"${pinned_args[@]}"} diff --git a/contrib/pq-canary/pq_canary.py b/contrib/pq-canary/pq_canary.py new file mode 100755 index 0000000000..d1042bf711 --- /dev/null +++ b/contrib/pq-canary/pq_canary.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +"""Sample format shared by pq-canary-sample.sh and pq-canary-eval.py. + +One sample is one CSV row (COLUMNS) plus, when getpqtransportinfo answered, one +JSON line with the raw recent_failures and fallback_set. The rules that keep the +evidence honest: + +* a field that is missing, or whose RPC failed, is written as NA, never 0; + v1.0.0 has neither getnetworkinfo.connections_pq nor getpqtransportinfo; +* so is a value of the wrong type: a count that is not a non-negative integer, + a flag that is not a boolean, an instance_id that is not 64 lowercase hex + digits; true is never written as a count of 1; +* a real 0 stays 0; +* sample_ok is 1 only when uptime, getnetworkinfo and, on the pool node, + getpeerinfo all answered. + +The RPC fields follow the getpqtransportinfo contract in #184. + +Run as ``pq_canary.py sample ...`` by pq-canary-sample.sh, which gathers the RPC +results into files; see that script for the arguments. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +import sys +from pathlib import Path +from typing import Any, Iterator + +NA = "NA" + +DIRECTION_COUNTS = ["switched", "legacy_peer", "malformed_record", "first_packet_failed", "abandoned", "internal_error"] +INBOUND_COUNTS = DIRECTION_COUNTS + ["shed"] +OUTBOUND_COUNTS = DIRECTION_COUNTS + ["closed_after_switch", "fallback"] + +COLUMNS = ( + ["time", "utc", "host", "uptime", "version", "sample_ok", + "pq_enabled", "instance_id", "arith_backend", "keccak_backend", "since"] + + [f"in_{name}" for name in INBOUND_COUNTS] + + [f"out_{name}" for name in OUTBOUND_COUNTS] + + ["load_shedding_active", + "in_ring_last_sequence", "in_ring_dropped", "out_ring_last_sequence", "out_ring_dropped", + "connections_in", "connections_pq", + "pinned_present", "pinned_connection_type", "pinned_transport_pq", "pinned_transport_pq_status"] +) + + +def count(value: Any) -> str: + """CSV text for a JSON non-negative integer, else NA.""" + return str(value) if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else NA + + +def flag(value: Any) -> str: + """CSV text for a JSON boolean, 1 or 0, else NA.""" + return ("1" if value else "0") if isinstance(value, bool) else NA + + +def text(value: Any) -> str: + """CSV text for a JSON string, else NA.""" + return value if isinstance(value, str) else NA + + +def instance_id(value: Any) -> str: + """The 64-lowercase-hex-digit instance_id, else NA.""" + return value if isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) else NA + + +def lookup(document: Any, *path: str) -> Any: + """document[path[0]][path[1]]..., or None if any step is missing or not an object.""" + for key in path: + if not isinstance(document, dict) or key not in document: + return None + document = document[key] + return document + + +def build_row(*, time: int, utc: str, host: str, uptime: Any, netinfo: Any, pqinfo: Any, + peers: Any, pinned: str | None) -> dict[str, str]: + """One CSV row. uptime, netinfo, pqinfo and peers are the parsed RPC results, or None + when the call failed; peers and pinned are None on nodes that do not pin a peer.""" + row = {name: NA for name in COLUMNS} + row.update(time=str(time), utc=utc, host=host) + row["uptime"] = count(uptime) + row["version"] = count(lookup(netinfo, "version")) + row["connections_in"] = count(lookup(netinfo, "connections_in")) + row["connections_pq"] = count(lookup(netinfo, "connections_pq")) + + row["pq_enabled"] = flag(lookup(pqinfo, "enabled")) + row["instance_id"] = instance_id(lookup(pqinfo, "instance_id")) + row["arith_backend"] = text(lookup(pqinfo, "arith_backend")) + row["keccak_backend"] = text(lookup(pqinfo, "keccak_backend")) + row["since"] = count(lookup(pqinfo, "since")) + for name in INBOUND_COUNTS: + row[f"in_{name}"] = count(lookup(pqinfo, "handshakes", "inbound", name)) + for name in OUTBOUND_COUNTS: + row[f"out_{name}"] = count(lookup(pqinfo, "handshakes", "outbound", name)) + row["load_shedding_active"] = flag(lookup(pqinfo, "load_shedding", "active")) + for prefix, direction in (("in", "inbound"), ("out", "outbound")): + row[f"{prefix}_ring_last_sequence"] = count(lookup(pqinfo, "recent_failures", direction, "last_sequence")) + row[f"{prefix}_ring_dropped"] = count(lookup(pqinfo, "recent_failures", direction, "dropped")) + + pool_node = pinned is not None + peers_ok = isinstance(peers, list) + if pool_node and peers_ok: + matches = [peer for peer in peers if isinstance(peer, dict) and peer.get("addr") == pinned] + # Prefer the manual connection if the address is connected more than once. + matches.sort(key=lambda peer: peer.get("connection_type") != "manual") + row["pinned_present"] = "1" if matches else "0" + if matches: + peer = matches[0] + row["pinned_connection_type"] = text(peer.get("connection_type")) + row["pinned_transport_pq"] = flag(peer.get("transport_pq")) + row["pinned_transport_pq_status"] = text(peer.get("transport_pq_status")) + + answered = row["uptime"] != NA and isinstance(netinfo, dict) and (peers_ok or not pool_node) + row["sample_ok"] = "1" if answered else "0" + return row + + +def build_failures_record(*, time: int, host: str, pqinfo: Any) -> dict[str, Any] | None: + """The JSON line kept for the evaluator, or None when getpqtransportinfo did not answer.""" + if not isinstance(pqinfo, dict): + return None + return { + "time": time, + "host": host, + "instance_id": pqinfo.get("instance_id"), + "since": pqinfo.get("since"), + "recent_failures": pqinfo.get("recent_failures"), + "fallback_set": pqinfo.get("fallback_set"), + } + + +def append_row(path: Path, row: dict[str, str]) -> None: + """Append a row, writing the header first when the file is new or empty.""" + new = not path.exists() or path.stat().st_size == 0 + if not new: + with path.open(encoding="utf8", newline="") as existing: + header = next(csv.reader(existing), None) + if header != COLUMNS: + raise ValueError(f"{path} has a different header; move it aside and start a new file") + with path.open("a", encoding="utf8", newline="") as out: + writer = csv.DictWriter(out, fieldnames=COLUMNS, lineterminator="\n") + if new: + writer.writeheader() + writer.writerow(row) + + +def read_rows(path: Path) -> list[dict[str, str | None]]: + """Rows of a samples file, with NA turned into None. + + A row with more or fewer fields than the header, such as one cut short by a + crash mid-write and continued by the next sample, is not a sample: its values + may sit in the wrong columns, so it is left out and counts as missing. + """ + with path.open(encoding="utf8", newline="") as source: + reader = csv.reader(source) + header = next(reader, None) + if header != COLUMNS: + raise ValueError(f"{path}: unexpected header {header}") + return [{key: (None if value == NA else value) for key, value in zip(COLUMNS, fields)} + for fields in reader if len(fields) == len(COLUMNS)] + + +def read_failures(path: Path) -> Iterator[dict[str, Any]]: + with path.open(encoding="utf8") as source: + for number, line in enumerate(source, 1): + if line.strip(): + try: + yield json.loads(line) + except json.JSONDecodeError as e: + raise ValueError(f"{path}:{number}: {e}") from e + + +def load_rpc_result(path: Path) -> Any: + """A parsed RPC result, or None when the call failed (marker file) or printed nothing parsable.""" + if not path.exists() or path.with_name(path.name + ".failed").exists(): + return None + text = path.read_text(encoding="utf8").strip() + try: + return json.loads(text) + except json.JSONDecodeError: + return None + + +def sample_main(args: argparse.Namespace) -> int: + rpc = Path(args.rpc_dir) + uptime = load_rpc_result(rpc / "uptime") + netinfo = load_rpc_result(rpc / "getnetworkinfo") + pqinfo = load_rpc_result(rpc / "getpqtransportinfo") + peers = load_rpc_result(rpc / "getpeerinfo") if args.pinned else None + row = build_row(time=args.time, utc=args.utc, host=args.host, uptime=uptime, netinfo=netinfo, + pqinfo=pqinfo, peers=peers, pinned=args.pinned) + out = Path(args.out) + append_row(out / "samples.csv", row) + record = build_failures_record(time=args.time, host=args.host, pqinfo=pqinfo) + if record is not None: + with (out / "failures.jsonl").open("a", encoding="utf8") as failures: + failures.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n") + return 0 if row["sample_ok"] == "1" else 2 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="PQ canary sample formatting (used by pq-canary-sample.sh).") + commands = parser.add_subparsers(dest="command", required=True) + sample = commands.add_parser("sample", help="append one sample from RPC results gathered in --rpc-dir") + sample.add_argument("--rpc-dir", required=True) + sample.add_argument("--out", required=True) + sample.add_argument("--host", required=True) + sample.add_argument("--time", required=True, type=int) + sample.add_argument("--utc", required=True) + sample.add_argument("--pinned") + args = parser.parse_args(argv) + return sample_main(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/contrib/pq-canary/test/fixtures/getnetworkinfo-v1.0.0.json b/contrib/pq-canary/test/fixtures/getnetworkinfo-v1.0.0.json new file mode 100644 index 0000000000..39b250a518 --- /dev/null +++ b/contrib/pq-canary/test/fixtures/getnetworkinfo-v1.0.0.json @@ -0,0 +1,10 @@ +{ + "version": 10000, + "subversion": "/qbit:1.0.0/", + "protocolversion": 70016, + "localrelay": true, + "networkactive": true, + "connections": 38, + "connections_in": 28, + "connections_out": 10 +} diff --git a/contrib/pq-canary/test/fixtures/getnetworkinfo.json b/contrib/pq-canary/test/fixtures/getnetworkinfo.json new file mode 100644 index 0000000000..2d5f110fda --- /dev/null +++ b/contrib/pq-canary/test/fixtures/getnetworkinfo.json @@ -0,0 +1,11 @@ +{ + "version": 10100, + "subversion": "/qbit:1.1.0/", + "protocolversion": 70016, + "localrelay": true, + "networkactive": true, + "connections": 41, + "connections_in": 30, + "connections_out": 11, + "connections_pq": 12 +} diff --git a/contrib/pq-canary/test/fixtures/getpeerinfo.json b/contrib/pq-canary/test/fixtures/getpeerinfo.json new file mode 100644 index 0000000000..a5222c2312 --- /dev/null +++ b/contrib/pq-canary/test/fixtures/getpeerinfo.json @@ -0,0 +1,18 @@ +[ + { + "id": 3, + "addr": "203.0.113.5:8333", + "connection_type": "manual", + "transport_protocol_type": "v2", + "transport_pq": true, + "transport_pq_status": "hybrid" + }, + { + "id": 4, + "addr": "198.51.100.20:8333", + "connection_type": "outbound-full-relay", + "transport_protocol_type": "v2", + "transport_pq": false, + "transport_pq_status": "legacy_peer" + } +] diff --git a/contrib/pq-canary/test/fixtures/getpqtransportinfo.json b/contrib/pq-canary/test/fixtures/getpqtransportinfo.json new file mode 100644 index 0000000000..e0f8fba06d --- /dev/null +++ b/contrib/pq-canary/test/fixtures/getpqtransportinfo.json @@ -0,0 +1,89 @@ +{ + "enabled": true, + "status": "enabled", + "instance_id": "5e1f0c3a9b7d4e2f8a6c1b3d5f7e9a0c2e4f6a8b0d1c3e5f7a9b0c2d4e6f8a1b", + "arith_backend": "x86_64-avx2", + "keccak_backend": "x86_64-avx2", + "since": 1760000000, + "handshakes": { + "inbound": { + "switched": 120, + "legacy_peer": 30, + "malformed_record": 1, + "first_packet_failed": 0, + "abandoned": 2, + "internal_error": 0, + "shed": 0 + }, + "outbound": { + "switched": 40, + "legacy_peer": 8, + "malformed_record": 0, + "first_packet_failed": 1, + "abandoned": 0, + "internal_error": 0, + "closed_after_switch": 1, + "fallback": 0 + } + }, + "load_shedding": { + "active": false, + "threshold_per_s": 1000, + "since": 0 + }, + "fallback_set": [], + "failure_streaks": [ + { + "endpoint": {"kind": "address", "network": "ipv4", "address": "203.0.113.9", "port": 8333}, + "cause": "first_packet_failed", + "reason": "tag", + "streak": 1, + "last_failure": 1760003000, + "next_window_seconds": 3600 + } + ], + "recent_failures": { + "inbound": { + "last_sequence": 1, + "dropped": 0, + "entries": [ + { + "sequence": 1, + "time": 1760001000, + "endpoint": {"kind": "address", "network": "ipv4", "address": "198.51.100.77", "port": 51234}, + "direction": "inbound", + "connection_type": "inbound", + "peer_id": 17, + "outcome": "malformed_record", + "reason": "ek_length" + } + ] + }, + "outbound": { + "last_sequence": 2, + "dropped": 0, + "entries": [ + { + "sequence": 1, + "time": 1760003000, + "endpoint": {"kind": "address", "network": "ipv4", "address": "203.0.113.9", "port": 8333}, + "direction": "outbound", + "connection_type": "outbound-full-relay", + "peer_id": 21, + "outcome": "first_packet_failed", + "reason": "tag" + }, + { + "sequence": 2, + "time": 1760004000, + "endpoint": {"kind": "address", "network": "ipv6", "address": "2001:db8::5", "port": 8333}, + "direction": "outbound", + "connection_type": "block-relay-only", + "peer_id": 25, + "outcome": "closed_after_switch", + "reason": "eof" + } + ] + } + } +} diff --git a/contrib/pq-canary/test/fixtures/known-good.txt b/contrib/pq-canary/test/fixtures/known-good.txt new file mode 100644 index 0000000000..723eabf617 --- /dev/null +++ b/contrib/pq-canary/test/fixtures/known-good.txt @@ -0,0 +1,7 @@ +# endpoint [label]: the report prints the label, never the address. +# The archive node, as the pool node dials it. +203.0.113.5:8333 archive +# The pool node, as the archive node sees its inbound connection (any source port). +198.51.100.7 pool +# An IPv6 canary peer, unlabeled: it is reported as known-good-3. +[2001:db8::5]:8333 diff --git a/contrib/pq-canary/test/test_pq_canary.py b/contrib/pq-canary/test/test_pq_canary.py new file mode 100755 index 0000000000..e6c2b05d52 --- /dev/null +++ b/contrib/pq-canary/test/test_pq_canary.py @@ -0,0 +1,1348 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026-present The qbit core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +"""Tests for the PQ canary sampler and evaluator (contrib/pq-canary). + +The RPC fixtures in fixtures/ follow the getpqtransportinfo contract in #184. +Standard library only, so the Required Merge Gate can run it as is. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from fractions import Fraction +from pathlib import Path +from typing import Any + +HERE = Path(__file__).resolve().parent +TOOL_DIR = HERE.parent +FIXTURES = HERE / "fixtures" +SAMPLER = TOOL_DIR / "pq-canary-sample.sh" +EVALUATOR = TOOL_DIR / "pq-canary-eval.py" + +sys.path.insert(0, str(TOOL_DIR)) +import pq_canary # noqa: E402 + +_spec = importlib.util.spec_from_file_location("pq_canary_eval", EVALUATOR) +assert _spec is not None and _spec.loader is not None +pq_eval = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = pq_eval # dataclasses look their module up while it loads +_spec.loader.exec_module(pq_eval) + +NA = pq_canary.NA +PINNED = "203.0.113.5:8333" +POOL_ADDRESS = "198.51.100.7" +T0 = 1_760_000_000 # canary start +INTERVAL = 300 +DAY = 86_400 +LONG_UPTIME = 30 * DAY + + +def fixture(name: str) -> Any: + return json.loads((FIXTURES / name).read_text(encoding="utf8")) + + +def parsed(row: dict[str, str]) -> dict[str, str | None]: + """A row as read_rows returns it: NA becomes None.""" + return {key: (None if value == NA else value) for key, value in row.items()} + + +class Node: + """A simulated node producing samples the way pq-canary-sample.sh records them.""" + + def __init__(self, host: str, *, boot: int, pinned: str | None = None, v1_0_0: bool = False) -> None: + self.host = host + self.boot = boot + self.pinned = pinned + self.v1_0_0 = v1_0_0 + self.instance = 0 + + def restart(self, boot: int) -> None: + self.boot = boot + self.instance += 1 + + def pqinfo(self, time: int) -> dict[str, Any] | None: + """getpqtransportinfo at time: the rings hold the fixture's failures recorded by then.""" + if self.v1_0_0: + return None + info = fixture("getpqtransportinfo.json") + # Random per process start on a real node; distinct per node and run here. + info["instance_id"] = hashlib.sha256(f"{self.host}/{self.instance}".encode()).hexdigest() + info["since"] = self.boot + for failures in info["recent_failures"].values(): + failures["entries"] = [item for item in failures["entries"] if item["time"] <= time] + failures["last_sequence"] = failures["entries"][-1]["sequence"] if failures["entries"] else 0 + return info + + def sample(self, time: int, *, down: bool = False, connections_in: int | None = 30, + pinned_peer: dict[str, Any] | None | str = "manual-hybrid") -> dict[str, str | None]: + netinfo = None if down else fixture("getnetworkinfo-v1.0.0.json" if self.v1_0_0 else "getnetworkinfo.json") + if netinfo is not None: + if connections_in is None: + del netinfo["connections_in"] + else: + netinfo["connections_in"] = connections_in + peers = None + if self.pinned and not down: + peers = [peer for peer in fixture("getpeerinfo.json") if peer["addr"] != PINNED] + if pinned_peer == "manual-hybrid": + peers.append({"addr": self.pinned, "connection_type": "manual", "transport_pq": True, "transport_pq_status": "hybrid"}) + elif isinstance(pinned_peer, dict): + peers.append(dict(pinned_peer, addr=self.pinned)) + row = pq_canary.build_row( + time=time, utc=pq_eval.utc(time), host=self.host, + uptime=None if down else time - self.boot, + netinfo=netinfo, pqinfo=None if down else self.pqinfo(time), peers=peers, pinned=self.pinned) + return parsed(row) + + +def window(count: int) -> Any: + return pq_eval.Window(T0, T0 + (count - 1) * INTERVAL) + + +def entry(sequence: int, time: int, address: str, port: int, outcome: str, *, direction: str = "outbound", + reason: str = "tag", connection_type: str = "manual") -> dict[str, Any]: + network = "ipv6" if ":" in address else "ipv4" + return {"sequence": sequence, "time": time, "direction": direction, "connection_type": connection_type, + "peer_id": sequence, "outcome": outcome, "reason": reason, + "endpoint": {"kind": "address", "network": network, "address": address, "port": port}} + + +def ring(last_sequence: Any, dropped: Any, entries: list[Any]) -> dict[str, Any]: + return {"last_sequence": last_sequence, "dropped": dropped, "entries": entries} + + +def record(time: int, *, inbound: Any, outbound: Any, host: str = "archive", + instance: Any = "a" * 64, since: Any = T0 - DAY, fallback_set: list[Any] | None = None) -> dict[str, Any]: + return {"time": time, "host": host, "instance_id": instance, "since": since, + "recent_failures": {"inbound": inbound, "outbound": outbound}, "fallback_set": fallback_set or []} + + +def known_good() -> Any: + return pq_eval.read_known_good(FIXTURES / "known-good.txt") + + +# --------------------------------------------------------------------------- +# Sampling +# --------------------------------------------------------------------------- + + +class SampleRowTest(unittest.TestCase): + def test_full_schema_fills_every_column(self) -> None: + row = pq_canary.build_row(time=T0, utc="2025-10-09T08:53:20Z", host="pool", uptime=1234, + netinfo=fixture("getnetworkinfo.json"), pqinfo=fixture("getpqtransportinfo.json"), + peers=fixture("getpeerinfo.json"), pinned=PINNED) + self.assertEqual(list(row), pq_canary.COLUMNS) + self.assertNotIn(NA, row.values()) + self.assertEqual(row["sample_ok"], "1") + self.assertEqual((row["pq_enabled"], row["load_shedding_active"]), ("1", "0")) + self.assertEqual((row["in_switched"], row["out_closed_after_switch"], row["out_fallback"]), ("120", "1", "0")) + self.assertEqual((row["in_ring_last_sequence"], row["out_ring_last_sequence"], row["out_ring_dropped"]), ("1", "2", "0")) + self.assertEqual((row["connections_in"], row["connections_pq"]), ("30", "12")) + self.assertEqual((row["pinned_present"], row["pinned_connection_type"], row["pinned_transport_pq"], + row["pinned_transport_pq_status"]), ("1", "manual", "1", "hybrid")) + # A real zero is a measurement, not a missing value. + self.assertEqual(row["in_internal_error"], "0") + self.assertNotIn(PINNED, ",".join(row.values()), "addresses stay out of the CSV") + + def test_v1_0_0_writes_na_and_a_complete_sample(self) -> None: + row = Node("control", boot=T0 - DAY, v1_0_0=True).sample(T0) + self.assertEqual(row["sample_ok"], "1") + self.assertEqual(row["version"], "10000") + self.assertEqual(row["connections_in"], "30") + new_fields = [name for name in pq_canary.COLUMNS if name not in + ("time", "utc", "host", "uptime", "version", "sample_ok", "connections_in")] + self.assertEqual({name: row[name] for name in new_fields}, {name: None for name in new_fields}) + + def test_failed_or_missing_values_are_na_never_zero(self) -> None: + down = Node("pool", boot=T0 - DAY, pinned=PINNED).sample(T0, down=True) + self.assertEqual(down["sample_ok"], "0") + self.assertIsNone(down["uptime"]) + self.assertIsNone(down["connections_in"]) + self.assertIsNone(down["pinned_present"]) + + info = fixture("getpqtransportinfo.json") + del info["handshakes"]["inbound"]["shed"] + info["recent_failures"]["outbound"] = "not a ring" + row = pq_canary.build_row(time=T0, utc="x", host="archive", uptime=10, netinfo={"version": 10100}, + pqinfo=info, peers=None, pinned=None) + self.assertEqual(row["in_shed"], NA) + self.assertEqual(row["out_ring_last_sequence"], NA) + self.assertEqual(row["connections_in"], NA) + self.assertEqual(row["in_switched"], "120") + self.assertEqual(row["sample_ok"], "1", "getpeerinfo is only part of the pool node's sample") + + # The pool node needs getpeerinfo; a pinned peer that is not connected is a 0, not NA. + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + absent = pool.sample(T0, pinned_peer=None) + self.assertEqual((absent["sample_ok"], absent["pinned_present"], absent["pinned_connection_type"]), ("1", "0", None)) + row = pq_canary.build_row(time=T0, utc="x", host="pool", uptime=10, netinfo={}, pqinfo=None, peers=None, pinned=PINNED) + self.assertEqual((row["sample_ok"], row["pinned_present"]), ("0", NA)) + + def test_values_of_the_wrong_type_are_na(self) -> None: + info = fixture("getpqtransportinfo.json") + info.update(enabled="yes", instance_id=info["instance_id"].upper(), arith_backend=3, since=-5) + info["handshakes"]["inbound"]["switched"] = True + info["handshakes"]["outbound"]["fallback"] = -1 + info["handshakes"]["outbound"]["switched"] = "40" + info["load_shedding"]["active"] = 1 + info["recent_failures"]["inbound"]["last_sequence"] = 1.0 + netinfo = dict(fixture("getnetworkinfo.json"), version="10100", connections_in=True, connections_pq=-2) + peers = [{"addr": PINNED, "connection_type": 5, "transport_pq": "true", "transport_pq_status": ["hybrid"]}] + wrong = ["uptime", "version", "connections_in", "connections_pq", "pq_enabled", "instance_id", "arith_backend", "since", + "in_switched", "out_fallback", "out_switched", "load_shedding_active", "in_ring_last_sequence", + "pinned_connection_type", "pinned_transport_pq", "pinned_transport_pq_status"] + for uptime in (-1, True, 12.5, "4300"): + with self.subTest(uptime=uptime): + row = pq_canary.build_row(time=T0, utc="x", host="pool", uptime=uptime, netinfo=netinfo, pqinfo=info, + peers=peers, pinned=PINNED) + self.assertEqual({name: row[name] for name in wrong}, dict.fromkeys(wrong, NA)) + # The values of the right type are kept. + self.assertEqual((row["keccak_backend"], row["in_legacy_peer"], row["out_ring_dropped"], row["pinned_present"]), + ("x86_64-avx2", "30", "0", "1")) + self.assertEqual(row["sample_ok"], "0", "a sample without a valid uptime is incomplete") + + def test_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "samples.csv" + row = pq_canary.build_row(time=T0, utc="x", host="pool", uptime=1, netinfo={}, pqinfo=None, peers=None, pinned=None) + pq_canary.append_row(path, row) + pq_canary.append_row(path, row) + lines = path.read_text(encoding="utf8").splitlines() + self.assertEqual(lines[0], ",".join(pq_canary.COLUMNS)) + self.assertEqual(len(lines), 3) + self.assertEqual(len(pq_canary.read_rows(path)), 2) + path.write_text("time,host\n1,pool\n", encoding="utf8") + with self.assertRaises(ValueError): + pq_canary.append_row(path, row) + + def test_rows_with_the_wrong_number_of_fields_are_not_samples(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "samples.csv" + for index in range(3): + pq_canary.append_row(path, {key: NA if value is None else value for key, value in pool.sample(T0 + index * INTERVAL).items()}) + header, first, second, third = path.read_text(encoding="utf8").splitlines() + # A crash cut the second row short and the next sample continued its line; + # a later crash left a short last line. + path.write_text("\n".join([header, first, second[:len(second) // 2] + third, first[:40]]) + "\n", encoding="utf8") + rows = pq_canary.read_rows(path) + self.assertEqual([row["time"] for row in rows], [str(T0)]) + + +@unittest.skipIf(shutil.which("bash") is None, "needs bash") +class SamplerScriptTest(unittest.TestCase): + """pq-canary-sample.sh end to end, with a fake qbit-cli serving the fixtures.""" + + FAKE_CLI = ( + "import json, os, sys\n" + "method = sys.argv[-1]\n" + "path = os.path.join(os.environ['FAKE_RPC'], method)\n" + "if not os.path.exists(path):\n" + " print('error code: -32601\\nerror message:\\nMethod not found', file=sys.stderr)\n" + " sys.exit(89)\n" + "print(open(path, encoding='utf8').read())\n" + "sys.exit(int(os.environ.get('FAKE_EXIT_' + method, '0')))\n" + ) + + def run_sampler(self, root: Path, served: dict[str, Any], *args: str, exits: dict[str, int] | None = None) -> subprocess.CompletedProcess[str]: + rpc = root / "rpc" + shutil.rmtree(rpc, ignore_errors=True) + rpc.mkdir() + for method, result in served.items(): + (rpc / method).write_text(json.dumps(result), encoding="utf8") + cli = root / "fake_cli.py" + cli.write_text(self.FAKE_CLI, encoding="utf8") + env = dict(os.environ, FAKE_RPC=str(rpc), **{f"FAKE_EXIT_{method}": str(code) for method, code in (exits or {}).items()}) + return subprocess.run(["bash", str(SAMPLER), *args, "--", sys.executable, str(cli), "-datadir=/nonexistent"], + env=env, text=True, capture_output=True) + + def test_samples(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + out = root / "out" + v1_0_0 = {"uptime": 4000, "getnetworkinfo": fixture("getnetworkinfo-v1.0.0.json")} + result = self.run_sampler(root, v1_0_0, "--host", "control", "--out", str(out)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((out / "failures.jsonl").exists()) + + full = {"uptime": 4300, "getnetworkinfo": fixture("getnetworkinfo.json"), + "getpqtransportinfo": fixture("getpqtransportinfo.json"), "getpeerinfo": fixture("getpeerinfo.json")} + result = self.run_sampler(root, full, "--host", "control", "--out", str(out)) + self.assertEqual(result.returncode, 0, result.stderr) + + result = self.run_sampler(root, {}, "--host", "control", "--out", str(out)) + self.assertEqual(result.returncode, 2, "an unreachable node is an incomplete sample") + + # A call that fails is NA even if it printed something that parses. + result = self.run_sampler(root, full, "--host", "control", "--out", str(out), exits={"uptime": 1}) + self.assertEqual(result.returncode, 2) + + rows = pq_canary.read_rows(out / "samples.csv") + self.assertEqual([row["sample_ok"] for row in rows], ["1", "1", "0", "0"]) + self.assertEqual([row["connections_pq"] for row in rows], [None, "12", None, "12"]) + self.assertEqual([row["instance_id"] is None for row in rows], [True, False, True, False]) + self.assertEqual([row["uptime"] for row in rows], ["4000", "4300", None, None]) + records = list(pq_canary.read_failures(out / "failures.jsonl")) + self.assertEqual(len(records), 2) + self.assertEqual(records[0]["recent_failures"], fixture("getpqtransportinfo.json")["recent_failures"]) + self.assertEqual(records[0]["host"], "control") + + result = self.run_sampler(root, full, "--host", "pool", "--out", str(root / "pool"), "--pinned", PINNED) + self.assertEqual(result.returncode, 0, result.stderr) + for pinned in ("[2001:db8::5]:8333", "example.b32.i2p:0"): + result = self.run_sampler(root, full, "--host", "pool", "--out", str(root / "other"), "--pinned", pinned) + self.assertEqual(result.returncode, 0, result.stderr) + (row,) = pq_canary.read_rows(root / "pool" / "samples.csv") + self.assertEqual((row["pinned_present"], row["pinned_connection_type"], row["pinned_transport_pq"]), ("1", "manual", "1")) + + def test_usage_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bad_pinned = [("--host", "pool", "--out", tmp, "--pinned", value) + for value in ("pool-node", "203.0.113.5", "[2001:db8::5", "203.0.113.5:99999", "a b:8333", ":8333")] + for args in ((), ("--host", "pool"), ("--host", "bad label", "--out", tmp), ("--host", "NA", "--out", tmp), + ("--host", "pool", "--out", tmp, "--bogus"), + *bad_pinned): + result = subprocess.run(["bash", str(SAMPLER), *args, "--", "true"], text=True, capture_output=True) + self.assertEqual(result.returncode, 1, args) + self.assertFalse((root / "samples.csv").exists()) + + +# --------------------------------------------------------------------------- +# Figure 1: the pinned link +# --------------------------------------------------------------------------- + + +class PinnedLinkTest(unittest.TestCase): + def evaluate(self, pool_rows: list[dict[str, str | None]], count: int, archive_rows: Any = None, + min_coverage: float = 0.9) -> Any: + return pq_eval.evaluate_pinned(pool_rows, archive_rows, window(count), INTERVAL, 600, min_coverage) + + def test_pass(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + figure = self.evaluate([pool.sample(T0 + i * INTERVAL) for i in range(200)], 200) + self.assertEqual((figure.result, figure.samples), ("pass", 200)) + + def test_restart_is_a_gap(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [] + for i in range(100): + time = T0 + i * INTERVAL + if i in (40, 41): + rows.append(pool.sample(time, down=True)) # the node is restarting + continue + if i == 42: + pool.restart(time - 30) + rows.append(pool.sample(time)) + figure = self.evaluate(rows, 100) + self.assertEqual(figure.result, "pass", figure.summary) + self.assertIn("excluded=4", figure.summary) # two down, then uptime 30 s and 330 s < 600 s grace + self.assertEqual(figure.samples, 96) + self.assertEqual(len(pq_eval.restarts(rows)), 1) + + def test_archive_restart_is_a_gap_for_the_pool(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + archive = Node("archive", boot=T0 - DAY) + pool_rows, archive_rows = [], [] + for i in range(50): + time = T0 + i * INTERVAL + if i == 25: + archive.restart(time - 60) + pool_rows.append(pool.sample(time, pinned_peer=None if i == 25 else "manual-hybrid")) + archive_rows.append(archive.sample(time)) + # Without the archive node's samples the dropped link is a real miss: 49 of 50 is below 99%. + self.assertEqual(self.evaluate(pool_rows, 50).result, "fail") + self.assertEqual(self.evaluate(pool_rows, 50, archive_rows).result, "pass") + + def test_rpc_outage_is_unknown(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [pool.sample(T0 + i * INTERVAL, down=i in (10, 11)) for i in range(100)] + figure = self.evaluate(rows, 100) + self.assertEqual(figure.result, "unknown", figure.summary) + self.assertIn("unknown=2", figure.summary) + # Missing rows, such as a stopped cron, are unknown too. + figure = self.evaluate([row for index, row in enumerate(rows) if index not in (10, 11)], 100) + self.assertEqual(figure.result, "unknown", figure.summary) + self.assertIn("unknown=2", figure.summary) + + def test_missing_rows_are_counted_not_listed(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [pool.sample(T0 + i * INTERVAL) for i in range(100)] + # A window starting long before the sampler did is mostly unknown, and cheap to evaluate. + early = pq_eval.evaluate_pinned(rows, None, pq_eval.Window(0, T0 + 99 * INTERVAL), INTERVAL, 600, 0.9) + self.assertEqual(early.result, "unknown") + self.assertIn(f"unknown={int(T0 / INTERVAL + 0.5)}", early.summary) # samples at 0, 300, ... before T0 + # Rows missing because the whole machine was down for a restart are part of the gap. + pool.restart(T0 + 50 * INTERVAL - 30) + rows = rows[:40] + [pool.sample(T0 + i * INTERVAL) for i in range(50, 100)] + figure = self.evaluate(rows, 100) + self.assertEqual(figure.result, "pass", figure.summary) + self.assertIn("unknown=0 excluded=12", figure.summary) + + def test_automatic_connection_fails_with_remedy(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + automatic = {"connection_type": "outbound-full-relay", "transport_pq": True, "transport_pq_status": "hybrid"} + rows = [pool.sample(T0 + i * INTERVAL, pinned_peer=automatic if i < 5 else "manual-hybrid") for i in range(100)] + figure = self.evaluate(rows, 100) + self.assertEqual(figure.result, "fail") + self.assertIn("disconnectnode", " ".join(figure.details)) + + def test_no_samples_is_unknown(self) -> None: + self.assertEqual(self.evaluate([], 10).result, "unknown") + # Two rows for one expected sample (for example a manual run beside cron) are one sample. + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + self.assertEqual(self.evaluate([pool.sample(T0), pool.sample(T0)], 1).samples, 1) + self.assertEqual(pq_eval.evaluate_pinned(None, None, window(10), INTERVAL, 600, 0.9).result, "unknown") + + def test_malformed_numbers_are_missing(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [pool.sample(T0 + i * INTERVAL, pinned_peer=None if i in (40, 41) else "manual-hybrid") for i in range(100)] + # The link was down in two samples: 98 of 100 fails. + self.assertEqual(self.evaluate(rows, 100).result, "fail") + for label, uptime in (("negative", "-30"), ("decimal", "30.0"), ("spaced", " 30"), ("non-ASCII digits", "\u0663\u0660"), + ("underscored", "3_0")): + with self.subTest(uptime=label): + # Not an uptime, so not a restart: the two samples are judged, not excluded. + figure = self.evaluate([dict(row, uptime=uptime) if index in (40, 41) else row for index, row in enumerate(rows)], 100) + self.assertEqual(figure.result, "fail", figure.summary) + self.assertIn("unknown=0 excluded=0", figure.summary) + good = [pool.sample(T0 + i * INTERVAL) for i in range(100)] + for label, time in (("text", "soon"), ("decimal", f"{T0 + 40 * INTERVAL}.0"), ("negative", "-1"), + ("beyond year 9999", str(10**14))): + with self.subTest(time=label): + # A row without a time is a missing sample: unknown. + figure = self.evaluate([dict(row, time=time) if index == 40 else row for index, row in enumerate(good)], 100) + self.assertIn("hybrid=99 not_hybrid=0 unknown=1", figure.summary) + + def test_malformed_pinned_peer_is_unknown(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [pool.sample(T0 + i * INTERVAL) for i in range(100)] + malformed = { + "pinned_present not 0 or 1": {"pinned_present": "yes"}, + "no connection type": {"pinned_connection_type": None}, + "malformed connection type": {"pinned_connection_type": "Manual connection"}, + "transport_pq not 0 or 1": {"pinned_transport_pq": "true"}, + "no transport_pq": {"pinned_transport_pq": None}, + "no status": {"pinned_transport_pq_status": None}, + "unknown status": {"pinned_transport_pq_status": "quantum"}, + "unknown status, not hybrid": {"pinned_transport_pq": "0", "pinned_transport_pq_status": "quantum"}, + "no status, not hybrid": {"pinned_transport_pq": "0", "pinned_transport_pq_status": None}, + "hybrid without transport_pq": {"pinned_transport_pq": "0"}, + "transport_pq without hybrid": {"pinned_transport_pq_status": "pending"}, + } + for label, change in malformed.items(): + with self.subTest(label): + # Neither hybrid nor a miss: two unknown samples in 100 cannot be judged against 99%. + figure = self.evaluate([dict(row, **change) if index in (10, 11) else row for index, row in enumerate(rows)], 100) + self.assertEqual(figure.result, "unknown", figure.summary) + self.assertIn("hybrid=98 not_hybrid=0 unknown=2", figure.summary) + # Well-formed values that are not a manual hybrid link are misses. + misses = { + "not connected": {"pinned_present": "0", "pinned_connection_type": None, "pinned_transport_pq": None, + "pinned_transport_pq_status": None}, + "not hybrid": {"pinned_transport_pq": "0", "pinned_transport_pq_status": "fallback"}, + "automatic": {"pinned_connection_type": "outbound-full-relay"}, + } + for label, change in misses.items(): + with self.subTest(label): + figure = self.evaluate([dict(row, **change) if index in (10, 11) else row for index, row in enumerate(rows)], 100) + self.assertEqual(figure.result, "fail", figure.summary) + self.assertIn("hybrid=98 not_hybrid=2 unknown=0", figure.summary) + + def test_uptime_that_contradicts_the_instance_is_unknown(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [pool.sample(T0 + i * INTERVAL, pinned_peer=None if i in (40, 41) else "manual-hybrid") for i in range(100)] + # The node reports a fresh boot in the two samples where the link was down, + # but its instance_id did not change: that is no restart, so they are judged. + rows[40]["uptime"], rows[41]["uptime"] = "30", "330" + figure = self.evaluate(rows, 100) + self.assertEqual(figure.result, "fail", figure.summary) + self.assertIn("excluded=0", figure.summary) + self.assertEqual(pq_eval.restarts(rows), []) + self.assertEqual([row["uptime"] for _, row in pq_eval.sample_rows(rows)[39:43]], [rows[39]["uptime"], None, None, rows[42]["uptime"]]) + + # A restart between the sampler's uptime and getpqtransportinfo calls pairs the + # old uptime with the new instance_id once: only that uptime is unknown. + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [] + for i in range(100): + time = T0 + i * INTERVAL + if i == 50: + old_uptime = str(time - pool.boot) + pool.restart(time - 30) + rows.append(pool.sample(time)) + rows[50]["uptime"] = old_uptime + self.assertEqual([index for index, (_, row) in enumerate(pq_eval.sample_rows(rows)) if row["uptime"] is None], [50]) + self.assertEqual([restart.last_seen for restart in pq_eval.restarts(rows)], [T0 + 49 * INTERVAL]) + figure = self.evaluate(rows, 100) + self.assertEqual(figure.result, "pass", figure.summary) + self.assertIn("excluded=2", figure.summary) # 30 s and 330 s after the restart + + def test_low_coverage_is_unknown_not_judged(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + automatic = {"connection_type": "outbound-full-relay", "transport_pq": True, "transport_pq_status": "hybrid"} + # 15 of 100 samples missing and 5 bad: with the gaps it cannot be judged. + rows = [pool.sample(T0 + i * INTERVAL, pinned_peer=automatic if i < 5 else "manual-hybrid") for i in range(100) if i % 7 or i > 98] + self.assertEqual(len(rows), 85) + figure = self.evaluate(rows, 100) + self.assertEqual(figure.result, "unknown", figure.summary) + self.assertAlmostEqual(figure.coverage, 0.85) + self.assertEqual(self.evaluate(rows, 100, min_coverage=0.8).result, "fail") + + def test_each_expected_sample_counts_once(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + # 95 of 100 expected samples cannot show 99%, however often each one is recorded. + rows = [pool.sample(T0 + i * INTERVAL) for i in range(100) if i % 20] + self.assertEqual(len(rows), 95) + cases = { + "once": rows, + "each row six times": [row for row in rows for _ in range(6)], + "files that overlap": rows + rows[10:] + rows[40:], + "six sampler runs per expected sample": + [pool.sample(T0 + i * INTERVAL + offset) for i in range(100) if i % 20 for offset in range(-100, 101, 40)], + } + for label, case in cases.items(): + with self.subTest(label): + figure = self.evaluate(case, 100) + self.assertEqual((figure.result, figure.samples), ("unknown", 100), figure.summary) + self.assertIn("hybrid=95 not_hybrid=0 unknown=5 excluded=0", figure.summary) + self.assertAlmostEqual(figure.coverage, 0.95) + + def test_several_samples_for_one_expected_sample_count_as_the_least_favorable(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [pool.sample(T0 + i * INTERVAL) for i in range(100)] + + def with_extra(extra: Any, before: bool) -> list[Any]: + """rows with an extra sample a minute before or after expected samples 10 and 11.""" + offset = -60 if before else 60 + return rows + [extra(T0 + i * INTERVAL + offset) for i in (10, 11)] + cases = { + "not connected": (lambda time: pool.sample(time, pinned_peer=None), "fail", "hybrid=98 not_hybrid=2 unknown=0"), + "unanswered": (lambda time: pool.sample(time, down=True), "unknown", "hybrid=98 not_hybrid=0 unknown=2"), + "hybrid": (pool.sample, "pass", "hybrid=100 not_hybrid=0 unknown=0"), + } + for label, (extra, result, summary) in cases.items(): + for before in (True, False): + with self.subTest(label, before=before): + figure = self.evaluate(with_extra(extra, before), 100) + self.assertEqual((figure.result, figure.samples), (result, 100), figure.summary) + self.assertIn(summary, figure.summary) + # Not connected in one sample and unanswered in the other is a miss. + unanswered = [dict(row, sample_ok="0") if index in (10, 11) else row for index, row in enumerate(rows)] + figure = self.evaluate(unanswered + [pool.sample(T0 + i * INTERVAL + 60, pinned_peer=None) for i in (10, 11)], 100) + self.assertIn("hybrid=98 not_hybrid=2 unknown=0", figure.summary) + + # In a restart gap (the archive node restarts after expected sample 49), a sample + # excludes its expected sample from the figure, unless another one is a miss. + archive = Node("archive", boot=T0 - DAY) + archive_rows = [archive.sample(T0 + i * INTERVAL) for i in range(50)] + archive.restart(T0 + 50 * INTERVAL - 60) + archive_rows += [archive.sample(T0 + i * INTERVAL) for i in range(50, 100)] + figure = self.evaluate(rows, 100, archive_rows) + self.assertIn("hybrid=98 not_hybrid=0 unknown=0 excluded=2", figure.summary) + in_gap = pool.sample(T0 + 49 * INTERVAL + 60) + figure = self.evaluate(rows + [in_gap], 100, archive_rows) + self.assertIn("hybrid=97 not_hybrid=0 unknown=0 excluded=3", figure.summary) + missed = [dict(row, pinned_present="0") if index == 49 else row for index, row in enumerate(rows)] + figure = self.evaluate(missed + [in_gap], 100, archive_rows) + self.assertIn("hybrid=97 not_hybrid=1 unknown=0 excluded=2", figure.summary) + + def test_repeated_rows_do_not_outvote_the_boot_time(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + rows = [pool.sample(T0 + i * INTERVAL, pinned_peer=None if i in (40, 41) else "manual-hybrid") for i in range(100)] + # A fresh boot in the two misses, without a new instance_id, is no restart: they are judged. + rows[40]["uptime"], rows[41]["uptime"] = "30", "330" + for copies in (1, 100): + with self.subTest(copies=copies): + figure = self.evaluate(rows + [dict(rows[index]) for index in (40, 41) for _ in range(copies - 1)], 100) + self.assertEqual(figure.result, "fail", figure.summary) + self.assertIn("hybrid=98 not_hybrid=2 unknown=0 excluded=0", figure.summary) + + +# --------------------------------------------------------------------------- +# Figures 2 and 3: failure entries +# --------------------------------------------------------------------------- + + +class FailureEntriesTest(unittest.TestCase): + ADDRESSES = ("203.0.113.5", "198.51.100.7", "2001:db8::5", "192.0.2.") + + def evaluate(self, records: list[dict[str, Any]], *, good: Any = None, monitored: list[str] | None = None, + min_coverage: float = 0.9, end: int | None = None) -> tuple[Any, Any]: + win = pq_eval.Window(T0, max(record["time"] for record in records) if end is None else end) + hosts = sorted({record["host"] for record in records}) if monitored is None else monitored + return pq_eval.evaluate_failures(records, known_good() if good is None else good, hosts, win, INTERVAL, min_coverage) + + def assert_no_addresses(self, *figures: Any) -> None: + text = " ".join(" ".join([figure.summary] + figure.details) for figure in figures) + for address in self.ADDRESSES: + self.assertNotIn(address, text) + + def test_monitored_outbound_failure_survives_an_inbound_flood(self) -> None: + first = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + flood = [entry(seq, T0 + 100, "192.0.2.200", 40000 + seq, "malformed_record", direction="inbound", + reason="ek_length", connection_type="inbound") for seq in range(45, 301)] + monitored = entry(1, T0 + 50, "203.0.113.5", 8333, "first_packet_failed") + second = record(T0 + INTERVAL, inbound=ring(300, 44, flood), outbound=ring(1, 0, [monitored])) + figure, triage = self.evaluate([first, second]) + self.assertEqual(figure.result, "fail") + self.assertIn("entries=1", figure.summary) + self.assertTrue(any("first_packet_failed/tag" in line and "known_good=archive" in line for line in figure.details)) + # 300 inbound failures between two samples overflow the ring: those lost entries are unknown. + self.assertTrue(any("unknown archive inbound" in line and "sequences 1..44 were lost" in line for line in figure.details)) + self.assertEqual(triage.result, "unknown") + self.assert_no_addresses(figure, triage) + + def test_wrapped_ring_without_loss_stays_judged(self) -> None: + def unrelated(first: int, last: int) -> list[dict[str, Any]]: + return [entry(seq, T0 + seq, "192.0.2.9", 9000 + seq, "malformed_record", direction="inbound") for seq in range(first, last + 1)] + # The ring holds 256 entries and has wrapped twice, but every sample reaches back past the previous one. + records = [record(T0, inbound=ring(256, 0, unrelated(1, 256)), outbound=ring(0, 0, [])), + record(T0 + INTERVAL, inbound=ring(300, 44, unrelated(45, 300)), outbound=ring(0, 0, [])), + record(T0 + 2 * INTERVAL, inbound=ring(556, 300, unrelated(301, 556)), outbound=ring(0, 0, []))] + figure, triage = self.evaluate(records) + self.assertEqual((figure.result, triage.result), ("pass", "pass"), figure.details) + self.assertIn("unknown_intervals=0", figure.summary) + # It is judged, so a known-good failure inside the wrapped stretch fails it. + records[2]["recent_failures"]["inbound"]["entries"][-1] = entry( + 556, T0 + 556, "198.51.100.7", 50000, "first_packet_failed", direction="inbound") + figure, _ = self.evaluate(records) + self.assertEqual(figure.result, "fail") + self.assertIn("known_good=pool", figure.details[0]) + + def test_wrap_that_loses_entries_is_unknown(self) -> None: + unrelated = [entry(seq, T0 + seq, "192.0.2.9", 9000 + seq, "malformed_record", direction="inbound") for seq in range(1, 301)] + first = record(T0, inbound=ring(10, 0, unrelated[:10]), outbound=ring(0, 0, [])) + wrapped = record(T0 + INTERVAL, inbound=ring(300, 44, unrelated[44:]), outbound=ring(0, 0, [])) + figure, _ = self.evaluate([first, wrapped]) + self.assertEqual(figure.result, "unknown") + self.assertIn("sequences 11..44 were lost when the ring wrapped", " ".join(figure.details)) + # Read again with nothing new, the ring is complete. + self.assertEqual(self.evaluate([first, record(T0 + INTERVAL, inbound=ring(10, 0, unrelated[:10]), + outbound=ring(0, 0, []))])[0].result, "pass") + + def test_inconsistent_rings_are_unknown(self) -> None: + entries = [entry(seq, T0 + seq, "192.0.2.9", 9000 + seq, "malformed_record", direction="inbound") for seq in range(1, 11)] + first = record(T0, inbound=ring(10, 0, entries), outbound=ring(0, 0, [])) + cases = { + "dropped changed without new entries": ring(10, 3, entries[3:]), + "went backwards": ring(5, 0, entries[:5]), + "malformed ring": ring(10, 0, entries[:4] + entries[5:]), + } + for reason, inbound in cases.items(): + with self.subTest(reason): + figure, _ = self.evaluate([first, record(T0 + INTERVAL, inbound=inbound, outbound=ring(0, 0, []))]) + self.assertEqual(figure.result, "unknown") + self.assertIn(reason, " ".join(figure.details)) + + def test_unrelated_noise_does_not_fail(self) -> None: + noise = [entry(seq, T0 + seq, "192.0.2.9", 8333, outcome) for seq, outcome in + enumerate(["malformed_record", "first_packet_failed", "fallback", "closed_after_switch"], 1)] + records = [record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])), + record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(4, 0, noise)), + record(T0 + 2 * INTERVAL, inbound=ring(0, 0, []), outbound=ring(4, 0, noise))] + figure, triage = self.evaluate(records) + self.assertEqual((figure.result, figure.samples), ("pass", 3)) + self.assertEqual(triage.result, "pass") + self.assertAlmostEqual(figure.coverage, 1.0) + + def test_malformed_endpoint_is_unknown(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + + def evaluate(outcome: str, endpoint: Any) -> tuple[Any, Any]: + failure = entry(1, T0 + 5, "192.0.2.9", 8333, outcome) + if endpoint is ...: + del failure["endpoint"] + else: + failure["endpoint"] = endpoint + return self.evaluate([start, record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(1, 0, [failure]))]) + + good = {"kind": "address", "network": "ipv4", "address": "192.0.2.9", "port": 8333} + malformed = { + "absent": ..., "null": None, "not an object": "192.0.2.9:8333", + "no address": {key: value for key, value in good.items() if key != "address"}, + "address not a string": dict(good, address=3221225993), "empty address": dict(good, address=""), + "address with a port": dict(good, address="192.0.2.9:8333"), "ipv6 address as ipv4": dict(good, address="2001:db8::9"), + "no port": {key: value for key, value in good.items() if key != "port"}, "port as text": dict(good, port="8333"), + "port true": dict(good, port=True), "port out of range": dict(good, port=65536), "negative port": dict(good, port=-1), + "unknown kind": dict(good, kind="service"), "kind a list": dict(good, kind=["address"]), + "unknown network": dict(good, network="tor"), + "kind and network disagree": dict(good, kind="name_proxy"), + } + for outcome in ("malformed_record", "first_packet_failed", "fallback", "closed_after_switch"): + monitored = outcome != "closed_after_switch" + for label, endpoint in malformed.items(): + with self.subTest(outcome=outcome, endpoint=label): + figure, triage = evaluate(outcome, endpoint) + judged, other = (figure, triage) if monitored else (triage, figure) + self.assertEqual((judged.result, other.result), ("unknown", "pass"), judged.details) + self.assertIn(f"{outcome} entry 1 has a missing or malformed endpoint", " ".join(judged.details)) + self.assert_no_addresses(figure, triage) + # Valid endpoints of unrelated peers, on every network, are judged clean. + valid = [good, dict(good, network="ipv6", address="2001:db8::9"), dict(good, network="cjdns", address="fc00::9"), + dict(good, network="onion", address="example.onion"), dict(good, network="i2p", address="example.b32.i2p", port=0), + {"kind": "name_proxy", "network": "name_proxy", "address": "seed.example.org", "port": 8333}] + for endpoint in valid: + with self.subTest(valid=endpoint["network"]): + self.assertEqual(tuple(figure.result for figure in evaluate("fallback", endpoint)), ("pass", "pass")) + # Outcomes outside both figures do not need an endpoint. + self.assertEqual(evaluate("legacy_peer", None)[0].result, "pass") + # A malformed endpoint that still names a known-good peer is that peer's failure. + figure, _ = evaluate("fallback", {"address": "203.0.113.5", "port": 8333}) + self.assertEqual(figure.result, "fail") + self.assertIn("known_good=archive", figure.details[0]) + + def test_malformed_rings_are_unknown(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + failure = entry(1, T0 + 5, "192.0.2.9", 8333, "malformed_record", direction="inbound") + cases: dict[str, Any] = { + "recent_failures absent": ..., "recent_failures null": None, "recent_failures a list": [failure], + "recent_failures text": "inbound", + "last_sequence true": ring(True, 0, [failure]), "dropped true": ring(1, True, [failure]), + "negative dropped": ring(1, -1, [failure]), "sequence true": ring(1, 0, [dict(failure, sequence=True)]), + "sequence 0": ring(0, 0, [dict(failure, sequence=0)]), "entry not an object": ring(1, 0, ["entry"]), + } + for label, value in cases.items(): + with self.subTest(label): + later = record(T0 + INTERVAL, inbound=value, outbound=ring(0, 0, [])) + if label.startswith("recent_failures"): + if value is ...: + del later["recent_failures"] + else: + later["recent_failures"] = value + figure, triage = self.evaluate([start, later]) + self.assertEqual((figure.result, triage.result), ("unknown", "unknown")) + self.assertIn("malformed ring", " ".join(figure.details)) + + def test_malformed_outcome_is_unknown(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + + def evaluate(outcome: Any) -> tuple[Any, Any]: + failure = entry(1, T0 + 5, "192.0.2.9", 8333, "fallback") + if outcome is ...: + del failure["outcome"] + else: + failure["outcome"] = outcome + return self.evaluate([start, record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(1, 0, [failure]))]) + + # A malformed outcome may be a monitored or a triage one: both figures are unknown. + for label, outcome in (("absent", ...), ("null", None), ("unknown word", "timeout"), ("capitalized", "Fallback"), + ("number", 3), ("object", {"outcome": "fallback"})): + with self.subTest(label): + figures = evaluate(outcome) + self.assertEqual(tuple(figure.result for figure in figures), ("unknown", "unknown")) + for figure in figures: + self.assertIn("entry 1 has a missing or malformed outcome", " ".join(figure.details)) + # The other outcomes in the vocabulary count towards neither figure. + for outcome in ("legacy_peer", "abandoned", "internal_error"): + with self.subTest(outcome): + self.assertEqual(tuple(figure.result for figure in evaluate(outcome)), ("pass", "pass")) + + def test_implausible_entry_time_is_unknown(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + + def evaluate(time: Any) -> Any: + # A known-good failure: with its time it fails the figure, or is outside the window. + failure = entry(1, T0 + 5, "203.0.113.5", 8333, "first_packet_failed") + if time is ...: + del failure["time"] + else: + failure["time"] = time + later = record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(1, 0, [failure])) + return self.evaluate([start, later], end=T0 + 2 * INTERVAL)[0] + + implausible = {"absent": ..., "null": None, "text": str(T0 + 5), "true": True, "negative": -1, + "beyond year 9999": 10**12, "milliseconds": (T0 + 5) * 1000, + "after the sample that read it": T0 + INTERVAL + 301, "before its process started": T0 - DAY - 301} + for label, time in implausible.items(): + with self.subTest(label): + figure = evaluate(time) + self.assertEqual(figure.result, "unknown", figure.details) + self.assertIn("entry 1 has a missing or implausible time", " ".join(figure.details)) + self.assert_no_addresses(figure) + # Within the clock slack it is judged by its time: in the window, or before it. + self.assertEqual(evaluate(T0 + INTERVAL + 300).result, "fail") + # First read long before the window, it was recorded before the window, whatever its time says. + held = ring(1, 0, [dict(entry(1, 0, "203.0.113.5", 8333, "fallback"), time="x")]) + early, later = (record(time, inbound=ring(0, 0, []), outbound=held) for time in (T0 - DAY + 600, T0)) + self.assertEqual(self.evaluate([early, later])[0].result, "pass") + figure = evaluate(T0 - DAY - 300) + self.assertEqual(figure.result, "unknown") # only the coverage: 2 of 3 samples + self.assertIn("entries=0 unknown_intervals=0", figure.summary) + + def test_malformed_fallback_set_entry_is_unknown(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + unrelated = {"endpoint": {"kind": "address", "network": "ipv4", "address": "192.0.2.9", "port": 8333}, + "cause": "first_packet_failed", "reason": "tag", "streak": 3, "entered": T0 + 5, "expires": T0 + 3605, + "window_seconds": 3600} + malformed = {"not an object": "192.0.2.9:8333", "null": None, "a list": [unrelated], + "no endpoint": {key: value for key, value in unrelated.items() if key != "endpoint"}, + "malformed endpoint": dict(unrelated, endpoint={"address": "192.0.2.9"})} + for label, item in malformed.items(): + with self.subTest(label): + # Held over three samples, it is one unknown entry. + records = [start] + [record(T0 + i * INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, []), fallback_set=[item]) + for i in (1, 2, 3)] + figure, triage = self.evaluate(records) + self.assertEqual((figure.result, triage.result), ("unknown", "pass")) + self.assertEqual(sum("fallback_set" in detail and "without a well-formed endpoint" in detail + for detail in figure.details), 1, figure.details) + self.assert_no_addresses(figure, triage) + figure, _ = self.evaluate([start, record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, []), fallback_set=[unrelated])]) + self.assertEqual(figure.result, "pass") + + def test_malformed_since_is_unknown(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + cases = {"absent": ..., "null": None, "text": str(T0 - DAY), "true": True, "negative": -1, "beyond year 9999": 10**12, + "after the sample": T0 + INTERVAL + 301} + reasons = dict.fromkeys(cases, "since missing, malformed or after the sample") + cases["changed within one instance_id"] = T0 - DAY + 1 + reasons["changed within one instance_id"] = "since changed within one instance_id" + for label, since in cases.items(): + with self.subTest(label): + later = record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, []), since=since) + if since is ...: + del later["since"] + after = record(T0 + 2 * INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + figure, _ = self.evaluate([start, later, after]) + self.assertEqual(figure.result, "unknown") + self.assertIn(reasons[label], " ".join(figure.details)) + # A process that started while the sampler ran is not after the sample. + self.assertEqual(self.evaluate([record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, []), since=T0 + 300)])[0].result, "pass") + + def test_report_prints_only_well_formed_values(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + # Known-good failures whose other fields are malformed: still failures, and an + # address in a malformed field never reaches the report. + failure = dict(entry(1, T0 + 5, "203.0.113.5", 8333, "first_packet_failed"), + reason="203.0.113.9", connection_type="2001:db8::7", peer_id="198.51.100.20") + held = {"endpoint": {"kind": "address", "network": "ipv6", "address": "2001:db8::5", "port": 8333}, "cause": ["tag"], + "reason": "198.51.100.20", "streak": True, "entered": {"time": T0}, "expires": 10**30} + later = record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(1, 0, [failure]), fallback_set=[held, held]) + figure, triage = self.evaluate([start, later]) + self.assertEqual(figure.result, "fail") + self.assertIn("entries=2", figure.summary) + self.assertIn("first_packet_failed/malformed conn_type=malformed peer_id=malformed known_good=archive", figure.details[0]) + self.assertEqual(figure.details[1], "malformed archive fallback_set cause=malformed/malformed streak=malformed " + "expires=malformed known_good=known-good-3") + text = " ".join(figure.details + triage.details) + for address in ("203.0.113.9", "2001:db8::7", "198.51.100.20"): + self.assertNotIn(address, text) + self.assert_no_addresses(figure, triage) + + def test_restart_is_an_unknown_interval(self) -> None: + first = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + restarted = record(T0 + 2 * INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, []), + instance="b" * 64, since=T0 + INTERVAL) + figure, _ = self.evaluate([first, restarted], min_coverage=0.5) + self.assertEqual(figure.result, "unknown") + self.assertIn("instance_id changed", " ".join(figure.details)) + # A fresh process whose rings are complete since startup is clean. + fresh = record(T0, inbound=ring(0, 0, []), outbound=ring(1, 0, [entry(1, T0 - 5, "192.0.2.1", 1, "fallback")]), + since=T0 - 10) + self.assertEqual(self.evaluate([fresh])[0].result, "pass") + incomplete = record(T0, inbound=ring(0, 0, []), outbound=ring(300, 44, []), since=T0 - 10) + figure, _ = self.evaluate([incomplete]) + self.assertEqual(figure.result, "unknown") + self.assertIn("sequences 1..300 were dropped before the first sample", " ".join(figure.details)) + + def test_known_good_failures(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + cases: dict[str, tuple[dict[str, Any], dict[str, Any], list[Any], str]] = { + "inbound from the pool node, any source port": + (ring(1, 0, [entry(1, T0 + 5, POOL_ADDRESS, 50123, "malformed_record", direction="inbound")]), ring(0, 0, []), [], "pool"), + "fallback entry": + (ring(0, 0, []), ring(1, 0, [entry(1, T0 + 5, "203.0.113.5", 8333, "fallback", reason="first_packet_failed")]), [], "archive"), + "fallback_set": + (ring(0, 0, []), ring(0, 0, []), [{"endpoint": {"kind": "address", "network": "ipv6", "address": "2001:db8::5", "port": 8333}, + "cause": "closed_after_switch", "reason": "eof", "streak": 3, + "entered": T0 + 5, "expires": T0 + 3605, "window_seconds": 3600}], "known-good-3"), + } + for label, (inbound, outbound, fallback_set, name) in cases.items(): + with self.subTest(label): + figure, triage = self.evaluate([start, record(T0 + INTERVAL, inbound=inbound, outbound=outbound, fallback_set=fallback_set)]) + self.assertEqual(figure.result, "fail") + self.assertIn("entries=1", figure.summary) + self.assertIn(f"known_good={name}", figure.details[0]) + self.assert_no_addresses(figure, triage) + + def test_known_good_address_in_any_spelling(self) -> None: + # The node reports an IPv6 address compressed; the list may spell it out, and either + # side may write an IPv4 address as IPv4-mapped IPv6. + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + cases = (("[2001:0DB8:0:0:0:0:0:5]:8333 peer", "2001:db8::5"), ("[2001:db8:0:0::5] peer", "2001:db8::5"), + ("[2001:db8::5]:8333 peer", "2001:0db8:0:0:0:0:0:5"), ("[2001:db8::5] peer", "2001:0db8:0:0:0:0:0:5"), + ("203.0.113.5:8333 peer", "::ffff:203.0.113.5")) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "known-good.txt" + for line, address in cases: + with self.subTest(line=line, address=address): + path.write_text(line + "\n", encoding="utf8") + failed = record(T0 + INTERVAL, inbound=ring(0, 0, []), + outbound=ring(1, 0, [entry(1, T0 + 5, address, 8333, "first_packet_failed")])) + figure, triage = self.evaluate([start, failed], good=pq_eval.read_known_good(path)) + self.assertEqual(figure.result, "fail", figure.details) + self.assertIn("known_good=peer", figure.details[0]) + self.assert_no_addresses(figure, triage) + + def test_missing_instance_id_is_unknown(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + for label, value in (("null", None), ("short", "abc"), ("number", 7), ("list", ["a" * 64])): + with self.subTest(label): + broken = record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(1, 0, [entry(1, T0 + 5, "192.0.2.9", 1, "fallback")]), + instance=value) + after = record(T0 + 2 * INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + figure, _ = self.evaluate([start, broken, after]) + self.assertEqual(figure.result, "unknown") + self.assertTrue(any("instance_id missing or malformed" in detail for detail in figure.details)) + + def test_missing_fallback_set_is_unknown(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + for label, value in (("null", None), ("not a list", {"endpoint": "x"}), ("absent", ...)): + with self.subTest(label): + later = record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + if value is ...: + del later["fallback_set"] + else: + later["fallback_set"] = value + figure, _ = self.evaluate([start, later]) + self.assertEqual(figure.result, "unknown") + self.assertTrue(any("fallback_set missing or malformed" in detail for detail in figure.details)) + + def test_closed_after_switch_is_listed_for_triage(self) -> None: + closed = entry(1, T0 + 5, "2001:db8::5", 8333, "closed_after_switch", reason="eof", connection_type="block-relay-only") + records = [record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])), + record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(1, 0, [closed]))] + figure, triage = self.evaluate(records) + self.assertEqual(figure.result, "pass") + self.assertEqual(triage.result, "fail") + self.assertIn("known_good=known-good-3", triage.details[0]) + self.assert_no_addresses(figure, triage) + + def test_every_monitored_node_needs_evidence(self) -> None: + archive = [record(T0 + i * INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) for i in range(3)] + figure, _ = self.evaluate(archive, monitored=["archive", "pool"]) + self.assertEqual(figure.result, "unknown") + self.assertIn("unknown pool: coverage 0.0% is below 90.0%", figure.details) + self.assertIn("nodes=archive:100.0%,pool:0.0%", figure.summary) + pool = [dict(item, host="pool") for item in archive] + self.assertEqual(self.evaluate(archive + pool, monitored=["archive", "pool"])[0].result, "pass") + + def test_low_coverage_is_unknown(self) -> None: + # Records for half of the expected samples; the rings say nothing was lost. + records = [record(T0 + i * INTERVAL, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) for i in range(0, 10, 2)] + figure, _ = self.evaluate(records, end=T0 + 9 * INTERVAL) + self.assertEqual(figure.result, "unknown") + self.assertAlmostEqual(figure.coverage, 0.5) + self.assertEqual(self.evaluate(records, end=T0 + 9 * INTERVAL, min_coverage=0.5)[0].result, "pass") + # Repeated records, files given twice and extra sampler runs add no coverage. + for label, case in (("repeated", records * 3), + ("extra runs", records + [dict(item, time=item["time"] + offset) for item in records for offset in (-100, 100)])): + with self.subTest(label): + figure, _ = self.evaluate(case, end=T0 + 9 * INTERVAL) + self.assertEqual((figure.result, figure.samples), ("unknown", 5)) + self.assertAlmostEqual(figure.coverage, 0.5) + + def test_coverage_counts_only_expected_samples_in_the_window(self) -> None: + # Records for expected samples 0 to 8, and one 160 s after sample 9: nearest to + # sample 10, which is after the window, so sample 9 is missing. + def empty(time: int) -> dict[str, Any]: + return record(time, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + records = [empty(T0 + i * INTERVAL) for i in range(9)] + [empty(T0 + 9 * INTERVAL + 160)] + figure, _ = self.evaluate(records, min_coverage=0.95) + self.assertEqual((figure.result, figure.samples), ("unknown", 9)) + self.assertAlmostEqual(figure.coverage, 0.9) + # Likewise a record 100 s before the window starts is nearest to sample 0, but not in the window. + records = [empty(T0 - 100)] + [empty(T0 + i * INTERVAL) for i in range(1, 10)] + figure, _ = self.evaluate(records, min_coverage=0.95) + self.assertEqual((figure.result, figure.samples), ("unknown", 9)) + + def test_repeated_records_count_each_entry_once(self) -> None: + start = record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])) + failed = record(T0 + INTERVAL, inbound=ring(0, 0, []), outbound=ring(1, 0, [entry(1, T0 + 5, "203.0.113.5", 8333, "first_packet_failed")])) + once = self.evaluate([start, failed]) + twice = self.evaluate([start, failed, start, failed]) + self.assertEqual([(figure.result, figure.samples, figure.summary, figure.details) for figure in twice], + [(figure.result, figure.samples, figure.summary, figure.details) for figure in once]) + self.assertIn("entries=1", twice[0].summary) + + def test_missing_evidence_is_unknown(self) -> None: + records = [record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, []))] + win = pq_eval.Window(T0, T0) + self.assertEqual(pq_eval.evaluate_failures(None, known_good(), ["archive"], win, INTERVAL, 0.9)[0].result, "unknown") + empty = pq_eval.KnownGood({}, {}) + self.assertEqual(pq_eval.evaluate_failures(records, empty, ["archive"], win, INTERVAL, 0.9)[0].result, "unknown") + self.assertEqual(pq_eval.evaluate_failures(records, known_good(), [], win, INTERVAL, 0.9)[0].result, "unknown") + self.assertEqual(pq_eval.evaluate_failures([], known_good(), ["archive"], win, INTERVAL, 0.9)[0].result, "unknown") + # A node that stopped reporting leaves the rest of the window unknown. + figure, _ = self.evaluate(records, end=T0 + 10 * INTERVAL, min_coverage=0.05) + self.assertEqual(figure.result, "unknown") + self.assertIn("no samples since", " ".join(figure.details)) + broken = record(T0, inbound=ring(0, 0, []), outbound={"entries": []}) + self.assertEqual(self.evaluate([broken])[0].result, "unknown") + + def test_known_good_file(self) -> None: + good = known_good() + self.assertEqual(good.endpoints, {"203.0.113.5:8333": "archive", "[2001:db8::5]:8333": "known-good-3"}) + self.assertEqual(good.addresses, {POOL_ADDRESS: "pool"}) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "known-good.txt" + bad = ["2001:db8::1:8333", "198.51.100.1:port", "[2001:db8::1", ":8333", "198.51.100.1:0", "198.51.100.1:70000", + "198.51.100.1 two labels", "198.51.100.1 bad/label", "198.51.100.1\n198.51.100.1", + "[2001:db8::1]\n[2001:0db8:0:0:0:0:0:1]", "198.51.100.1:8333\n[::ffff:198.51.100.1]:8333"] + for line in bad: + path.write_text(line.replace("\\n", "\n") + "\n", encoding="utf8") + with self.subTest(line), self.assertRaises(ValueError) as raised: + pq_eval.read_known_good(path) + self.assertNotIn("198.51.100.1", str(raised.exception).replace(str(path), "")) + self.assertNotIn("2001:db8", str(raised.exception)) + path.write_text("[2001:db8::1]\nExample.org:8333 seed # comment\n", encoding="utf8") + good = pq_eval.read_known_good(path) + self.assertEqual((good.endpoints, good.addresses), ({"example.org:8333": "seed"}, {"2001:db8::1": "known-good-1"})) + # Each IP address has one spelling: compressed, lower case, and IPv4 for an IPv4-mapped address. + path.write_text("[2001:0DB8:0:0:0:0:0:1]\n[2001:db8:0::0:5]:8333 peer\n[::ffff:203.0.113.5]:8333 archive\n", encoding="utf8") + good = pq_eval.read_known_good(path) + self.assertEqual(good.endpoints, {"[2001:db8::5]:8333": "peer", "203.0.113.5:8333": "archive"}) + self.assertEqual(good.addresses, {"2001:db8::1": "known-good-1"}) + + +# --------------------------------------------------------------------------- +# Figure 4: connections_in against the control +# --------------------------------------------------------------------------- + + +class ConnectionsTest(unittest.TestCase): + BASELINE_DAYS = 3 + CANARY_DAYS = 4 + + def series(self, archive_in: Any, control_in: Any, *, archive_restart: int | None = None, + control_down: range | None = None) -> tuple[list[Any], list[Any], Any]: + archive = Node("archive", boot=T0 - 60 * DAY) + control = Node("control", boot=T0 - 60 * DAY, v1_0_0=True) + archive_rows, control_rows = [], [] + per_day = DAY // INTERVAL + for index in range(-self.BASELINE_DAYS * per_day, self.CANARY_DAYS * per_day): + time = T0 + index * INTERVAL + if archive_restart is not None and index == archive_restart: + archive.restart(time - 60) + archive_rows.append(archive.sample(time, connections_in=archive_in(index))) + down = control_down is not None and index in control_down + control_rows.append(control.sample(time, connections_in=control_in(index), down=down)) + end = T0 + (self.CANARY_DAYS * per_day - 1) * INTERVAL + return archive_rows, control_rows, pq_eval.Window(T0, end) + + def evaluate(self, archive_rows: Any, control_rows: Any, win: Any, baseline_ratio: Fraction | None = None, + min_coverage: float = 0.9) -> Any: + return pq_eval.evaluate_connections(archive_rows, control_rows, T0, win, INTERVAL, baseline_ratio, min_coverage) + + def test_within_tolerance(self) -> None: + # The archive runs at 1.2 times the control in the baseline; 1.3 times is within 20% of that. + figure = self.evaluate(*self.series(lambda i: 36 if i < 0 else 39, lambda i: 30)) + self.assertEqual(figure.result, "pass", figure.details) + self.assertIn("baseline_ratio=1.200", figure.summary) + self.assertEqual(figure.samples, self.CANARY_DAYS * DAY // INTERVAL) + + def test_outside_tolerance(self) -> None: + figure = self.evaluate(*self.series(lambda i: 36 if i < 288 else 25, lambda i: 30)) + self.assertEqual(figure.result, "fail") + self.assertTrue(any(" fail: relative=0.694" in line for line in figure.details)) + + def test_tolerance_is_inclusive(self) -> None: + # Exactly 20% off the baseline ratio passes. In floats, 24/10 against a + # baseline of 3 is 0.7999999999999999, and 108/10 against 9 is 1.2000000000000002. + for baseline_in, canary_in, result in ((30, 24, "pass"), (30, 23, "fail"), (90, 108, "pass"), (90, 109, "fail")): + with self.subTest(baseline_in=baseline_in, canary_in=canary_in): + figure = self.evaluate(*self.series(lambda i, b=baseline_in, c=canary_in: b if i < 0 else c, lambda i: 10)) + self.assertEqual(figure.result, result, figure.details) + # A given ratio is read exactly too: the float nearest 1.2 is below it, + # which would put 36/25 against it just outside 20%. + self.assertEqual(pq_eval.ratio_arg("1.2"), Fraction(6, 5)) + archive_rows, control_rows, win = self.series(lambda i: 36, lambda i: 25) + self.assertEqual(self.evaluate(archive_rows, control_rows, win, baseline_ratio=pq_eval.ratio_arg("1.2")).result, "pass") + + def test_48_hours_after_a_restart_are_excluded(self) -> None: + # Inbound connections recover slowly after the restart at the canary start. + archive_rows, control_rows, win = self.series( + lambda i: 36 if i < 0 or i >= 2 * 288 else 5, lambda i: 30, archive_restart=0) + figure = self.evaluate(archive_rows, control_rows, win) + self.assertEqual(figure.result, "pass", figure.details) + self.assertEqual(sum(" excluded:" in line for line in figure.details), 2) + + def test_missing_measurements_are_unknown(self) -> None: + archive_rows, control_rows, win = self.series(lambda i: 36, lambda i: 30, control_down=range(288, 2 * 288)) + figure = self.evaluate(archive_rows, control_rows, win) + self.assertEqual(figure.result, "unknown", figure.details) + archive_rows, control_rows, win = self.series(lambda i: 36, lambda i: None if 0 <= i < 288 else 30) + self.assertEqual(self.evaluate(archive_rows, control_rows, win).result, "unknown") + # Without a baseline there is nothing to compare against, unless one is given. + archive_rows, control_rows, win = self.series(lambda i: 36, lambda i: 30) + canary_only = ([row for row in archive_rows if int(row["time"]) >= T0], [row for row in control_rows if int(row["time"]) >= T0]) + self.assertEqual(self.evaluate(*canary_only, win).result, "unknown") + self.assertEqual(self.evaluate(*canary_only, win, baseline_ratio=Fraction(6, 5)).result, "pass") + self.assertEqual(pq_eval.evaluate_connections(archive_rows, None, T0, win, INTERVAL, None, 0.9).result, "unknown") + + def test_zero_baseline_is_unknown(self) -> None: + # Valid pre-canary pairs whose connections_in sum to 0 on one side give no usable ratio. + pairs = self.BASELINE_DAYS * DAY // INTERVAL + cases = { + "archive": (lambda i: 0 if i < 0 else 36, lambda i: 30), + "control": (lambda i: 36, lambda i: 0 if i < 0 else 30), + } + for node, (archive_in, control_in) in cases.items(): + with self.subTest(node=node): + archive_rows, control_rows, win = self.series(archive_in, control_in) + figure = self.evaluate(archive_rows, control_rows, win) + self.assertEqual(figure.result, "unknown") + self.assertEqual(figure.summary, f"no baseline: {node} connections_in summed to 0 over {pairs} valid pre-canary pairs") + self.assertEqual((figure.samples, figure.coverage), (0, None)) + # A given ratio still judges the canary days. + self.assertEqual(self.evaluate(archive_rows, control_rows, win, baseline_ratio=Fraction(6, 5)).result, "pass") + + def test_malformed_numbers_are_missing(self) -> None: + # Day 0 holds the baseline ratio; the archive then loses a third of its connections. + archive_rows, control_rows, win = self.series(lambda i: 36 if i < 288 else 24, lambda i: 30) + self.assertEqual(self.evaluate(archive_rows, control_rows, win).result, "fail") + later = T0 + DAY + for label, uptime in (("negative", "-1"), ("non-ASCII digits", "\u0661")): + with self.subTest(uptime=label): + # Not an uptime, so not a restart: the later days are unknown, not excluded. + broken = [dict(row, uptime=uptime) if int(row["time"]) >= later else row for row in archive_rows] + figure = self.evaluate(broken, control_rows, win) + self.assertEqual(figure.result, "unknown", figure.details) + self.assertFalse(any(" excluded:" in line for line in figure.details), figure.details) + for label, count in (("negative", "-30"), ("decimal", "30.5")): + with self.subTest(connections_in=label): + broken = [dict(row, connections_in=count) if int(row["time"]) >= later else row for row in control_rows] + self.assertEqual(self.evaluate(archive_rows, broken, win).result, "unknown") + + def test_unanswered_samples_are_not_pairs(self) -> None: + archive_rows, control_rows, win = self.series(lambda i: 36, lambda i: 30) + # sample_ok=0 says a call failed: its values are not a measurement, even when present. + control_rows = [dict(row, sample_ok="0") if T0 + DAY <= int(row["time"]) < T0 + 2 * DAY else row for row in control_rows] + figure = self.evaluate(archive_rows, control_rows, win) + self.assertEqual(figure.result, "unknown", figure.details) + self.assertEqual(figure.samples, (self.CANARY_DAYS - 1) * DAY // INTERVAL) + + def test_days_need_coverage(self) -> None: + # 30 of a full day's 288 samples missing on the control: 89.6% coverage. + archive_rows, control_rows, win = self.series(lambda i: 36, lambda i: 30) + gap = {T0 + index * INTERVAL for index in range(400, 430)} + control_rows = [row for row in control_rows if int(row["time"]) not in gap] + figure = self.evaluate(archive_rows, control_rows, win) + self.assertEqual(figure.result, "unknown") + self.assertTrue(any(line.startswith("2025-10-10 unknown:") and "coverage=89.6%" in line for line in figure.details), figure.details) + self.assertEqual(self.evaluate(archive_rows, control_rows, win, min_coverage=0.85).result, "pass") + # Recording the other samples twice does not fill the gap. + twice = control_rows + [dict(row, time=str(int(row["time"]) + 60)) for row in control_rows] + figure = self.evaluate(archive_rows * 2, twice, win) + self.assertEqual(figure.result, "unknown") + self.assertTrue(any(line.startswith("2025-10-10 unknown:") and "coverage=89.6%" in line for line in figure.details), figure.details) + + def test_several_samples_for_one_expected_sample_count_as_the_least_favorable(self) -> None: + archive_rows, control_rows, win = self.series(lambda i: 36, lambda i: 30) + full = self.CANARY_DAYS * DAY // INTERVAL + self.assertEqual(self.evaluate(archive_rows, control_rows, win).samples, full) + + def shifted(rows: list[Any], indexes: range, offset: int, **changes: str) -> list[Any]: + """Copies of the canary samples at indexes, offset seconds later, with changes.""" + return [dict(rows[self.BASELINE_DAYS * 288 + index], time=str(T0 + index * INTERVAL + offset), **changes) for index in indexes] + cases = { + # An unanswered sample a minute before some expected samples and after others. + "unanswered": shifted(control_rows, range(300, 310), -60, sample_ok="0") + shifted(control_rows, range(400, 410), 60, sample_ok="0"), + # A second measurement that disagrees with the first. + "disagreeing": shifted(control_rows, range(300, 310), -60, connections_in="31") + shifted(control_rows, range(400, 410), 60, connections_in="29"), + } + for label, extra in cases.items(): + with self.subTest(label): + figure = self.evaluate(archive_rows, control_rows + extra, win) + self.assertEqual(figure.samples, full - 20, figure.details) + # A measurement that agrees is the same measurement. + figure = self.evaluate(archive_rows, control_rows + shifted(control_rows, range(300, 310), -60), win) + self.assertEqual((figure.result, figure.samples), ("pass", full)) + + # A sample of the node 30 s after a restart excludes its expected sample, + # unless another sample for it is unusable. + restarted = Node("archive", boot=T0 + 300 * INTERVAL + 30) + restarted.instance = 1 + after_restart = restarted.sample(T0 + 300 * INTERVAL + 60) + figure = self.evaluate(archive_rows + [after_restart], control_rows, win) + self.assertEqual(figure.samples, full - 1) + self.assertTrue(any(" excluded=1 " in line for line in figure.details), figure.details) + unanswered = [dict(row, sample_ok="0") if int(row["time"]) == T0 + 300 * INTERVAL else row for row in archive_rows] + figure = self.evaluate(unanswered + [after_restart], control_rows, win) + self.assertEqual(figure.samples, full - 1) + self.assertFalse(any(" excluded=1 " in line for line in figure.details), figure.details) + + +# --------------------------------------------------------------------------- +# The command line +# --------------------------------------------------------------------------- + + +class EvaluatorCommandTest(unittest.TestCase): + # Every address in the fixtures; none may reach the report. + ADDRESSES = ("203.0.113.5", "203.0.113.9", "198.51.100.7", "198.51.100.20", "198.51.100.77", "2001:db8") + + @staticmethod + def run_evaluator(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([sys.executable, str(EVALUATOR), *args], text=True, capture_output=True) + + def write_samples(self, root: Path) -> None: + nodes = {"pool": Node("pool", boot=T0 - 3 * DAY, pinned=PINNED), "archive": Node("archive", boot=T0 - 3 * DAY), + "control": Node("control", boot=T0 - 10 * DAY, v1_0_0=True)} + for index in range(-2 * 288, 3 * 288): + time = T0 + index * INTERVAL + for name, node in nodes.items(): + row = node.sample(time) + pq_canary.append_row(root / f"{name}.csv", {key: NA if value is None else value for key, value in row.items()}) + failures = pq_canary.build_failures_record(time=time, host=name, pqinfo=node.pqinfo(time)) + if failures is not None and name != "control": + with (root / f"{name}.jsonl").open("a", encoding="utf8") as out: + out.write(json.dumps(failures) + "\n") + + def test_prints_every_figure_without_addresses(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.write_samples(root) + inputs = ["--canary-start", pq_eval.utc(T0), "--pool", str(root / "pool.csv"), "--archive", str(root / "archive.csv"), + "--control", str(root / "control.csv"), "--known-good", str(FIXTURES / "known-good.txt"), "--baseline-ratio", "1.0"] + result = self.run_evaluator(*inputs, "--failures", str(root / "pool.jsonl"), "--failures", str(root / "archive.jsonl")) + # The pool and archive nodes are monitored by default: one node's file is not enough. + one_node = self.run_evaluator(*inputs, "--failures", str(root / "archive.jsonl")) + lines = result.stdout.splitlines() + figures = [line for line in lines if line.split(" ", 1)[0] in ("pass", "fail", "unknown")] + self.assertEqual(len(figures), 4, result.stdout + result.stderr) + for line in figures: + self.assertIn("samples=", line) + self.assertIn("coverage=", line) + self.assertTrue(figures[0].startswith("pass ") and "pinned link" in figures[0], figures[0]) + self.assertTrue(figures[1].startswith("pass ") and "nodes=archive:100.0%,pool:100.0%" in figures[1], figures[1]) + # The fixture's closed_after_switch entry comes from a known-good IPv6 peer, reported by its label. + self.assertTrue(figures[2].startswith("fail ") and "closed_after_switch" in figures[2], figures[2]) + self.assertIn("known_good=known-good-3", result.stdout) + for address in self.ADDRESSES: + self.assertNotIn(address, result.stdout + result.stderr) + self.assertIn("canary verdict (figures 1, 2 and 4):", lines[-1]) + verdict = lines[-1].rsplit(" ", 1)[1] + self.assertEqual(result.returncode, {"pass": 0, "fail": 1, "unknown": 3}[verdict]) + + failures_line = next(line for line in one_node.stdout.splitlines() if "first_packet_failed or fallback" in line) + self.assertTrue(failures_line.startswith("unknown "), failures_line) + self.assertIn("unknown pool: coverage 0.0%", one_node.stdout) + + def test_sample_time_beyond_year_9999_is_not_a_sample(self) -> None: + pool = Node("pool", boot=T0 - DAY, pinned=PINNED) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "pool.csv" + for index in range(3): + row = pool.sample(T0 + index * INTERVAL) + pq_canary.append_row(path, {key: NA if value is None else value for key, value in dict(row, time=str(10**14) if index == 2 else row["time"]).items()}) + result = self.run_evaluator("--canary-start", str(T0), "--pool", str(path)) + self.assertNotIn("Traceback", result.stderr) + self.assertIn(f"PQ canary evaluation: {pq_eval.utc(T0)} to {pq_eval.utc(T0 + INTERVAL)}", result.stdout) + + def test_zero_baseline_is_reported_not_raised(self) -> None: + archive, control = Node("archive", boot=T0 - 60 * DAY), Node("control", boot=T0 - 60 * DAY, v1_0_0=True) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for index in range(-288, 288): + time = T0 + index * INTERVAL + for name, row in (("archive", archive.sample(time, connections_in=0 if index < 0 else 36)), ("control", control.sample(time))): + pq_canary.append_row(root / f"{name}.csv", {key: NA if value is None else value for key, value in row.items()}) + result = self.run_evaluator("--canary-start", str(T0), "--archive", str(root / "archive.csv"), "--control", str(root / "control.csv")) + self.assertEqual(result.returncode, 3, result.stdout + result.stderr) + self.assertNotIn("Traceback", result.stderr) + line = next(line for line in result.stdout.splitlines() if "connections_in within" in line) + self.assertTrue(line.startswith("unknown "), line) + self.assertIn("no baseline: archive connections_in summed to 0 over 288 valid pre-canary pairs", line) + + def test_argument_validation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.write_samples(root) + pool, failures, good = str(root / "pool.csv"), str(root / "archive.jsonl"), str(FIXTURES / "known-good.txt") + bad_csv = root / "bad.csv" + bad_csv.write_text("not,a,sample\n", encoding="utf8") + bad_good = root / "bad-known-good.txt" + bad_good.write_text("203.0.113.5:99999\n", encoding="utf8") + archive, control = str(root / "archive.csv"), str(root / "control.csv") + + def samples(name: str, *sources: str, **changes: str) -> str: + """A samples file with the rows of sources, with changes applied to each row.""" + for source in sources: + for row in pq_canary.read_rows(Path(source)): + pq_canary.append_row(root / name, {key: NA if value is None else value for key, value in dict(row, **changes).items()}) + return str(root / name) + start = ["--canary-start", str(T0)] + cases = { + "the following arguments are required: --canary-start": ["--pool", pool], + "expected Unix seconds or an ISO 8601 time": ["--canary-start", "yesterday", "--pool", pool], + "expected a time from 1970 to 9999, got '99999999999999'": start + ["--pool", pool, "--end", "99999999999999"], + "give at least one of --pool": start, + "unrecognized arguments: --bogus": start + ["--pool", pool, "--bogus"], + "unrecognized arguments: --pool-csv": start + ["--pool-csv", pool], + "unrecognized arguments: --canary-st": start + ["--canary-st", str(T0), "--pool", pool], + "no such file": start + ["--pool", str(root / "missing.csv")], + "expected a positive whole number": start + ["--pool", pool, "--interval", "0"], + "expected a whole number of seconds": start + ["--pool", pool, "--restart-grace", "-1"], + "expected a share greater than 0": start + ["--pool", pool, "--min-coverage", "1.5"], + "expected a positive ratio": start + ["--pool", pool, "--baseline-ratio", "inf"], + "expected a positive ratio, got '0'": start + ["--pool", pool, "--baseline-ratio", "0"], + "--failures needs --known-good": start + ["--failures", failures], + "--known-good is only used with --failures": start + ["--pool", pool, "--known-good", good], + "--monitored is only used with --failures": start + ["--pool", pool, "--monitored", "pool"], + "expected distinct comma-separated host labels": start + ["--failures", failures, "--known-good", good, "--monitored", "pool,pool"], + "--end is before --canary-start": start + ["--pool", pool, "--end", str(T0 - 1)], + "unexpected header": start + ["--pool", str(bad_csv)], + # Each role is a different node: never compare a node with itself. + "--archive and --control are the same file": start + ["--archive", archive, "--control", archive], + "--archive and --control both have the host label archive": + start + ["--archive", archive, "--control", samples("relabeled.csv", control, host="archive")], + "--archive mixes the host labels archive, control": start + ["--archive", samples("mixed.csv", archive, control)], + "--pool has a row without a valid host label": start + ["--pool", samples("no-host.csv", pool, host=NA)], + "--archive and --control report the same instance_id": + start + ["--archive", archive, "--control", samples("same-node.csv", archive, host="control")], + "--pool and --archive both have the host label pool": + start + ["--pool", pool, "--archive", samples("pool-as-archive.csv", archive, host="pool")], + "expected address or address:port with a port from 1 to 65535": start + ["--failures", failures, "--known-good", str(bad_good)], + } + for message, args in cases.items(): + with self.subTest(message): + result = self.run_evaluator(*args) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn(message, result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertNotIn("203.0.113.5", result.stderr) + # A failures.jsonl line that cannot be placed in time or attributed to a node. + for label, change in (("time true", {"time": True}), ("time text", {"time": str(T0)}), ("time negative", {"time": -1}), + ("time beyond year 9999", {"time": 10**12}), ("host null", {"host": None}), + ("host not a label", {"host": "archive node"}), ("host with a newline", {"host": "archive\n"})): + with self.subTest(label): + bad_failures = root / "bad.jsonl" + line = dict(record(T0, inbound=ring(0, 0, []), outbound=ring(0, 0, [])), **change) + bad_failures.write_text(json.dumps(line) + "\n", encoding="utf8") + result = self.run_evaluator(*start, "--failures", str(bad_failures), "--known-good", good) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn("a failures.jsonl line lacks a time in Unix seconds or a host label", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_time_parsing(self) -> None: + self.assertEqual(pq_eval.parse_time("1760000000"), T0) + self.assertEqual(pq_eval.parse_time(pq_eval.utc(T0)), T0) + self.assertEqual(pq_eval.parse_time("2025-10-09T08:53:20+00:00"), T0) + with self.assertRaises(ValueError): + pq_eval.parse_time("2025-13-01") + + +if __name__ == "__main__": + unittest.main() diff --git a/doc/design/pq-transport.md b/doc/design/pq-transport.md new file mode 100644 index 0000000000..5c8f9addc0 --- /dev/null +++ b/doc/design/pq-transport.md @@ -0,0 +1,525 @@ +# Hybrid post-quantum v2 transport + +This document specifies how two qbit nodes add an ML-KEM-1024 key exchange to +the [BIP324](https://github.com/bitcoin/bips/blob/master/bip-0324.mediawiki) v2 +P2P transport, so that their session keys stay confidential unless both +secp256k1 ECDH and ML-KEM-1024 are broken. It is written so that another +implementation can interoperate, and it is the starting point for outside +review. The tracking issue is +[Qbit-Org/qbit#184](https://github.com/Qbit-Org/qbit/issues/184). Operators +should read the [operator guide](../user/network/pq-transport.md) instead; the +[release note](../release-notes-240.md) summarizes the change. + + + +The key words MUST, MUST NOT, SHOULD and MAY are to be interpreted as described +in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and +[RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they +appear in all capitals. + +## Overview + +- **Threat.** A passive observer can record v2 traffic today and, with a + cryptographically relevant quantum computer, later recover the ECDH secret + from the two 64-byte ElligatorSwift keys at the start of each connection. + Hybrid keys protect such recordings. The symmetric layer (ChaCha20-Poly1305 + with FSChaCha20 rekeying every 224 packets) is unchanged. +- **Hybrid, and ECDH stays.** Session keys come from HKDF-SHA256 over the BIP324 + ECDH secret, an ML-KEM-1024 shared secret + ([FIPS 203](https://csrc.nist.gov/pubs/fips/203/final), NIST category 5) and + a hash of the negotiation transcript. +- **No extra round trip.** The responder puts its ML-KEM encapsulation key in + its BIP324 version packet. The initiator waits for that packet, which arrives + in the same flight as today, and puts the ciphertext in its own version + packet. Both sides switch keys right after the version packets, and each + side's first packet under the new keys is a 20-byte key confirmation. +- **Compatible.** Version packet contents are ignored by BIP324 receivers, so + legacy v2 peers keep today's ECDH-only keys with no disconnect, and v1 peers + are unaffected. No consensus rule changes. +- **Cost.** 3,184 extra bytes per hybrid connection (two 1,572-byte records and + two 20-byte confirmations), and one ML-KEM key generation, encapsulation and + decapsulation. + +```text +Initiator Responder +ellswift_I || garbage_I ----------> + <---------- ellswift_R || garbage_R + <---------- terminator_R || version_R{F0 || ek} then holds +terminator_I ----------> +holds its version until version_R arrives +version_I{F0 || ct}, ECDH keys ----------> +switch to hybrid keys decapsulate; switch to hybrid keys +confirmation, hybrid keys ----------> + <---------- confirmation, hybrid keys +application packets <----------> application packets +``` + +Neither side waits for the other's confirmation before sending. A receiver +delivers application messages only after the peer's confirmation verifies. + +## Terms + +- **Contents**: the plaintext contents of a BIP324 version packet, which + BIP324 reserves for future versions. +- **Record**: one entry in the contents, as defined below. +- **Own record**: a record with header `0xF0`. +- **Offer**: the responder's own record, carrying an ML-KEM-1024 encapsulation + key `ek` (1,568 bytes). +- **Accept**: the initiator's own record, carrying an ML-KEM-1024 ciphertext + `ct` (1,568 bytes). +- **ECDH keys**: the four BIP324 packet ciphers derived from the ECDH secret + alone. **Hybrid keys**: the four packet ciphers derived by the recipe below. +- **Switch on**: a node configured to offer (as responder) and accept (as + initiator). With the switch off, a node behaves exactly as BIP324 does today. + +## Wire format + +### Records + +Version packet contents are zero or more records: + +```text +contents = record* +record = CompactSize(len) || header (1 byte) || payload (len - 1 bytes) +``` + +`len` counts the header plus the payload and MUST be at least 1. `CompactSize` +is Bitcoin's variable-length integer, and a receiver MUST reject non-canonical +encodings as a parse failure. The shape follows the bitcoin-dev +[Transport Feature Negotiation](https://gnusha.org/pi/bitcoindev/29174459-1523-4cf8-a20f-5ce1abc302c3n@googlegroups.com/) +draft. + +### Header registry + +| Header | Meaning | +|---|---| +| `0x00` | Reserved. | +| `0xF0` | `PQ_MLKEM1024`: the ML-KEM-1024 offer (sent by the responder) and accept (sent by the initiator). The sender's role says which one a record is. | + +qbit assigns its own header values from `0xF0` upward, far from the low values +a Transport Feature Negotiation table would assign first. Every other value is +unassigned, and a receiver ignores records with headers it doesn't know. + +### Offer and accept + +- The responder sends an offer only when its switch is on. The offer's payload + is the 1,568-byte encapsulation key `ek`. +- The initiator sends an accept only after it received a valid offer and its + switch is on. The accept's payload is the 1,568-byte ciphertext `ct`. + Otherwise the initiator sends empty contents. +- qbit sends the offer and the accept as the entire contents, but a receiver + MUST handle other records alongside them as the parse rules below say. + +| Object | Layout | Bytes | +|---|---|---:| +| Offer contents | `fd 21 06 f0 ‖ ek` | 1,572 | +| Accept contents | `fd 21 06 f0 ‖ ct` | 1,572 | +| Record length | CompactSize 1,569 (header plus payload) | `fd 21 06` | +| Encrypted version packet | encrypted length 3 ‖ encrypted payload (header `00` ‖ contents) 1,573 ‖ tag 16 | 1,592 | +| Encrypted confirmation | encrypted length 3 (plaintext `00 00 00`) ‖ encrypted header 1 (plaintext `80`) ‖ tag 16 | 20 | + +**AAD.** Both version packets travel under the ECDH keys, with BIP324's +associated data rule: a sender's first packet in its direction authenticates +its garbage, so if a decoy already consumed the garbage, the version packet +uses empty AAD. Confirmations always use empty AAD. + +### Parse rules + +A receiver parses the contents in one pass: + +1. **The whole contents must parse as records.** Empty contents, or a parse + failure anywhere (truncation, a zero length, a non-canonical or oversized + length, trailing bytes), mean "no features": the connection continues with + ECDH keys and MUST NOT be dropped for it. This deliberately differs from the + Transport Feature Negotiation draft, which disconnects on unparseable + contents: BIP324 receivers ignore version contents today, and qbit v1.0.0 + relies on that. +2. **The first own record wins.** Later `0xF0` records and records with unknown + headers are ignored, but the transcript still covers them. +3. **Only after the whole grammar validates** is the first own record checked. + An offer's payload MUST be exactly 1,568 bytes and MUST pass the FIPS 203 + section 7.2 encapsulation key check (every encoded coefficient below q). An + accept's payload MUST be exactly 1,568 bytes. A malformed first own record + disconnects the connection. +4. **A corrupted `ct` of the right length can't be detected**, because ML-KEM + decapsulation rejects implicitly. The two sides derive different keys, and + the key confirmation fails at once. + +A responder that sent no offer MUST ignore an own record in the initiator's +contents and never decapsulate. A node whose switch is off MUST NOT parse or +validate the peer's contents. qbit's receive limit for any packet, 4,000,013 +bytes of contents, bounds the parser's work. + +## Message rules + +### Responder + +- It MUST send its version packet after its garbage terminator without waiting + to receive anything more from the initiator, such as the initiator's garbage + terminator or version packet. It MAY send decoy packets before the version + packet, as BIP324 allows. BIP324 implementations already behave this way. +- After sending an offer, it MUST send nothing more, decoys included, until it + has processed the initiator's version packet. +- On the initiator's version packet: with no own record, or contents that fail + to parse, it keeps the ECDH keys and discards its ML-KEM state. With a + malformed accept, it disconnects. With a valid-length accept, it decapsulates + and switches. + +### Initiator + +- With its switch on, it MAY hold its version packet until the responder's + version packet arrives, and then sends the ciphertext in it. With its switch + off, it sends its version packet at once, as today. +- On the responder's version packet: with no own record, or contents that fail + to parse, it sends empty contents and keeps the ECDH keys. With a malformed + offer, it disconnects. With a valid offer, it encapsulates, sends the accept + under the ECDH keys and switches. +- It MAY send decoy packets before its version packet, under the ECDH keys. + +### Why progress still holds + +BIP324's version negotiation phase lists the initiator's steps as receiving a +packet and then sending its own version packet, and notes that version packets +*can* be sent right after the garbage terminator. Deployed initiators send at +once; a hybrid qbit initiator forgoes that optional immediate send and uses the +listed order. All of the progress conditions BIP324 sets for handshake +upgrades still hold: in particular, after sending its garbage terminator each +party moves to version negotiation without waiting for more bytes. The +initiator's wait always ends, because every responder, legacy or hybrid, MUST +send its version packet without waiting to receive anything more from the +initiator. Responders never hold before their own version packet. + +BIP324 names post-quantum cryptography upgrades to the handshake among the +features its version negotiation phase is meant to carry. + +## Key schedule + +### Retained secret + +Each side keeps the raw 32-byte BIP324 ECDH secret `ss_ECDH` (the tagged hash +`bip324_ellswift_xonly_ecdh` that BIP324's HKDF consumes) past its normal +lifetime, until it either switches or decides to stay on the ECDH keys. An +implementation SHOULD wipe it on every path. + +### Transcript + +```text +TH = SHA256( ellswift_I (64) || ellswift_R (64) + || CompactSize(len(contents_R)) || contents_R + || CompactSize(len(contents_I)) || contents_I ) +``` + +- `ellswift_I` and `ellswift_R` are the 64-byte ElligatorSwift keys the + initiator and the responder sent, in that order, whatever the local role. +- `contents_R` and `contents_I` are the full version packet contents each side + sent, responder first, including unknown and duplicate records. +- Packet framing, garbage, terminators, tags, decoys and confirmations are not + hashed. +- For the ordinary one-record exchange, the TH input is 3,278 bytes. + +### Derivation + +HKDF-SHA256 ([RFC 5869](https://www.rfc-editor.org/rfc/rfc5869)), with one +32-byte output block per label: + +```text +salt = ASCII("qbit_v2_hybrid_mlkem1024") || network_magic 24 + 4 = 28 bytes, no NUL +IKM = ss_ECDH (32) || ss_MLKEM (32) || TH (32) 96 bytes +PRK = HMAC-SHA256(key = salt, msg = IKM) +initiator_L, initiator_P, responder_L, responder_P, session_id + = HMAC-SHA256(PRK, ASCII(label) || 0x01) 32 bytes each +``` + +The network magic is the chain's 4-byte message start in wire order, the same +bytes BIP324's salt uses: mainnet `44 4f 24 a8`, regtest `a6 6b 1f da`. +`ss_MLKEM` is the 32-byte ML-KEM-1024 shared secret: the output of +encapsulation at the initiator and of decapsulation at the responder. + +### Switch + +- Switching replaces all four packet ciphers at once: the initiator sends with + `initiator_L` and `initiator_P` and receives with `responder_L` and + `responder_P`, and the responder the reverse. Each new cipher starts with its + packet counter at 0, and BIP324's rekeying every 224 packets then derives + from the hybrid keys. +- The session id becomes the hybrid `session_id`. +- The garbage terminators are not re-derived. +- The initiator switches right after queuing its version packet, and the + responder right after processing the initiator's version packet. + +### Recipe changes + +The recipe, its labels, salt and transcript layout are a wire contract between +releases. Any change takes a new header with its own salt label. A label-only +change under the same header would let two adjacent releases both accept the +offer, derive different keys and disconnect at the first hybrid packet. + +## The switch boundary + +| Direction | Packets | Keys | +|---|---|---| +| Initiator to responder | ElligatorSwift key, garbage, garbage terminator | none (plaintext) | +| Initiator to responder | decoys before the version packet; the version packet (accept or empty) | ECDH | +| Initiator to responder | after a switch: confirmation (hybrid packet 0), then decoys and application packets | hybrid | +| Responder to initiator | ElligatorSwift key, garbage, garbage terminator | none (plaintext) | +| Responder to initiator | decoys before the version packet; the version packet (offer or empty) | ECDH | +| Responder to initiator | after a switch: confirmation (hybrid packet 0), then decoys and application packets | hybrid | + +Without a switch, every packet after the garbage terminators uses the ECDH +keys, exactly as in BIP324. + +## Key confirmation + +**Sending.** Right after switching, a node MUST send a key confirmation as its +first packet under the hybrid keys: an empty decoy packet with header `0x80` +and empty AAD, 20 bytes on the wire. It consumes hybrid packet 0 in that +direction. A node MUST NOT wait for the peer's confirmation before sending +application packets. + +**Receiving.** The first packet a node receives under the hybrid keys MUST meet +all of the following, and otherwise the node MUST disconnect at once: + +- its decrypted length is 0, checked as soon as the 3 length bytes arrive, + before waiting for or allocating a body; +- its tag verifies; +- its ignore bit is set. Other header bits are ignored, as BIP324 specifies. + +A node MUST NOT deliver application messages, or report the connection as +hybrid, until the peer's confirmation verifies. Without this check, a key +mismatch decrypts to a random length, and about 24% of the time the receiver +would wait until its handshake timeout for bytes that never arrive. + +## Failure handling + +- Internal ML-KEM errors not caused by the peer never crash the node. A + responder that can't generate a key sends no offer. An initiator that can't + check the key or encapsulate continues as if its switch were off for that + connection. A responder whose decapsulation fails internally closes the + connection, because the initiator has already switched. +- Key generation draws two independent 32-byte strong random values for the + 64-byte seed, and encapsulation draws one 32-byte value. +- The decapsulation key, the retained ECDH secret, ML-KEM temporaries and + derived keys are wiped on switch, on staying with ECDH keys, at disconnect + and timeout, and in destructors. + +How qbit counts, logs and reports these outcomes, its fallback after repeated +failures and its load shedding are local policy, described in +[#184](https://github.com/Qbit-Org/qbit/issues/184) and, for operators, in the +[operator guide](../user/network/pq-transport.md); they don't change the wire +behavior above. + +qbit reports each v2 connection's negotiation status as one of these values: + +- `pending` from the start of the handshake until one of the + outcomes below; +- `hybrid` once the peer's confirmation verifies; +- `legacy_peer` when the peer's version packet carries no usable + offer or accept, so the ECDH keys stay; +- `off` when the switch is off, when a responder sheds its offer + under load, or after a local key generation, key check or encapsulation fault + that left the connection on plain v2; +- `fallback` for an automatic outbound connection that runs + plain v2 because of earlier failures; manual and inbound connections never + fall back. + +There is no failed status. A connection that closes on any failure above or +in key confirmation, local faults included, reports `pending` until it is +removed. + +## Security notes + +- **Version contents have ECDH-only protection.** The EK, the CT and any future + record travel under the ECDH keys. A future attacker who breaks ECDH learns + them, which is fine for ML-KEM's public values but matters for any future + record that carries secrets. +- **The guarantee holds for confirmed hybrid connections.** A switch that is + off on either side, a local fault, load shedding and fallback all permit + ECDH-only sessions. `getpeerinfo` reports + `transport_pq` only once the peer's confirmation verified. +- **This is not authentication.** BIP324 stays unauthenticated, and an active + man-in-the-middle can still relay two separate sessions. The hybrid session + id can be compared out of band the same way BIP324's can. +- **Downgrade.** Stripping an offer or an accept means forging packets under + the ECDH keys, so it takes an active attacker who breaks ECDH in real time + or sits in the middle, and an unauthenticated session can't stop the latter + anyway. A passive recorder can't downgrade a connection. qbit's fallback + policy is weaker: an attacker on the network path, without any keys, can + make hybrid handshakes with an endpoint fail after the switch, by damaging + the peer's key confirmation in transit, resetting the connection or + dropping its traffic. Three such failures in a row put an automatic + outbound peer in the fallback set, so that new connections to it use ECDH + keys only, for 1 hour, then 4, then 24, and the attacker can repeat this. + Manual peers (`addnode`, `-connect`) are exempt: + their failures never count, and they always negotiate. +- **Transcript binding.** Both keys, both contents and the network magic feed + the derivation, so two sides that saw different negotiation bytes derive + different keys and fail the confirmation instead of continuing. +- **Distinguishability.** Hybrid sessions are distinguishable from legacy v2 + by packet sizes. Users who need to hide that should use Tor or I2P. +- **Forward secrecy.** Each connection uses a fresh ML-KEM key pair, and the + decapsulation key is wiped after the switch. + +## Interop recipe + +The vectors file +[`src/test/data/pq_transport_vectors.json`](../../src/test/data/pq_transport_vectors.json) +holds every input and intermediate. All byte strings are lowercase hex. To +check an implementation against vector 0: + +1. Select the chain in `chain` and check that its message start is `magic`. +2. Take `initiator_privkey` with `initiator_ellswift` and `responder_privkey` + with `responder_ellswift`. Each side's BIP324 ECDH gives `ss_ecdh`; BIP324's + own HKDF gives `ecdh_session_id` and the two garbage terminators, which the + switch leaves alone. +3. Run ML-KEM-1024 KeyGen on `mlkem_keygen_seed` (`d || z`) to get `ek`, and + Encaps on `ek` with `mlkem_encaps_m` to get `ct` and `ss_mlkem`. + Decapsulating `ct` with the decapsulation key gives `ss_mlkem` too. +4. Build `contents_responder` (`fd 21 06 f0 || ek`) and `contents_initiator` + (`fd 21 06 f0 || ct`). In vector 2 the responder's offer is followed by a + second `0xF0` record with a different valid `ek` and an unknown `0xF1` + record, and the initiator's accept is preceded by an unknown `0x01` record: + the KEM uses the first `0xF0` record of each contents, and the transcript + covers every record. Vector 3 appends a 65,535-byte unknown record to the + offer, so that record's length and the responder's contents length in the + transcript both take the `0xfe` CompactSize form. +5. Concatenate the transcript input and compare it with `transcript_input`, and + its SHA256 with `transcript_hash`. +6. Compare `salt`, `ikm`, `prk` and the five derived values. +7. Encrypt the initiator's confirmation (empty contents, header `0x80`, empty + AAD) with fresh ciphers keyed by `initiator_L` and `initiator_P`; it must + equal `initiator_confirmation`. Hybrid packet 1 with + `initiator_first_contents` (a short-id `ping`) must equal + `initiator_first_packet`. Do the same for the responder. +8. For each entry in `negative_vectors`, the side named by `role` derives from + `transcript_contents` and `ss_mlkem` instead of the honest values of vector + `vector`. It must + get `session_id` and send `confirmation`, and decrypting the honest peer's + confirmation must fail with `decrypted_length` (reason `length` when nonzero, + else `tag`); the honest peer fails on its confirmation with + `peer_decrypted_length`. +9. Each entry in `confirmation_failures` is a first hybrid packet that the side + named by `receiver` must reject under the honest keys of vector `vector`: + the length decrypts to `decrypted_length`, and the check fails with + `failure`, one of `length`, `tag` (a zero length with a damaged tag) and + `not_decoy`. + +### Vector 0 + + +```text +magic = 444f24a8 +initiator_ellswift = 6b5fb320cab84c788ef6393262172b222facedee5e55f4e2fa44bae00b1f36102dd8739200e025a0443a6c6d3d52f003e4b94d296ed3a68e9dacc567f0c7bd09 +responder_ellswift = 3d3449542df2dc02aedca2a14e5c6b22f2b797b3191488cdbdc1ff6ad3ef40a77e62023395a490bbacc9de3da7fd37f70592ec361e34c01c761ad4ea2029712c +ss_ecdh = 7e8dd0eda7d613290fcfc7919af8675a2405e6585378777b9d2f02e592f1fb1d +ecdh_session_id = ab8e9ae6fe9d33e2e10f847855298fbe295ed45625608b425ca118d09dbfd0a4 +ss_mlkem = 22c8bf5c4858ac2e5f94b6e945ca142ccc28b98e7b954a78899630a7bfa3135f +transcript_hash = 66f0655505928bdeec6ce4cf2b1de08ff16c2cb1cf04b781011e30ca6687c240 +salt = 716269745f76325f6879627269645f6d6c6b656d31303234444f24a8 +prk = cacbbffc106ea035465bc498d22562e83989c039390ddc1ea36e0fe7ba0ac6b4 +initiator_L = d723af332d033d229fe91db011f04cd005657fcc35d59b93b6771e77dc9bd572 +initiator_P = 72462d2ed71f2b6b9c7435745166155bbd61ab04e2e21798945d1282f690e7d2 +responder_L = 167756b477acb286099da2a866ad9e2d94ec719236dc61836f2956451148ec12 +responder_P = 95eb7e61e0e575bd36db2a0b589553984942db0da9f43f8d8bc48012f2323d21 +session_id = c749d7dadf82b12615fa4ccdeef8cbce4ffb935ad22a7eac1fbc52d8d1394ea4 +initiator_confirmation = 69abb2b1c9601dad7baf8038bc3601eaa42527de +responder_confirmation = a634faec713fb192a52b9e810216b8a36d39516b +initiator_first_packet = 4bbe69dca55d32377b3cee47741171e83115bae7d56d7a9a81dd94394a +responder_first_packet = be28dfc2f7f52321d961b644a0ff9fb473d4f9055c6e9523c696751721 +``` + +The generator's `--check` mode fails if any value above differs from the file. + +## Convergence policy and recipe retirement + +- qbit tracks bitcoin-dev, bitcoin/bips and upstream transport changes such as + [bitcoin/bitcoin#36376](https://github.com/bitcoin/bitcoin/issues/36376) + (rare BIP324 decoys). The rule against decoys between an offer and the + initiator's version packet binds only offering qbit responders, so upstream + decoys from legacy peers stay compatible. +- If a Bitcoin standard for post-quantum transport ships, qbit implements it as + a new header. At most two post-quantum recipes are supported at once, and + while two exist, `getpeerinfo` reports which one each hybrid peer uses. +- The `0xF0` recipe stays for at least two major releases after that standard + ships. The release before its removal announces it in its release notes and + warns at startup while `0xF0` connections are still in use. + +## Discussion + +This specification, its Python reference and the vectors are to be posted to +the bitcoin-dev threads +[Transport Feature Negotiation](https://gnusha.org/pi/bitcoindev/29174459-1523-4cf8-a20f-5ce1abc302c3n@googlegroups.com/) +and +[A Post-Quantum Path for BIP 324](https://groups.google.com/g/bitcoindev/c/n_5WuKVYqwI/), +asking for a reserved private-use header range and discussing ML-KEM-768 +against ML-KEM-1024. Links to those posts will be added here. + +## For contributors + +### Build and test + +```sh +cmake -B build +cmake --build build -j 8 --target test_bitcoin # builds build/bin/test_qbit +ctest --test-dir build -R bip324_tests +build/bin/test_qbit --run_test=bip324_tests +``` + +`bip324_tests` replays the vectors file (`hybrid_vectors`) and checks +transcript binding, API misuse and rekey boundaries. The Python reference has +its own unit tests: + +```sh +build/test/functional/test_runner.py feature_framework_unit_tests.py +``` + +The `bip324_cipher_roundtrip` fuzz target covers the switch, mismatched +transcripts, damaged packets and rekeys: + +```sh +cmake -B build_fuzz -DBUILD_FOR_FUZZING=ON -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DSANITIZERS=fuzzer,address,undefined +cmake --build build_fuzz --target fuzz +FUZZ=bip324_cipher_roundtrip build_fuzz/bin/fuzz +``` + +### ML-KEM backends + +qbit uses native ML-KEM code on x86_64 with AVX2, detected at run time, and on +macOS arm64, and portable C everywhere else. That includes AArch64 Linux and +every other AArch64 ELF build: upstream's AArch64 assembly has no BTI landing +pads or GNU property note, so linking it would drop BTI/PAC/GCS marking from the +whole binary, and hardening wins. The CMake option +`WITH_MLKEM_NATIVE=AUTO|ON|OFF` controls the build: `OFF` builds portable C +only, and `ON` makes missing native support a configure error, as on AArch64 +ELF. At runtime, the hidden `-mlkemportable` option, and +`-test=mlkem_portable` on regtest, force portable C; the +[operator guide](../user/network/pq-transport.md) describes when to use the +former. + +### Regenerating the vectors + +```sh +contrib/devtools/generate-pq-transport-vectors.py # rewrite the vectors file +contrib/devtools/generate-pq-transport-vectors.py --check # what the lint runs +``` + +The generator imports the Python reference from +`test/functional/test_framework/crypto` (`bip324_cipher.py` for the recipe, +`mlkem.py` for ML-KEM-1024) and never calls the C++ code. Every input comes +from a fixed label, so the output is byte-for-byte reproducible. Changing the +recipe changes the vectors, and both `--check` and `bip324_tests` fail; a +deliberate recipe change needs a new header. + +## References + +- [BIP324](https://github.com/bitcoin/bips/blob/master/bip-0324.mediawiki), + the v2 P2P transport, and its version negotiation phase. +- [FIPS 203](https://csrc.nist.gov/pubs/fips/203/final), ML-KEM. +- [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869), HKDF. +- [mlkem-native v2.0.0](https://github.com/pq-code-package/mlkem-native/tree/v2.0.0), + the C library qbit uses. +- bitcoin-dev, + [Transport Feature Negotiation](https://gnusha.org/pi/bitcoindev/29174459-1523-4cf8-a20f-5ce1abc302c3n@googlegroups.com/) + (2026-09-17), and + [A Post-Quantum Path for BIP 324](https://groups.google.com/g/bitcoindev/c/n_5WuKVYqwI/). diff --git a/doc/development/fuzzing.md b/doc/development/fuzzing.md index e86895f21e..5c5132f2f7 100644 --- a/doc/development/fuzzing.md +++ b/doc/development/fuzzing.md @@ -38,10 +38,32 @@ set from that mapping file: $ ctest --test-dir build_fuzz -R '^required_fuzz_targets$' --output-on-failure ``` +## qbit hybrid v2 transport targets + +These targets cover the [hybrid post-quantum v2 transport](../design/pq-transport.md): + +| Target | What it checks | +|---|---| +| `pq_records` | The version-contents record parser, against an independent reading of the grammar. | +| `mlkem` | ML-KEM-1024 round trips, the encapsulation key check, implicit rejection and the decapsulation key check. | +| `mlkem_backend_diff` | That the detected ML-KEM-1024 backend and portable C agree on every status and output. | +| `bip324_cipher_roundtrip` | The switch to hybrid keys, mismatched transcripts, damaged packets and rekeys. | +| `p2p_transport_bidirectional_v2` | Two V2 transports, each with or without the hybrid negotiation: the keys are hybrid exactly when both negotiate. | +| `p2p_transport_bidirectional_v1v2` | A v1 initiator against a V2 responder, with or without the negotiation. | +| `p2p_v2_pq_malicious_peer` | A peer that completes ECDH honestly and then chooses the negotiation, packet boundaries and timing, against the transport's invariants: when it may call ML-KEM, that its secrets are wiped, and that only a verified confirmation makes a connection hybrid. | + +```sh +$ FUZZ=p2p_v2_pq_malicious_peer build_fuzz/bin/fuzz +``` + ## qbit seed corpora `qa-assets` has no inputs for `asert_chain_transition`, `asert_edge_cases`, -`asert_math`, `auxpow`, `p2mr_script` or `pqc`. Generated seeds for these +`asert_math`, `auxpow`, `mlkem`, `mlkem_backend_diff`, `p2mr_script`, +`p2p_v2_pq_malicious_peer`, `pq_records` or `pqc`, and its inputs for +`p2p_transport_bidirectional_v2` and `p2p_transport_bidirectional_v1v2` predate +the hybrid v2 negotiation. +Generated seeds for these targets, their manifest and the input format are described in [`test/fuzz/qbit_corpora/README.md`](../../test/fuzz/qbit_corpora/README.md). CI copies them into the `qa-assets` corpus and requires that they are replayed: @@ -51,17 +73,23 @@ $ test/fuzz/qbit_corpora/overlay.py qa-assets/fuzz_corpora $ build_fuzz/test/fuzz/test_runner.py --require_qbit_corpus qa-assets/fuzz_corpora ``` -`--require_qbit_corpus` fails if any of the six targets is not compiled or not +`--require_qbit_corpus` fails if any of the twelve targets is not compiled or not selected (including through `--exclude`), has no regular input files, or is not reported as replayed by the fuzz executable. The runner prints the number of input files present and replayed for each target. +The ML-KEM targets use native code where the CPU has it, and print the backend +they run at start-up. CI replays `mlkem` and `mlkem_backend_diff` a second time +with `MLKEM_FORCE_PORTABLE=1`, which makes them run portable C throughout; they +abort if portable C is not what runs, and on any value other than unset, `0` or +`1`. + With a libFuzzer build, `--mutate_min_time=` replaces the single replay with a mutation phase of that many seconds per target. The phase starts from the corpus, writes new inputs to a temporary directory and leaves the corpus unchanged. It fails on non-libFuzzer builds. The nightly native fuzz job sets `QBIT_FUZZ_MUTATE_MIN_TIME`, which makes `ci/test/03_test_script.sh` run -this phase for the six targets after the normal replay. +this phase for the twelve targets after the normal replay. The budget is libFuzzer's `-max_total_time`, which counts time spent loading the seed corpus (libFuzzer's `INITED` line) as well as mutation. A run can end diff --git a/doc/integration/rpc-delta-reference.md b/doc/integration/rpc-delta-reference.md index 640d57c1e5..1b704ea347 100644 --- a/doc/integration/rpc-delta-reference.md +++ b/doc/integration/rpc-delta-reference.md @@ -15,6 +15,7 @@ method arguments, result schemas, examples, and command semantics. - Exchange and integrator quickstart: [Exchange and Integrator Quickstart](exchange-integrator-quickstart.md) - Full-validation and archive bootstrap guide: [Full-Validation Bootstrap](../user/full-validation-bootstrap.md) - P2MR watch-only pubkey database guide: [P2MR Watch-Only Pubkey Database Guide](../user/wallet/p2mr-pubkeydb.md) +- Hybrid post-quantum v2 transport: [operator guide](../user/network/pq-transport.md) and [specification](../design/pq-transport.md) The in-tree RPC documentation pipeline lives under `doc/rpc/`. Release builds install generated RPC artifacts under `share/doc/qbit/rpc/`, and the public @@ -30,6 +31,7 @@ qbit keeps the Bitcoin Core RPC shape where that is still accurate, but adds or - future block-time activation and candidate timestamp headroom - archive-by-default node behavior and witness-pruned peers - stale block metrics and qbit confirmation-target estimation +- hybrid post-quantum (ML-KEM-1024) v2 P2P transport state For protocol constants behind these RPCs, use the protocol specification rather than older Bitcoin Core assumptions. Mainnet values include @@ -251,6 +253,37 @@ Integrator notes: - The RPC uses the same C++ helper as P2MR script execution, so it is the canonical node-exposed way to generate CTV test vectors. - The hash commits to transaction version, locktime, optional scriptSig hash, input count, sequences hash, output count, outputs hash, and input index. It does not commit to prevouts, spent amounts, spent scripts, or witness data. +### `getpqtransportinfo` + +Canonical RPC reference: `getpqtransportinfo` at `docs.qbit.org` + +Reports the hybrid post-quantum v2 transport: whether new v2 connections +negotiate an ML-KEM-1024 key exchange, which ML-KEM-1024 code runs, and the +handshake outcomes since startup. + +Request: + +```bash +qbit-cli getpqtransportinfo +``` + +Important result fields: + +- `enabled`, and `status`: `enabled`, `disabled_v2pqtransport` or `disabled_v2transport` +- `arith_backend` (`x86_64-avx2`, `aarch64-neon` or `portable`) and `keccak_backend` (`x86_64-avx2`, `aarch64` or `portable`): the ML-KEM-1024 code that runs +- `instance_id` and `since`: the process start the counters count from +- `handshakes.inbound` and `handshakes.outbound`: event counters, such as `switched`, `legacy_peer`, `first_packet_failed` and, inbound only, `shed` +- `load_shedding`: `active`, `threshold_per_s` and `since` +- `fallback_set` and `failure_streaks`: automatic outbound endpoints that fall back to plain v2, or are on their way there; manual connections (`addnode`, `-connect`) never fall back +- `recent_failures.inbound` and `recent_failures.outbound`: the most recent non-success outcomes, with sequence numbers + +Integrator notes: + +- Every field is always present. The counters, rings and endpoint history are kept in memory only and start over when `instance_id` changes. +- Counters count events, not exclusive outcomes: a switch whose key confirmation then fails counts `switched` and `first_packet_failed`. +- A `fallback_set` entry's `expires` is the latest its window ends: the window also ends once `window_seconds` have passed on the node's monotonic clock, so after a clock step back it can end earlier. +- Endpoints contain peer addresses; treat the output as sensitive. + ## Shared RPC Names With qbit-Specific Semantics ### `getblocktemplate` @@ -279,6 +312,7 @@ Fields to watch: - `localservicesnames` - `connections` - `connections_out` +- `connections_pq`: connections with hybrid post-quantum session keys, the peers `getpeerinfo` lists with `transport_pq` true - `networkactive` - `warnings` @@ -293,6 +327,20 @@ Integrator notes: - Nodes started with witness pruning can withdraw archive service and advertise witness-pruned service. - For archive bootstrap diagnostics, prefer `getarchivepeers` over parsing only `getnetworkinfo`. +### `getpeerinfo` + +Canonical RPC reference: `getpeerinfo` at `docs.qbit.org` + +Each peer gains two fields: + +- `transport_pq`: true once the peer's key confirmation verified and the session keys are hybrid post-quantum (ML-KEM-1024 and ECDH); `session_id` is then the hybrid session id +- `transport_pq_status`: `pending`, `hybrid`, `legacy_peer`, `fallback`, `off` or `v1` + +Integrator notes: + +- `pending` also covers a connection that is closing after a hybrid failure; there is no failed status. +- A v1.0.0 server returns neither field. `qbit-cli -netinfo` treats a missing `transport_pq` as false, and marks hybrid peers `2p` in its `v` column. + ### `getblock` Canonical RPC reference: `getblock` at `docs.qbit.org` @@ -524,6 +572,8 @@ The generated RPC reference should include current entries for: - `getarchivepeers` - `getblocktemplate` - `getnetworkinfo` +- `getpeerinfo` +- `getpqtransportinfo` - `getblock` - `createwalletdescriptor` - `exportpubkeydb` diff --git a/doc/release-notes-223.md b/doc/release-notes-223.md new file mode 100644 index 0000000000..b325f50a0a --- /dev/null +++ b/doc/release-notes-223.md @@ -0,0 +1,24 @@ +I2P post-quantum encryption +--------------------------- + +qbit now asks the I2P router for post-quantum hybrid session encryption, +MLKEM768-X25519 with ECIES-X25519 for peers without it +(`i2cp.leaseSetEncType=6,4`), as Bitcoin Core does. Java I2P 2.10.0 or newer, +and i2pd 2.58.0 or newer built with OpenSSL 3.5 or newer, use it; other i2pd +versions ignore it. + +Java I2P older than 2.10.0 rejects the request, naming the encryption type in +its reply. qbit then retries once with the previous setting, `4,0`, and warns +the first time this happens with a router. After two sessions in a row in +which the router rejects the post-quantum type this way and accepts `4,0`, +qbit uses `4,0` with that router until qbit restarts; a session that gets the +post-quantum type starts the count anew, so one passing router error does not +turn it off. Other rejections, such as a failure to build tunnels or a SAM +`RESULT=TIMEOUT` reply, are retried the same way but do not count and do not +warn. Upgrade the router to get the post-quantum type, then restart qbit. No +reply from the router (a timeout while waiting for it, or a closed or broken +connection) is not retried. + +Creating an I2P session now has a single 3-minute limit that covers every +step, and stops promptly when qbit shuts down. The limit is measured in +elapsed time, so a change to the system clock does not extend it. (#223) diff --git a/doc/release-notes-240.md b/doc/release-notes-240.md new file mode 100644 index 0000000000..1ef12bcf55 --- /dev/null +++ b/doc/release-notes-240.md @@ -0,0 +1,43 @@ +Hybrid post-quantum v2 transport +-------------------------------- + +qbit can now add an ML-KEM-1024 key exchange to the BIP324 v2 transport, so +that the session keys of a connection between two upgraded nodes stay +confidential unless both secp256k1 ECDH and ML-KEM-1024 are broken. This +protects traffic recorded today from decryption by a future quantum computer. +It needs no extra round trip, and costs 3,184 bytes and one ML-KEM key +generation, encapsulation and decapsulation per connection. + +It is on by default in this release, so upgrading switches it on. To switch +it off, set `-v2pqtransport=0` (or `v2pqtransport=0` in +`qbit.conf`) and restart; to switch it back on, remove the setting or set +`-v2pqtransport=1`, and restart. It has no effect with `-v2transport=0`. The +value must be a number: `v2pqtransport=true` is read as 0, which switches it +off, and qbit warns at startup and says how it read such a value. If native +ML-KEM-1024 code misbehaves on a CPU, the hidden option +`-mlkemportable` selects portable code. + +It does not authenticate peers, or protect against spy peers or an active +man-in-the-middle, and it does nothing for traffic with v1.0.0 nodes or any +peer without it, nor for transaction-origin privacy. Hybrid sessions can be +recognized by their packet sizes; where that matters, use Tor or I2P. Peers +without it, including v1.0.0 nodes, keep connecting as before. Nothing it +records persists across a restart. To upgrade with it off, add +`v2pqtransport=0` to `qbit.conf` first: v1.0.0 ignores the line there. v1.0.0 +refuses to start with `-v2pqtransport` in any form on its command line, so +don't add it there before upgrading, and remove it before downgrading. + +The new `getpqtransportinfo` RPC reports the configuration, +handshake outcomes, load shedding and the fallback set. `getpeerinfo` gains +`transport_pq` and `transport_pq_status`, +`getnetworkinfo` gains `connections_pq`, `qbit-cli -netinfo` +marks hybrid peers `2p`, and the log states the setting at +startup in a `v2 pq: … arith=… keccak=…` line. Automatic +outbound connections to an address whose hybrid handshakes keep failing fall +back to plain v2 for a while. Someone on the network path can cause such +failures, so peers added with `addnode` or `-connect` never fall back. A node +under an inbound connection flood stops offering hybrid keys until it +subsides. + +See the [operator guide](user/network/pq-transport.md) to try it, check it and +troubleshoot it, and the [specification](design/pq-transport.md). (#240) diff --git a/doc/rpc/annotations/v30.2-delta.yml b/doc/rpc/annotations/v30.2-delta.yml index 1bc02b5f77..2a93e0963b 100644 --- a/doc/rpc/annotations/v30.2-delta.yml +++ b/doc/rpc/annotations/v30.2-delta.yml @@ -25,6 +25,31 @@ "Lane-specific estimates filter which blocks contribute work while using the same active-chain elapsed-time window as the all-lane estimate; they do not use a lane-local timestamp window." ] }, + "getpqtransportinfo": { + "status_override": "new_since_v30_2", + "delta_summary": "Adds the hybrid post-quantum v2 transport (ML-KEM-1024 and ECDH) diagnostics RPC.", + "delta_notes": [ + "This method does not exist in Bitcoin Core v30.2.", + "Every field is always present, and empty collections are [].", + "Counters count events since startup, not exclusive outcomes; they start over when instance_id changes." + ] + }, + "getpeerinfo": { + "status_override": "semantic_changed_since_v30_2", + "delta_summary": "Adds transport_pq and transport_pq_status, and session_id is the hybrid session id for a hybrid post-quantum peer.", + "delta_notes": [ + "transport_pq is true once the peer's key confirmation verified.", + "transport_pq_status is pending, hybrid, legacy_peer, fallback, off or v1. A connection that closes on a hybrid failure reports pending until it is removed.", + "transport_protocol_type keeps its values: detecting until the handshake completes, then v2." + ] + }, + "getnetworkinfo": { + "status_override": "surface_changed_since_v30_2", + "delta_summary": "Adds connections_pq, the number of hybrid post-quantum connections.", + "delta_notes": [ + "connections_pq counts transport_pq over the same peers getpeerinfo lists." + ] + }, "getmininginfo": { "status_override": "semantic_changed_since_v30_2", "delta_summary": "networkhashps is the default total qbit chainwork hashrate estimate, and next is the permissionless/native candidate.", diff --git a/doc/rpc/versioned_site.py b/doc/rpc/versioned_site.py index e1047c989b..288d466713 100644 --- a/doc/rpc/versioned_site.py +++ b/doc/rpc/versioned_site.py @@ -329,7 +329,6 @@ def build_plan( development_ref: str, publisher_sha: str, repository: str, - development_checkout_ref: str = "", release_resolver: Callable[[dict[str, Any]], str] | None = None, development_resolver: Callable[[str], str] = resolve_commit, ) -> dict[str, Any]: @@ -342,7 +341,7 @@ def build_plan( "path": "main", "source_ref": development_ref, "source_sha": development_sha, - "source_checkout_ref": development_checkout_ref or development_sha, + "source_checkout_ref": development_sha, } return { "schema_version": SCHEMA_VERSION, @@ -588,7 +587,6 @@ def parse_args() -> argparse.Namespace: discover = subparsers.add_parser("discover") discover.add_argument("--repository", required=True) discover.add_argument("--development-ref", required=True) - discover.add_argument("--development-checkout-ref", default="") discover.add_argument("--publisher-sha", required=True) discover.add_argument("--output", required=True) discover.add_argument("--github-output") @@ -612,7 +610,6 @@ def main() -> int: plan = build_plan( releases, development_ref=args.development_ref, - development_checkout_ref=args.development_checkout_ref, publisher_sha=args.publisher_sha, repository=args.repository, ) diff --git a/doc/subtrees/mlkem-native.md b/doc/subtrees/mlkem-native.md new file mode 100644 index 0000000000..a9e1d07a08 --- /dev/null +++ b/doc/subtrees/mlkem-native.md @@ -0,0 +1,332 @@ +# mlkem-native Vendoring Runbook + +This runbook defines how qbit vendors and updates +[mlkem-native](https://github.com/pq-code-package/mlkem-native), the ML-KEM +(FIPS 203) library behind the hybrid post-quantum BIP324 transport. + +mlkem-native's C code is CBMC-proved memory-safe, and its x86_64 and AArch64 +assembly is HOL-Light-proved correct and constant-time wherever it handles +secret data. Editing any vendored file voids those proofs, so qbit never edits +`src/mlkem-native`. All qbit-owned glue (configuration, backend selection, +the C++ wrapper and the build rules) lives outside it. + +## Current Pin + +- Source repository: `https://github.com/pq-code-package/mlkem-native.git` +- Source tag: `v2.0.0` +- Commit: `d1b2fe782888bdb761a50336012923180be7f502` +- Tree id of upstream `mlkem/`: `d9d581290ea1e6fa37462bfbc61b9b4266a2e9e5` +- Blob id of upstream `LICENSE`: `79c71b1fdcc53cc17b1df99a0f4d4d48121ac174` +- qbit path: `src/mlkem-native` (`mlkem/` and `LICENSE`, nothing else) + +The machine-readable pin is +[`contrib/devtools/mlkem-native.pin`](../../contrib/devtools/mlkem-native.pin). +Keep this section in sync with it. + +The whole upstream `mlkem/` directory is vendored, including backends qbit does +not build, so the identity check stays a single tree id and every file that +upstream's single-compilation-unit build (`mlkem_native.c`, +`mlkem_native_asm.S`) includes is present. + +## How qbit Builds It + +`cmake/mlkem-native.cmake` compiles upstream's single compilation unit +(`mlkem/mlkem_native.c`) and, when native code is enabled, its assembly unit +(`mlkem/mlkem_native_asm.S`) into the `mlkem_native` library, linked privately +into `bitcoin_node` only. Never into `bitcoin_crypto`, `bitcoin_common` or +`bitcoin_consensus`; tests, fuzzers and benchmarks reach it through their node +linkage. All qbit-owned glue lives outside the vendored tree: + +- `src/crypto/mlkem_config.h`: the configuration, selected with + `MLK_CONFIG_FILE`. ML-KEM-1024, the `qbit_mlkem1024` symbol prefix, + deterministic APIs only, zeroization through `memory_cleanse`, and the + capability hook. +- `src/crypto/mlkem_arith_backend.h` and `src/crypto/mlkem_fips202_backend.h`: + the native backends, chosen by qbit rather than by upstream's + compile-time defaults (which enable x86_64 only for AVX2 compilers). +- `src/crypto/mlkem_shim.h` and `src/crypto/mlkem_backend.c`: the C ABI + between the library and qbit's C++. +- `src/crypto/mlkem.{h,cpp}`: the `mlkem::` wrapper. + +Native code is selected at run time. No source is compiled with `-mavx2` or +another ISA flag: every native entry point asks the capability hook first, and +the hook reads only cached CPU detection plus the portable override. The +`WITH_MLKEM_NATIVE` CMake option (`AUTO`, `ON`, `OFF`) decides whether native +code is built at all, and the configure summary's `ML-KEM-1024 backend` line +says what was chosen and why: + +| Target | `AUTO` | `ON` | +|---|---|---| +| x86_64 with GCC or Clang (Linux, macOS, MinGW) | native x86_64 AVX2, used when the CPU has every extension it needs (below) | native | +| macOS arm64 | native AArch64 | native | +| AArch64 ELF (Linux, BSD) | portable (see below) | configure error | +| MSVC | portable, no assembly at all | configure error | +| `SANITIZERS` contains `memory` | portable (MSan cannot see assembly writes) | configure error | +| anything else (riscv64, powerpc64, armv7, s390x, i686, ...) | portable | configure error | + +`ci/checks/test_mlkem_build_policy.py` also links the glue with every +assembly routine wrapped by a call counter, and fails if any of them runs while +portable C is forced, or when an entry point run alone on a new thread uses +assembly although portable C is forced (it entered the library without taking +the override). It checks the x86_64 backend natively and the AArch64 backend +as a static binary under `qemu-aarch64`, built from a copy of +`src/crypto/mlkem_config.h` without its AArch64 ELF refusal. CI's build smoke +job requires both. + +### x86_64 instruction set + +The x86_64 backend runs only where the CPU has every extension its assembly +executes; `cpu_features::HasMlkemX86Native()` checks them once and caches the +answer, and the capability hook answers upstream's single +`MLK_SYS_CAP_X86_64_AVX2` query with it. Upstream annotates its routines +`Features: [AVX2]`, but an audit of the assembled `mlkem_native_asm.S.o` +(v2.0.0) found: + +| Extension | Instructions | Where | +|---|---|---| +| AVX2 (with AVX, and the OS saving YMM state) | 256-bit integer ops such as `vpmulhw`, `vperm2i128`, `vpblendd`, `vpsrlvq`, `vpbroadcastq`, `vinserti128` | every routine | +| AVX | `vmovdqa`, `vmovsldup`, `vmovhpd`, `vpinsrq`, `vpinsrw`, `vpextrw` | every routine | +| SSSE3 | `pshufb` | `rej_uniform` | +| SSE4.1 | `pinsrd`, `pinsrq`, `pblendw` | `rej_uniform` | +| POPCNT | `popcnt` | `rej_uniform` | +| BMI2 | `pext` | `rej_uniform` | +| baseline x86-64 | SSE2, `cmov`, `rep movsb`; `endbr64` is a no-op without CET | | + +No FMA, AVX-512, BMI1, LZCNT, MOVBE, AES, PCLMUL or SHA instruction occurs. +A virtual machine can show AVX2 while hiding the others; a check of AVX2 alone +would then die with SIGILL on the first ML-KEM operation. qbit's C code is +compiled for baseline x86-64 and uses only SSE2. + +Repeat the audit on every library update, with native code built: + +```bash +objdump -d --no-show-raw-insn build/src/CMakeFiles/mlkem_native.dir/mlkem-native/mlkem/mlkem_native_asm.S.o \ + | awk -F'\t' 'NF >= 2 && $1 ~ /:$/ { split($2, a, " "); print a[1] }' | sort | uniq -c +``` + +Classify every mnemonic, and extend `HasMlkemX86Native` and its tests for a +new extension. Then check masked CPUs: under +`qemu-x86_64 -cpu Haswell,-bmi2` (and `-popcnt`, `-sse4.1`, `-ssse3`) a +program using `mlkem::` must report portable and finish without SIGILL. +`test_qbit` itself runs under all four masks. + +### MinGW assembly encoding + +Windows builds run the x86_64 backend natively too: with GCC or Clang, +upstream declares its SysV-ABI routines `__attribute__((sysv_abi))`, and +`mlkem_native_asm.S` assembles all 15 of them. On MinGW, `core_interface` +passes `-Wa,-muse-unaligned-vector-move` (GCC bug 54412: on 64-bit Windows, +GCC does not keep the stack 32-byte aligned for AVX code). On the assembly, +that flag would make gas encode each of its 632 aligned vector moves +(`vmovdqa`) as an unaligned one (`vmovdqu`): object bytes the HOL-Light proofs +do not cover. `add_mlkem_native` therefore passes the flag to the C sources +only, and `ci/checks/test_mlkem_build_policy.py` checks that it does. + +The assembly needs no such protection, because its aligned moves touch only +32-byte aligned data: + +- `MLK_ALIGN` stack buffers, which MinGW GCC aligns by over-allocating and + rounding the address, since it cannot realign the stack pointer (14 sites in + `mlkem_native.c`, as many as the stack realignments on Linux); +- constant tables, in a 32-byte aligned `.rdata` section. + +No aligned move addresses the stack pointer or RIP-relative data, and the +vendored C is compiled without AVX, so it has no AVX spills. A misaligned +buffer would fault rather than give a wrong result. Recheck this on every +library update, by disassembling both objects built for MinGW. + +### AArch64 ELF builds are portable only + +mlkem-native v2.0.0's AArch64 assembly has no BTI landing pads (`bti c`) and +no GNU property note. A linker keeps an ELF binary's BTI and PAC marking only if +every input object carries it, so linking the native backend would strip the +marking from the whole binary. Hardening wins: AArch64 ELF targets build +portable C. Measured with Ubuntu's `aarch64-linux-gnu-gcc` 15.2.0 and +`-mbranch-protection=standard`, as qbit builds aarch64 Linux: + +```text +mlkem_native.c.o, qbit's C and C++ objects AArch64 feature: BTI, PAC, GCS +mlkem_native_asm.S.o (no GNU property note) +binary with portable ML-KEM AArch64 feature: BTI, PAC, GCS +same binary with the native backend (no AArch64 feature note) +ld -z force-bti: asm.o: warning: BTI is required by -z force-bti, but this + input object file lacks the necessary property note. +``` + +Revisit when an upstream release ships AArch64 assembly with BTI landing pads +and the GNU property note: then re-run the property comparison below on +aarch64 and, if no marking is lost, drop the `aarch64-elf` rule in +`cmake/mlkem-native.cmake` and `src/crypto/mlkem_config.h`. Never restore the +marking by force-marking objects that lack landing pads. macOS arm64 (Mach-O, +no GNU property notes) keeps the native backend. + +### x86_64 CET marking + +The x86_64 assembly keeps a binary's Intel CET marking. Upstream's `sys.h` +includes `` in the assembly whenever the compiler defines `__CET__`, so +with `-fcf-protection=full`, which qbit's hardening flags pass to the assembly +as to the C (`ci/checks/test_mlkem_build_policy.py` checks that the C flags +reach it), the object gets `endbr64` landing pads and the GNU property note. +Measured for the initial vendoring (v2.0.0) with Ubuntu's gcc 15.2.0 and GNU ld +2.46, `RelWithDebInfo` with qbit's default hardening, native x86_64 enabled: + +```text +mlkem_native_asm.S.o, mlkem_native.c.o, x86 feature: IBT, SHSTK +mlkem_backend.c.o, mlkem.cpp.o +test_qbit, fuzz (both link the native x86 feature: IBT, SHSTK +backend) x86 ISA needed: x86-64-baseline +mlkem_native_asm.S.o assembled with (no x86 feature note) +-fcf-protection=none +``` + +The release evidence is still outstanding for the initial vendoring: Guix +builds of every release host, reproduced, with this comparison for `qbitd`, +`qbit-cli`, `qbit-qt` and `test_qbit` (see the PR checklist below). It is +required before the post-quantum feature branch (#184) merges into `1.x.x`. + +### The AArch64 single-lane Keccak mirror + +Upstream's `fips202/native/aarch64/x1_scalar.h` runs its scalar assembly +without asking the capability hook, so qbit's portable override could not reach +it and `mlkem::GetBackendNames()` would report portable Keccak while assembly +ran. `src/crypto/mlkem_fips202_backend.h` therefore mirrors that header's +dispatch glue (the same macros and the same proved assembly routine) with a +capability check added. Every other native entry point in the x86_64 and +AArch64 backends checks the hook itself. + +The build-policy test's AArch64 case shows the mirror at work: the harness +makes 252 assembly calls natively and none with portable C forced. Its negative +control builds upstream's `x1_scalar.h` in place of the mirror, and then 221 +assembly calls leak into a forced-portable run. + +## Update Procedure + +Run in a qbit worktree: + +```bash +contrib/devtools/update-mlkem-native.sh # the pinned tag +contrib/devtools/update-mlkem-native.sh v2.0.1 # a new release +``` + +The script fetches the tag or commit from `REMOTE_URL` (default: the pinned +repository) into a temporary repository, so the qbit clone does not become +shallow, replaces `src/mlkem-native` with upstream's `mlkem/` and +`LICENSE` byte for byte, rewrites the pin from the object ids of the fetched +upstream commit, stages both, and checks that the staged tree has exactly those +ids. `REMOTE_REF` selects the ref when no argument is given. It refuses to +follow the pinned tag if upstream moved it to another commit. + +Vendor only tagged releases. Read the upstream release notes and changelog +before updating, then work through the PR checklist below: qbit's backend +headers include upstream paths, so an update can need qbit-side changes even +though no vendored file is edited. + +## Verify Integrity + +```bash +test/lint/mlkem-native-check.sh +``` + +The check runs offline as part of the lint runner's `subtree` lint. It prints +`GOOD` when `HEAD:src/mlkem-native/mlkem` has the pinned tree id, +`HEAD:src/mlkem-native/LICENSE` has the pinned blob id, `src/mlkem-native` +holds nothing else, and the work tree has no changes under it. Otherwise it +prints `FAIL` with the signature, cause and fix. + +## PR Checklist For Library Updates + +When a PR touches `src/mlkem-native` or `contrib/devtools/mlkem-native.pin`, +confirm: + +- [ ] The new commit is an upstream release tag, and its release notes and + security advisories were read. +- [ ] The tree was produced by `contrib/devtools/update-mlkem-native.sh`, and + this runbook's Current Pin matches the pin file. +- [ ] `test/lint/mlkem-native-check.sh` prints `GOOD`, and the full lint runner + passes. +- [ ] Running the update script again at the new tag leaves no diff. +- [ ] The options `src/crypto/mlkem_config.h` sets, the `MLK_SYS_*` macros it + reads and the upstream paths the backend headers include still exist with + the same meaning (upstream `mlkem/mlkem_native_config.h`, `mlkem/src/sys.h`, + `mlkem/src/native/*/meta.h`). +- [ ] Upstream's `mlkem/src/fips202/native/aarch64/x1_scalar.h` was diffed + against the mirror in `src/crypto/mlkem_fips202_backend.h`, and the mirror + still differs only by its capability check. +- [ ] Every native entry point compiled in still asks the capability hook: + `grep -n "static MLK_INLINE\|mlk_sys_check_capability"` over + `mlkem/src/native/x86_64/meta.h`, `mlkem/src/native/aarch64/meta.h`, + `mlkem/src/fips202/native/x86_64/keccak_f1600_x4_avx2.h` and + `mlkem/src/fips202/native/aarch64/x4_v8a_scalar.h` pairs each function with a + check. A new unchecked entry point needs a mirror like the x1 one. +- [ ] The x86_64 instruction-set audit above was repeated, and + `HasMlkemX86Native` still requires every extension the assembly executes. +- [ ] `test_qbit --run_test=mlkem_tests` passes (every case runs on the native + backend and with portable C forced), and so do + `ci/checks/test_mlkem_build_policy.py`, the `mlkem` and `mlkem_backend_diff` + fuzz targets, and `bench_qbit -filter='MLKEM.*'`. +- [ ] The GNU property notes of the release ELF binaries did not lose a + marking. Build the base and the change with the release hardening flags + (Guix for release evidence) and compare, for each binary (`qbitd`, + `qbit-cli`, `qbit-qt`, `test_qbit`, ...): + + ```bash + readelf -n | grep -E 'x86 feature|AArch64 feature' + ``` + + x86_64 must keep `IBT, SHSTK`; aarch64 must keep `BTI, PAC` (and `GCS` where + present). A lost marking blocks the update; see the AArch64 section above. +- [ ] Guix builds of every release host reproduce (two independent builds, + identical hashes). + +## Common Failures + +1. Signature: + `FAIL: src/mlkem-native/mlkem tree differs from pinned tree ` + (or the same for `LICENSE`) + Cause: + A vendored file was edited, added, removed or changed mode, or the pin was + changed without re-vendoring. + Fix: + Never edit `src/mlkem-native`. Restore it with + `contrib/devtools/update-mlkem-native.sh`. Changes belong upstream, or in + qbit's glue outside the vendored directory. + +2. Signature: + `FAIL: uncommitted changes under src/mlkem-native` + Cause: + The work tree or index has edits, new files or deletions under the vendored + directory. + Fix: + `git checkout HEAD -- src/mlkem-native && git clean -fdx -- src/mlkem-native`. + +3. Signature: + `FAIL: src/mlkem-native in HEAD holds entries other than LICENSE and mlkem` + Cause: + A qbit file was added next to the vendored tree. + Fix: + Move it out; qbit's ML-KEM glue lives in `src/crypto/mlkem*`. + +4. Signature: + `FAIL: resolves to , but contrib/devtools/mlkem-native.pin pins ` + Cause: + Upstream moved the tag, or `REMOTE_URL` points at a different repository. + Fix: + Stop and investigate upstream before vendoring anything. To vendor a + different commit deliberately, pass it to the script explicitly. + +5. Signature: + `FAIL: staged src/mlkem-native (...) differs from upstream (...)` + Cause: + A git attribute, filter or line-ending setting (for example + `core.autocrlf`) rewrote vendored bytes on staging. + Fix: + Remove the setting that applies to `src/mlkem-native` and re-run the update + script. + +## Review Cadence + +At least quarterly, check +[upstream releases](https://github.com/pq-code-package/mlkem-native/releases) +and [security advisories](https://github.com/pq-code-package/mlkem-native/security/advisories). +Update promptly for a security fix; otherwise update when a release brings a +proved improvement qbit uses. diff --git a/doc/user/README.md b/doc/user/README.md index 90c9dfd329..f71c3c960c 100644 --- a/doc/user/README.md +++ b/doc/user/README.md @@ -39,3 +39,4 @@ network-resource status pages. - [Tor Support](network/tor.md) - [I2P Support](network/i2p.md) +- [Hybrid Post-Quantum v2 Transport](network/pq-transport.md) diff --git a/doc/user/bitcoin-core-differences.md b/doc/user/bitcoin-core-differences.md index 03a6c334ba..f9f9f52124 100644 --- a/doc/user/bitcoin-core-differences.md +++ b/doc/user/bitcoin-core-differences.md @@ -11,7 +11,8 @@ qbit is based on Bitcoin Core v30.2, but it is not a Bitcoin network with differ - qbit has no witness discount. A 3,680-byte PQC signature counts at full weight. - qbit uses ASERT difficulty adjustment and Cadence mining lanes instead of Bitcoin's 2016-block retarget. - qbit keeps full witness history by default. Witness pruning is an explicit opt-in mode. -- Some RPC names are inherited, but several have qbit-specific behavior. qbit also adds new RPCs for AuxPoW, archive peers, orphan metrics, confirmation targets, and P2MR watch-only workflows. +- Some RPC names are inherited, but several have qbit-specific behavior. qbit also adds new RPCs for AuxPoW, archive peers, orphan metrics, confirmation targets, P2MR watch-only workflows, and hybrid post-quantum v2 transport. +- qbit's v2 P2P transport adds an ML-KEM-1024 key exchange to BIP324 (`-v2pqtransport`, on by default in this release; `-v2pqtransport=0` switches it off); peers without it, qbit v1.0.0 included, keep plain v2. See [Hybrid post-quantum v2 transport](network/pq-transport.md). - Bitcoin Lightning is not a drop-in port. A qbit channel network would need new protocol work. ## Network Identity @@ -39,6 +40,20 @@ qbit.org announcements define official qbit networks. The payment URI scheme is `qbit:`. qbit deliberately rejects `bitcoin:`, `bitcoin://`, and `qbit://` payment URI variants. +## P2P Transport + +qbit uses the BIP324 v2 transport by default, as Bitcoin Core does, and adds +a post-quantum key exchange to it: when both nodes have `-v2pqtransport` on, a +v2 connection mixes an ML-KEM-1024 secret into its session keys, so that +recorded traffic stays confidential unless both ECDH and ML-KEM-1024 are +broken. It is on by default in this release, and `-v2pqtransport=0` switches +it off. Peers without it, qbit v1.0.0 included, keep today's v2 keys; it does +not authenticate peers. `getpeerinfo` reports +`transport_pq` and `transport_pq_status`, `getnetworkinfo` reports +`connections_pq`, `qbit-cli -netinfo` marks hybrid peers `2p`, and the new +`getpqtransportinfo` RPC reports the rest. See +[Hybrid post-quantum v2 transport](network/pq-transport.md). + ## Consensus Parameters qbit starts from Bitcoin Core mechanics where they still fit, but the chain parameters are different. @@ -261,11 +276,13 @@ qbit-only or qbit-specific public RPCs include: - `getdefaultctvhash` - `exportpubkeydb` - `importpubkeydb` +- `getpqtransportinfo` Inherited RPC names with changed or qbit-specific semantics include: - `getblocktemplate`, because of Cadence and AuxPoW. -- `getnetworkinfo`, because archive and witness-pruned service bits matter. +- `getnetworkinfo`, because archive and witness-pruned service bits matter, and it counts hybrid post-quantum connections in `connections_pq`. +- `getpeerinfo`, which reports each peer's hybrid post-quantum transport state in `transport_pq` and `transport_pq_status`. - `getblock`, because verbose historical witness data can be unavailable on witness-pruned nodes. - `getnewaddress`, `getrawchangeaddress`, and `createwalletdescriptor`, because public launch chains are P2MR-only. - `getaddressinfo`, `validateaddress`, `decodescript`, wallet signing, and PSBT RPCs, because P2MR carries additional script, key, signature, and watch-only data. diff --git a/doc/user/network/i2p.md b/doc/user/network/i2p.md index c6ed76563b..32c9a3ae66 100644 --- a/doc/user/network/i2p.md +++ b/doc/user/network/i2p.md @@ -121,6 +121,51 @@ You can use the `getnodeaddresses` RPC to fetch a number of I2P peers known to y qbit uses the [SAM v3.1](https://geti2p.net/en/docs/api/samv3) protocol to connect to the I2P network. Any I2P router that supports it can be used. +### Post-quantum encryption of I2P sessions + +qbit asks the I2P router to encrypt its sessions with the post-quantum hybrid +type MLKEM768-X25519, and with ECIES-X25519 for peers that lack it +(`i2cp.leaseSetEncType=6,4`). Whether you get the post-quantum type depends on +your router: + +- Java I2P 2.10.0 or newer, and i2pd 2.58.0 or newer built with OpenSSL 3.5 or + newer, use it. +- Other i2pd versions ignore it and use ECIES-X25519 as before. +- Java I2P older than 2.10.0 rejects it and refuses to create the session. + +When the router rejects the session, qbit retries once without the +post-quantum type (`i2cp.leaseSetEncType=4,0`, what earlier versions of qbit +used). Java I2P older than 2.10.0 names the encryption type in its rejection, +for example `Unsupported crypto type: 6`. The first time such a rejection is +followed by a working retry with a router, qbit logs a warning such as: + +``` +I2P: the router rejected leaseSetEncType=6,4 (RESULT=I2P_ERROR) and accepted 4,0, so this session has no post-quantum leaseset. Java I2P older than 2.10.0 does this; upgrade the router to get it. +``` + +One such rejection may be a passing router error, so qbit asks for the +post-quantum type again in the next session. Only when the router rejects it +this way and accepts `4,0` in two sessions in a row does qbit stop asking: +later sessions through that router use `4,0` until qbit restarts. A session in +which the router accepts the post-quantum type starts the count anew. + +Only a rejection that names the encryption type counts and warns. Other +rejections, such as a failure to build tunnels or a SAM `RESULT=TIMEOUT` reply, +are retried the same way but do not count: they neither add to the count nor +start it anew. qbit logs that the session uses `4,0` and that the next session +asks for the post-quantum type again. + +Upgrade the router to get the post-quantum type, then restart qbit so that it +asks for it again. The retry is specific to qbit; Bitcoin Core has none. qbit +retries only when the router answers with a rejection, and not when the +rejection is about the session id or the destination. A SAM `RESULT=TIMEOUT` +reply is a rejection and is retried. No reply at all is not: a timeout while +waiting for the router, or a closed or broken connection to it, fails as +before. + +Creating an I2P session, with the retry, must complete within 3 minutes, and +stops promptly when qbit shuts down. Use `-debug=i2p` to see each attempt. + ## Ports in I2P and qbit One particularity of SAM v3.1 is that it does not support ports, diff --git a/doc/user/network/pq-transport.md b/doc/user/network/pq-transport.md new file mode 100644 index 0000000000..1da209a2a6 --- /dev/null +++ b/doc/user/network/pq-transport.md @@ -0,0 +1,381 @@ +# Hybrid Post-Quantum v2 Transport + + + +qbit nodes can add a post-quantum key exchange, ML-KEM-1024, to the encrypted +v2 P2P transport. This guide shows how to try it on your own machine, switch it +off or on for a real node, check that it works and troubleshoot it. The wire +format is specified in +[Hybrid post-quantum v2 transport](../../design/pq-transport.md), and the +[release note](../../release-notes-240.md) summarizes the change. + +In this release the feature is **on by default**: a node with v2 transport, +also on by default, uses hybrid keys with every upgraded peer that has it on +too. Switch it off with `-v2pqtransport=0`. + +## BIP324 in Two Lines + +[BIP324](https://github.com/bitcoin/bips/blob/master/bip-0324.mediawiki), the +"v2" transport that qbit uses by default, encrypts every connection between two +nodes with keys the two sides agree on by elliptic-curve Diffie-Hellman (ECDH), +so an eavesdropper sees only random-looking bytes. A large enough quantum +computer could break ECDH, so traffic recorded today could be decrypted later. + +Hybrid v2 transport mixes an ML-KEM-1024 secret into those keys: a recording +stays unreadable unless both ECDH and ML-KEM-1024 are broken. It needs no extra +round trip, and costs 3,184 bytes per connection and, on a current x86_64 CPU, +about 0.1 ms of CPU time for the two nodes together. + +## Try It + +Two nodes on a private test network (regtest) on your own machine: no chain +sync, no wallet, nothing sent to the internet. It takes about five minutes. + +You need `qbitd` and `qbit-cli` from this release on your `PATH`, and a Linux +or macOS shell (on Windows, use WSL). Run the three steps in the same terminal. +The nodes listen on 127.0.0.1 ports 18501 and 18511 and answer RPC on 18502 and +18512; if one of those is taken, change it everywhere it appears. + +Using qbit-qt? The feature is on there too: type `getpqtransportinfo` in +**Window → Console**, and see [Verify](#verify). To change the setting, open +**Settings → Options → Open Configuration File** (on macOS, Options is in the +application menu), add a line as shown in +[Switch It On or Off](#switch-it-on-or-off), save, and restart qbit-qt. + +### Step 1: start two nodes + + +```sh +Q=~/qbit-pq-quickstart +mkdir -p "$Q/a" "$Q/b" +qbitd -regtest -datadir="$Q/a" -bind=127.0.0.1:18501 -rpcport=18502 -daemon +qbitd -regtest -datadir="$Q/b" -bind=127.0.0.1:18511 -rpcport=18512 -daemon +a() { qbit-cli -regtest -datadir="$Q/a" -rpcport=18502 -rpcwait -rpcwaittimeout=60 "$@"; } +b() { qbit-cli -regtest -datadir="$Q/b" -rpcport=18512 -rpcwait -rpcwaittimeout=60 "$@"; } +a getpqtransportinfo | grep '"status"' +b getpqtransportinfo | grep '"status"' +``` + +The nodes start with the feature on, the default, so no option is needed. `a` +and `b` are shorthands for asking each node; they wait up to 60 seconds for it +to answer. You should see: + + +```text +qbit starting +qbit starting + "status": "enabled", + "status": "enabled", +``` + +### Step 2: connect them and check + + +```sh +a addnode 127.0.0.1:18511 onetry +for i in $(seq 30); do a getpeerinfo | grep -q '"pingtime"' && break; sleep 1; done +a getpeerinfo | grep '"transport_pq' +a -netinfo 1 | grep -E '^<->|^out +manual' +grep 'v2 pq: enabled' "$Q/a/regtest/debug.log" +``` + +The loop waits up to 30 seconds for the nodes to finish connecting and exchange +a first ping, which normally takes well under a second. You should see: + + +```text + "transport_pq": true, + "transport_pq_status": "hybrid" +<-> type net serv v mping ping send recv txn blk hb addrp addrl age id +out manual npr nwl2a 2p 0 0 0 0 0 0 +2026-10-21T17:26:21Z v2 pq: enabled arith=x86_64-avx2 keccak=x86_64-avx2 +``` + +`2p` in the `v` column means v2 with hybrid keys. The numbers and the date will +differ, and so may the `arith=` and `keccak=` names, which say which ML-KEM-1024 +code runs on your CPU. If `transport_pq` is `false`, see +[Troubleshooting](#troubleshooting), then clean up anyway. + +### Step 3: clean up + + +```sh +a stop +b stop +for i in $(seq 60); do [ -e "$Q/a/regtest/qbitd.pid" ] || [ -e "$Q/b/regtest/qbitd.pid" ] || break; sleep 1; done +rm -rf "$Q" +unset -f a b +``` + +This stops both nodes, waits up to 60 seconds for them to exit and deletes their +data directories. You should see: + + +```text +qbit stopping +qbit stopping +``` + +## Switch It On or Off + +The feature is on by default. To switch it off, add this line to `qbit.conf` +and restart the node: + +```ini +v2pqtransport=0 +``` + +To switch it back on, delete the line, or set `v2pqtransport=1` to say so +explicitly, and restart. On the command line, `-v2pqtransport=0` (or +`-nov2pqtransport`) switches it off and +`-v2pqtransport=1` on, and either overrides `qbit.conf`. The +setting is read once, at startup, and has no effect when +`-v2transport` is 0; v2 transport is on by default. + +The value must be a number. `v2pqtransport=true` (or `yes`, or `on`) is read as +0, which switches the feature off, and a double negative such as +`-nov2pqtransport=0` is read as 1, which leaves it on. In both +cases, and when `-v2pqtransport=1` has no effect because v2 transport is off, +the node warns at startup and says what it did; the default and +`-v2pqtransport=0` never warn: + +```text +Warning: -v2pqtransport=true is not a number and was read as -v2pqtransport=0: hybrid transport is OFF. Use -v2pqtransport=1 or -v2pqtransport=0. +Warning: -nov2pqtransport was given 0 or a non-numeric value, so it was read as -v2pqtransport=1: hybrid transport is ON. Use -v2pqtransport=0 to switch it off. +Warning: -v2pqtransport=1 has no effect because -v2transport=0. Set -v2transport=1 to use hybrid post-quantum v2 transport. +``` + +`qbitd` prints these warnings in the terminal it was started from, and +`debug.log` records them with a `[warning]` prefix; qbit-qt shows them in a +dialog. Check the result with `getpqtransportinfo` (see [Verify](#verify)). + +## Verify + +**The startup line.** At every start, `debug.log` (see [Logs](#logs)) gets one +line, `v2 pq: … arith=… keccak=…`, which says whether the feature +is `enabled`, `disabled (-v2pqtransport=0)` or +`disabled (-v2transport=0)`, and names the ML-KEM-1024 code that +runs: `arith=` is `x86_64-avx2`, `aarch64-neon` +or `portable`, and `keccak=` is `x86_64-avx2`, +`aarch64` or `portable`. + +**`getpqtransportinfo`.** `getpqtransportinfo` reports the +configuration and what happened since startup: + +- `enabled` is true when new v2 connections negotiate hybrid + keys, and `status` is `enabled`, + `disabled_v2pqtransport` or `disabled_v2transport`. +- `arith_backend` and `keccak_backend` name the + ML-KEM-1024 code, as the startup line does. +- `handshakes` counts negotiation events for + `inbound` and `outbound` connections since + `since`. They count events, not final states: a connection + that switched to hybrid keys and then failed its key confirmation counts + `switched` and `first_packet_failed`. +- `load_shedding`, `fallback_set`, + `failure_streaks` and `recent_failures` are + described under [Fallback and load shedding](#fallback-and-load-shedding). + +Everything here is kept in memory only and starts over when the node restarts; +a new `instance_id` tells you it did. Every field is always +present. `qbit-cli help getpqtransportinfo` describes each one, with the reason +words of every outcome. + +**`getpeerinfo`.** `getpeerinfo` gives each peer +`transport_pq`, true only once the peer's key confirmation has +verified, and `transport_pq_status`: + +| `transport_pq_status` | Meaning | +|---|---| +| `pending` | Still negotiating. A connection that is closing after a hybrid failure also stays `pending` until it is removed: there is no failed status, and the failure is counted in `getpqtransportinfo`. A handshake that doesn't finish within 60 seconds (`-peertimeout`) is closed. | +| `hybrid` | The session keys are hybrid. | +| `legacy_peer` | The peer's handshake carried no hybrid offer or accept, so the connection keeps the ECDH keys, as before. Peers running v1.0.0, or with the feature off, show this. | +| `fallback` | Plain v2 to an address in the fallback set (automatic outbound connections only). | +| `off` | Plain v2 because the feature is off on this node, the offer was shed under load, or a local ML-KEM fault happened before anything was committed. | +| `v1` | An unencrypted v1 connection. | + +With hybrid keys, `session_id` is the hybrid session id, which +two operators can compare out of band. + +**`-netinfo`.** `qbit-cli -netinfo 1` lists peers; in its `v` column, +`2p` marks v2 with hybrid keys, `2` plain v2 and `1` v1. The +`-netinfo` help describes every column. + +**`getnetworkinfo`.** `getnetworkinfo` reports +`connections_pq`, the number of peers whose `transport_pq` is +true. + +With the feature on, the default, but no upgraded peers yet, the `legacy_peer` +counters grow and `connections_pq` stays 0, which is expected while the network +upgrades. With it switched off, `enabled` is false, every counter stays 0, v2 +peers show `off` and `connections_pq` is 0. + +## Fallback and Load Shedding + +**Fallback.** If hybrid handshakes of automatic outbound connections with the +same address fail three times in a row, the node stops trying hybrid keys with +that address for an hour, and connects to it with plain v2 instead, so that a +broken peer or a middlebox that mangles the handshake can't keep the connection +from working. The second time the address falls back it stays out for 4 hours, +and from then on for 24 hours. Each window is also measured on the node's +monotonic clock, so setting the clock back can't lengthen it. Only failures the +peer side can cause count: `malformed_record`, +`first_packet_failed` and `closed_after_switch`. +An address that completed a hybrid handshake since startup never falls back +(the node remembers the 1,000 that did so most recently), inbound connections +never fall back, and a restart forgets the whole history. `getpqtransportinfo` +lists the addresses in `fallback_set`, with +`expires`, and those on their way there in +`failure_streaks`. + +**Manual peers never fall back.** Someone on the network path between you and +a peer can cause those failures without breaking any keys, by damaging or +dropping packets, and so keep an automatic peer on ECDH keys. Connections you +make yourself, with `addnode` or `-connect` (also +`-addnode`), always negotiate hybrid keys, even to an address in +the fallback set, and their failures never count toward it; they are still +counted, listed in `recent_failures` and logged. If hybrid keys with particular +peers matter to you, add them with `addnode`. + +**Load shedding.** An offer costs the responding node an ML-KEM key generation, +and a decapsulation when the peer accepts. If more inbound v2 connections than +the threshold, currently 1,000, reach that point within one second, the node +stops offering, and those connections continue as plain v2 (status `off`, +counted as `shed`). It offers again after 10 seconds in a row +with fewer than half the threshold's connections per second, counted on the +node's monotonic clock. Outbound connections are not affected. +`getpqtransportinfo` shows the state in `load_shedding`: +`active`, `threshold_per_s` and `since`, which is +0 when shedding is not active. + +Both are local policy: a peer can't tell them from a node that has the feature +off. + +## What It Protects and What It Doesn't + +It protects the confidentiality of traffic between two upgraded nodes that +both have the feature on and whose connection shows `"transport_pq": true`: +someone who records that traffic can't decrypt it later, even with a quantum +computer, unless ML-KEM-1024 is broken too. + +It does not: + +- **Authenticate peers.** Like BIP324, it doesn't tell you who is on the other + end. A spy peer sees everything you send it, and an active man-in-the-middle + can still relay two separate sessions, each of which may be hybrid. Comparing + `session_id` out of band detects that. +- **Protect traffic to v1.0.0 nodes,** or to any peer that has the feature off: + those connections use ECDH keys (v2) or no encryption (v1), as before. The + fallback, which someone on the network path can trigger for automatic peers, + load shedding and local faults also leave some connections on ECDH keys; + `transport_pq` reports which. +- **Hide where transactions come from.** Your peers see which transactions you + announce, and when, as before, which can reveal that one started at your node. +- **Hide that you use it.** Hybrid sessions can be told apart from plain v2 by + their packet sizes: a hybrid initiator's first send ends at its garbage + terminator, without the version packet other v2 initiators send right after + it, and each side's version packet is 1,592 bytes. If being recognized + matters, for example to avoid censorship, use [Tor](tor.md) or [I2P](i2p.md), + which also help with transaction origin. + +The [security notes](../../design/pq-transport.md#security-notes) of the +specification cover downgrades and transcript binding. + +## Logs + +The log is `debug.log` in the data directory: `~/.qbit/debug.log` on Linux, +`~/Library/Application Support/Qbit/debug.log` on macOS, and +`%LOCALAPPDATA%\Qbit\debug.log` on Windows, with the network's subdirectory +for test networks (for example `~/.qbit/testnet4/debug.log`). With the systemd +unit in `contrib/init`, the data directory is `/var/lib/qbit`, and the same +lines also go to the journal: `journalctl -u qbitd`. + +- The startup line and load shedding are logged at info level. +- Failures of outbound handshakes are logged at info level, and an address + entering the fallback set or a suspected local fault at warning level + (`[warning]` in `debug.log`). +- Inbound outcomes and successful handshakes are logged only with + `-debug=net`, for example `v2 pq: switched role=initiator` and + `v2 pq: switched role=responder`. Anyone can cause inbound + failures at will, so they are not logged by default. + +## Troubleshooting + +Look for the log line or counter in the first column. Log lines are followed by +details such as `role=`, `peer=` and the ML-KEM-1024 backends; counters are in +`getpqtransportinfo`. + +| Log line or counter | Meaning | Likely causes | What to do | +|---|---|---|---| +| The startup line says `disabled (-v2pqtransport=0)` | The feature is off. | `v2pqtransport=0` in `qbit.conf`; set to `true`, which reads as 0; `-v2pqtransport=0` or `-nov2pqtransport` on the command line. | Delete the setting or set `v2pqtransport=1`, restart, and read the startup warnings. | +| The startup line says `disabled (-v2transport=0)` | v2 transport is off, so hybrid keys can't be used. | `-v2transport=0`. | Remove it. Only with `-v2pqtransport=1` also set did the node warn `has no effect because -v2transport=0`. | +| `is not a number and was read as` | A non-numeric value, such as `true`, was read as 0 or 1. | `true`, `yes`, `on` or a typo. | Use 1 or 0. | +| `was given 0 or a non-numeric value, so it was read as` | A double negative switched the feature on. | `-nov2pqtransport=0` or `nov2pqtransport=true`. | Use `v2pqtransport=0` to switch it off. | +| `legacy_peer` (counter and status) | The peer sent no hybrid offer or accept (reason `no_features`), or contents qbit can't parse (`parse_error`). | The peer runs v1.0.0 or has the feature off. `parse_error`: another implementation or a bug. | Nothing for `no_features`; it is normal while the network upgrades. `parse_error` from many peers: open an issue. | +| `v2 pq: load_shedding started threshold_per_s=…`, `v2 pq: load_shedding stopped`, counter `shed` | Load shedding started or stopped. | A connection flood, or many peers reconnecting at once. | Nothing; those connections continue as plain v2. If it stays on, check where inbound connections come from (`qbit-cli -netinfo 4`), and consider firewall rules or `-maxconnections`. | +| `v2 pq: malformed_record reason=…`, counter `malformed_record` | The peer's offer (`ek_length`, `ek_modulus`) or accept (`ct_length`) was malformed. The connection closes; on an automatic outbound connection, it counts toward the fallback. | A non-conforming implementation, or tampering in transit. | One-off: nothing. From many peers: report it. | +| `v2 pq: first_packet_failed reason=…`, counter `first_packet_failed` | The peer's key confirmation failed (`length`, `tag` or `not_decoy`): the two sides derived different keys. The connection closes; on an automatic outbound connection, it counts toward the fallback. | Tampering in transit, a peer bug, or this node's ML-KEM code (see `local_fault`). | One-off: nothing. From many peers: report it. | +| `v2 pq: closed_after_switch cause=…`, counter `closed_after_switch` | After this node switched to hybrid keys, the peer closed (`eof`), reset (`reset`) or timed out (`timeout`) before its key confirmation arrived. On an automatic connection, it counts toward the fallback. | The peer restarted, the network dropped, or the peer's check of our confirmation failed. | One-off: nothing. Often with one peer: ask its operator for their side's log. | +| `v2 pq: internal_error reason=…`, counter `internal_error` | A local ML-KEM or cipher-state error (`keygen`, `check_public_key`, `encaps`, `decaps`, `cipher_state`). Peer data never causes it. | A fault in this node's ML-KEM code or its CPU. | Report it. Try `-mlkemportable`. If a peer can trigger it, use `SECURITY.md`. | +| `v2 pq: fallback cause=… until=…`, counter `fallback` | An address failed three times in a row on automatic outbound connections, and gets plain v2 on them until the time given (UTC). | See the failure it names. | Usually nothing: the window ends by itself, and a restart clears it. To keep hybrid keys with a peer, add it with `addnode`. Many addresses: see `local_fault`. | +| `v2 pq: local_fault` | Hybrid handshakes failed with 8 different outbound addresses, in ways this node's own ML-KEM code can cause (`ek_modulus`, `length`, `tag`, or `closed_after_switch` with `eof` or `reset`), and none succeeded since startup, so that code may be at fault. Failures only the peer can cause (`ek_length`, `not_decoy`) and `timeout` don't count. | A miscompiled or misbehaving native ML-KEM path, or a broken CPU. | If the line names native code, restart with `-mlkemportable`, as it suggests. Report it either way. | +| `abandoned` (inbound) | This node offered, and the connection closed before the initiator's version packet authenticated, whichever side closed it. The reason is the close cause, or what failed (`garbage_terminator`, `version_length`, `version_tag`); `local` is this node's own close, such as an eviction. Closes because the network was switched off, or at shutdown, aren't counted. | Port scanners, peers giving up, flaky links; with `local`, evictions when the inbound slots are full. | Nothing at low rates. Many, together with `shed`: a flood. | +| `transport_pq_status` stays `pending` | The handshake is still running, or the connection is closing after a failure. | A slow link, or a failure counted above. | It ends within 60 seconds. Look for the peer's `peer_id` in `recent_failures`. | +| `transport_pq_status` is `off` while `enabled` is true | This connection is plain v2. | The offer was shed, or a local fault (`internal_error`) before anything was committed. | Check `shed` and `internal_error`. | + +**What to attach to a report:** the first line of `qbitd -version`, your +operating system and CPU model, the startup line, the output of +`getpqtransportinfo` and the matching log lines, if possible with `-debug=net`. +These contain peer addresses; remove them before posting in public. + +**When to use [`SECURITY.md`](../../../SECURITY.md) instead:** report privately, +as it describes, rather than in a public issue, if you see a crash, a hang or +growing memory use around these log lines; an `internal_error` that a peer can +trigger; failures that suggest someone tampers with or downgrades hybrid +connections, such as `first_packet_failed` or `malformed_record` from many +unrelated peers behind one network path; or anything that suggests the session +keys are not what `transport_pq` reports. + +## Upgrading and Rolling Back + +- **Upgrading.** An upgrade switches the feature on: with v2 transport on, also + the default, the upgraded node offers and accepts hybrid keys on new v2 + connections, and keeps the ECDH keys with peers that don't have it. It also + gains the RPC fields and the startup line. Check `getpqtransportinfo` after + the upgrade. +- **Upgrading with the feature off.** Before upgrading, add `v2pqtransport=0` + to `qbit.conf`. v1.0.0 ignores the line, logging + `Ignoring unknown configuration value v2pqtransport`, so it can be added + while the node still runs v1.0.0, and the upgraded node starts with the + feature off. Don't put `-v2pqtransport=0` on v1.0.0's command line: it + refuses to start (see below). Add it to command lines only after the upgrade. +- **Mixed versions.** Peers running v1.0.0 keep connecting in both directions: + over v2 they show `legacy_peer` (with the feature on, the default) or `off` + (with it switched off), and over v1 `v1`. Between two upgraded nodes, the + connection is hybrid unless one of them switched the feature off; the side + that has it on then shows `legacy_peer`. +- **Nothing persists.** The counters, failure history and fallback set live in + memory and start over at every restart. The feature changes no file in the + data directory. +- **Rolling back to v1.0.0.** If you never set the option, there is nothing to + remove. v1.0.0 doesn't know it: it ignores `v2pqtransport=0` or + `v2pqtransport=1` in `qbit.conf`, logging + `Ignoring unknown configuration value v2pqtransport`, but refuses to start + with any form of it on the command line, `-nov2pqtransport` included: + `Error: Error parsing command line arguments: Invalid parameter -v2pqtransport=0`. + Remove it from command lines, service files and scripts before downgrading. +- **The escape hatch.** If the native ML-KEM-1024 code misbehaves on your CPU, + for example with `local_fault` in the log, restart with + `-mlkemportable` (`mlkemportable=1` in `qbit.conf`). It is + hidden from `-help`; `qbitd -help-debug` lists it. The node then runs the + portable code, as the startup line and `getpqtransportinfo` show, at a cost + of about 0.1 ms more CPU time per hybrid handshake. Its value must be a + number too, and it warns the same way. + +## See Also + +- [Hybrid post-quantum v2 transport](../../design/pq-transport.md), the + specification. +- The [release note](../../release-notes-240.md). +- [Tor](tor.md) and [I2P](i2p.md) support. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c97d6651be..7d7ce68d34 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -27,6 +27,7 @@ include(../cmake/secp256k1.cmake) add_secp256k1(secp256k1) include(../cmake/libbitcoinpqc.cmake) add_libbitcoinpqc(libbitcoinpqc) +add_mlkem_native() # Set top-level target output locations. if(NOT CMAKE_RUNTIME_OUTPUT_DIRECTORY) @@ -190,9 +191,11 @@ add_library(bitcoin_node STATIC EXCLUDE_FROM_ALL auxpow_validation.cpp banman.cpp bip324.cpp + bip324_pq.cpp blockencodings.cpp blockfilter.cpp consensus/tx_verify.cpp + crypto/mlkem.cpp dbwrapper.cpp deploymentstatus.cpp flatfile.cpp @@ -292,6 +295,7 @@ target_link_libraries(bitcoin_node $ leveldb minisketch + mlkem_native univalue Boost::headers $ diff --git a/src/bench/CMakeLists.txt b/src/bench/CMakeLists.txt index afa66f354a..d225e1eed9 100644 --- a/src/bench/CMakeLists.txt +++ b/src/bench/CMakeLists.txt @@ -38,6 +38,7 @@ add_executable(bench_bitcoin mempool_eviction.cpp mempool_stress.cpp merkle_root.cpp + mlkem.cpp obfuscation.cpp parse_hex.cpp peer_eviction.cpp @@ -56,6 +57,7 @@ add_executable(bench_bitcoin txgraph.cpp txorphanage.cpp util_time.cpp + v2_transport.cpp verify_script.cpp ) set_target_properties(bench_bitcoin PROPERTIES OUTPUT_NAME bench_qbit) @@ -69,6 +71,7 @@ target_link_libraries(bench_bitcoin core_interface test_util bitcoin_node + bitcoinpqc Boost::headers ) diff --git a/src/bench/cadence.cpp b/src/bench/cadence.cpp index a1be0a710b..73a4669210 100644 --- a/src/bench/cadence.cpp +++ b/src/bench/cadence.cpp @@ -12,7 +12,6 @@ #include #include -#include #include #include #include @@ -73,9 +72,15 @@ class CadenceBenchChain CBlockHeader m_next_auxpow; }; +//! Legacy blocks since the last AuxPoW block when -asymptote does not set one. +constexpr size_t DEFAULT_STARVATION_DISTANCE{10'000}; + size_t StarvationDistance(const benchmark::Bench& bench) { - return std::max(1, static_cast(bench.complexityN())); + // complexityN() is -1 unless -asymptote sets it: never cast that to size_t, + // which would build a chain until memory runs out. + const double n{bench.complexityN()}; + return n >= 1 ? static_cast(n) : DEFAULT_STARVATION_DISTANCE; } void CadenceLaneLookup(benchmark::Bench& bench) diff --git a/src/bench/mlkem.cpp b/src/bench/mlkem.cpp new file mode 100644 index 0000000000..0be1a0d024 --- /dev/null +++ b/src/bench/mlkem.cpp @@ -0,0 +1,107 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#include +#include +#include + +#include +#include +#include +#include + +using namespace mlkem; + +namespace { + +enum class Backend { DETECTED, PORTABLE }; + +/** + * Run `body` on the detected backend, or with portable C forced, and name the + * benchmark after the backend that runs. Auto-detection is restored afterwards. + */ +template +void WithBackend(benchmark::Bench& bench, const char* operation, Backend backend, Body body) +{ + std::optional portable; + if (backend == Backend::PORTABLE) portable.emplace(); + const BackendNames names{GetBackendNames()}; + bench.name(strprintf("%s using the '%s/%s' ML-KEM-1024 backend", operation, names.arith, names.keccak)); + body(); +} + +std::array Seed() +{ + std::array seed; + for (size_t i{0}; i < seed.size(); ++i) seed[i] = uint8_t(i * 7 + 1); + return seed; +} + +void KeyGenBench(benchmark::Bench& bench, Backend backend) +{ + WithBackend(bench, "MLKEMKeyGen", backend, [&] { + auto seed{Seed()}; + PublicKey ek; + DecapsulationKey dk; + bench.batch(1).unit("keygen").run([&] { + ++seed[0]; + const Error error{KeyGen(seed, ek, dk)}; + assert(error == Error::NONE); + }); + }); +} + +void EncapsBench(benchmark::Bench& bench, Backend backend) +{ + WithBackend(bench, "MLKEMEncaps", backend, [&] { + PublicKey ek; + DecapsulationKey dk; + const Error keygen{KeyGen(Seed(), ek, dk)}; + assert(keygen == Error::NONE); + std::array coins{}; + Ciphertext ct; + SharedSecret ss; + bench.batch(1).unit("encaps").run([&] { + ++coins[0]; + const Error error{Encaps(ek, coins, ct, ss)}; + assert(error == Error::NONE); + }); + }); +} + +void DecapsBench(benchmark::Bench& bench, Backend backend) +{ + WithBackend(bench, "MLKEMDecaps", backend, [&] { + PublicKey ek; + DecapsulationKey dk; + const Error keygen{KeyGen(Seed(), ek, dk)}; + assert(keygen == Error::NONE); + const std::array coins{}; + Ciphertext ct; + SharedSecret sent; + const Error encaps{Encaps(ek, coins, ct, sent)}; + assert(encaps == Error::NONE); + SharedSecret received; + bench.batch(1).unit("decaps").run([&] { + const Error error{Decaps(dk, ct, received)}; + assert(error == Error::NONE); + }); + }); +} + +} // namespace + +static void MLKEM_KEYGEN_DETECTED(benchmark::Bench& bench) { KeyGenBench(bench, Backend::DETECTED); } +static void MLKEM_KEYGEN_PORTABLE(benchmark::Bench& bench) { KeyGenBench(bench, Backend::PORTABLE); } +static void MLKEM_ENCAPS_DETECTED(benchmark::Bench& bench) { EncapsBench(bench, Backend::DETECTED); } +static void MLKEM_ENCAPS_PORTABLE(benchmark::Bench& bench) { EncapsBench(bench, Backend::PORTABLE); } +static void MLKEM_DECAPS_DETECTED(benchmark::Bench& bench) { DecapsBench(bench, Backend::DETECTED); } +static void MLKEM_DECAPS_PORTABLE(benchmark::Bench& bench) { DecapsBench(bench, Backend::PORTABLE); } + +BENCHMARK(MLKEM_KEYGEN_DETECTED, benchmark::PriorityLevel::HIGH); +BENCHMARK(MLKEM_KEYGEN_PORTABLE, benchmark::PriorityLevel::HIGH); +BENCHMARK(MLKEM_ENCAPS_DETECTED, benchmark::PriorityLevel::HIGH); +BENCHMARK(MLKEM_ENCAPS_PORTABLE, benchmark::PriorityLevel::HIGH); +BENCHMARK(MLKEM_DECAPS_DETECTED, benchmark::PriorityLevel::HIGH); +BENCHMARK(MLKEM_DECAPS_PORTABLE, benchmark::PriorityLevel::HIGH); diff --git a/src/bench/v2_transport.cpp b/src/bench/v2_transport.cpp new file mode 100644 index 0000000000..5b2aa0a4ba --- /dev/null +++ b/src/bench/v2_transport.cpp @@ -0,0 +1,109 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +enum class Backend { DETECTED, PORTABLE }; + +/** Rounds of delivery in both directions that any handshake needs fewer of. */ +constexpr int MAX_ROUNDS{8}; + +/** Hand everything `from` has to send to `to`. Returns the bytes moved. */ +size_t Deliver(Transport& from, Transport& to) +{ + size_t moved{0}; + while (true) { + const auto [bytes, more, msg_type] = from.GetBytesToSend(/*have_next_message=*/false); + if (bytes.empty()) return moved; + std::span remaining{bytes}; + while (!remaining.empty()) { + const bool ok{to.ReceivedBytes(remaining)}; + assert(ok); + // The handshake delivers no application message. + assert(!to.ReceivedMessageComplete()); + } + moved += bytes.size(); + from.MarkBytesSent(bytes.size()); + } +} + +/** + * One complete handshake between an in-memory initiator and responder, as a node creates them for + * a connection (fresh keys, production entropy and ML-KEM code) but without garbage, so that the + * bytes on the wire are the same every time. It is complete when both sides report v2, the same + * session id and, for hybrid, the peer's verified key confirmation. Returns the bytes on the wire, + * both directions together. + */ +size_t Handshake(bool hybrid) +{ + const V2PQOptions pq{.mode = hybrid ? PQMode::NEGOTIATE : PQMode::OFF}; + V2Transport initiator{/*nodeid=*/0, /*initiating=*/true, GenerateRandomKey(), MakeByteSpan(GetRandHash()), + /*garbage=*/{}, pq, DefaultPQRandomSource(), DefaultPQKemOps()}; + V2Transport responder{/*nodeid=*/1, /*initiating=*/false, GenerateRandomKey(), MakeByteSpan(GetRandHash()), + /*garbage=*/{}, pq, DefaultPQRandomSource(), DefaultPQKemOps()}; + size_t bytes{0}; + for (int round{0}; round < MAX_ROUNDS; ++round) { + const size_t moved{Deliver(initiator, responder) + Deliver(responder, initiator)}; + if (moved == 0) break; + bytes += moved; + } + const Transport::Info initiator_info{initiator.GetInfo()}; + const Transport::Info responder_info{responder.GetInfo()}; + assert(initiator_info.transport_type == TransportProtocolType::V2); + assert(responder_info.transport_type == TransportProtocolType::V2); + assert(initiator_info.session_id == responder_info.session_id); + assert(initiator_info.transport_pq == hybrid && responder_info.transport_pq == hybrid); + return bytes; +} + +/** + * Time complete handshakes on the detected ML-KEM-1024 backend, or with portable C forced, and + * name the benchmark after the transport, the bytes on the wire per handshake and the backend that + * runs. Legacy v2 runs no ML-KEM code, so its two runs only show the backend selection costs + * nothing there. Auto-detection is restored afterwards. + */ +void HandshakeBench(benchmark::Bench& bench, bool hybrid, Backend backend) +{ + const auto testing_setup{MakeNoLogFileContext(ChainType::MAIN)}; + std::optional portable; + if (backend == Backend::PORTABLE) portable.emplace(); + const mlkem::BackendNames names{mlkem::GetBackendNames()}; + const size_t bytes{Handshake(hybrid)}; + bench.name(strprintf("V2TransportHandshake %s, %u bytes on the wire, %s the '%s/%s' ML-KEM-1024 backend", + hybrid ? "hybrid" : "legacy v2", bytes, hybrid ? "using" : "not using", names.arith, names.keccak)); + bench.batch(1).unit("handshake").run([&] { + const size_t moved{Handshake(hybrid)}; + assert(moved == bytes); + }); +} + +} // namespace + +static void V2_TRANSPORT_HANDSHAKE_LEGACY_DETECTED(benchmark::Bench& bench) { HandshakeBench(bench, /*hybrid=*/false, Backend::DETECTED); } +static void V2_TRANSPORT_HANDSHAKE_LEGACY_PORTABLE(benchmark::Bench& bench) { HandshakeBench(bench, /*hybrid=*/false, Backend::PORTABLE); } +static void V2_TRANSPORT_HANDSHAKE_HYBRID_DETECTED(benchmark::Bench& bench) { HandshakeBench(bench, /*hybrid=*/true, Backend::DETECTED); } +static void V2_TRANSPORT_HANDSHAKE_HYBRID_PORTABLE(benchmark::Bench& bench) { HandshakeBench(bench, /*hybrid=*/true, Backend::PORTABLE); } + +BENCHMARK(V2_TRANSPORT_HANDSHAKE_LEGACY_DETECTED, benchmark::PriorityLevel::HIGH); +BENCHMARK(V2_TRANSPORT_HANDSHAKE_LEGACY_PORTABLE, benchmark::PriorityLevel::HIGH); +BENCHMARK(V2_TRANSPORT_HANDSHAKE_HYBRID_DETECTED, benchmark::PriorityLevel::HIGH); +BENCHMARK(V2_TRANSPORT_HANDSHAKE_HYBRID_PORTABLE, benchmark::PriorityLevel::HIGH); diff --git a/src/bip324.cpp b/src/bip324.cpp index 9c5a1572bb..1ea66bd7f7 100644 --- a/src/bip324.cpp +++ b/src/bip324.cpp @@ -8,9 +8,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -22,6 +24,20 @@ #include #include +namespace { + +/** Serialization stream that feeds every written byte into a running SHA256, without copying. */ +class TranscriptWriter +{ + CSHA256& m_hasher; + +public: + explicit TranscriptWriter(CSHA256& hasher) noexcept : m_hasher{hasher} {} + void write(std::span src) noexcept { m_hasher.Write(UCharCast(src.data()), src.size()); } +}; + +} // namespace + BIP324Cipher::BIP324Cipher(const CKey& key, std::span ent32) noexcept : m_key(key) { @@ -31,7 +47,12 @@ BIP324Cipher::BIP324Cipher(const CKey& key, std::span ent32) no BIP324Cipher::BIP324Cipher(const CKey& key, const EllSwiftPubKey& pubkey) noexcept : m_key(key), m_our_pubkey(pubkey) {} -void BIP324Cipher::Initialize(const EllSwiftPubKey& their_pubkey, bool initiator, bool self_decrypt) noexcept +BIP324Cipher::~BIP324Cipher() +{ + DiscardHybridSecret(); +} + +void BIP324Cipher::Initialize(const EllSwiftPubKey& their_pubkey, bool initiator, bool self_decrypt, bool retain_for_hybrid) noexcept { // Determine salt (fixed string + network magic bytes) const auto& message_header = Params().MessageStart(); @@ -63,6 +84,19 @@ void BIP324Cipher::Initialize(const EllSwiftPubKey& their_pubkey, bool initiator // Derive session id from shared secret. hkdf.Expand32("session_id", UCharCast(m_session_id.data())); + // Keep what SwitchToHybrid() needs: the ECDH secret itself, the transcript started with the + // actual-role public keys (initiator first), the network magic and the cipher direction. + if (retain_for_hybrid) { + m_retained_ecdh = make_secure_unique(ecdh_secret); + m_hybrid_transcript.emplace(); + const EllSwiftPubKey& ellswift_initiator{initiator ? m_our_pubkey : their_pubkey}; + const EllSwiftPubKey& ellswift_responder{initiator ? their_pubkey : m_our_pubkey}; + m_hybrid_transcript->Write(UCharCast(ellswift_initiator.data()), ellswift_initiator.size()); + m_hybrid_transcript->Write(UCharCast(ellswift_responder.data()), ellswift_responder.size()); + std::copy(std::begin(message_header), std::end(message_header), m_hybrid_magic.begin()); + m_hybrid_send_is_initiator = side; + } + // Wipe all variables that contain information which could be used to re-derive encryption keys. memory_cleanse(ecdh_secret.data(), ecdh_secret.size()); memory_cleanse(hkdf_32_okm.data(), sizeof(hkdf_32_okm)); @@ -70,6 +104,63 @@ void BIP324Cipher::Initialize(const EllSwiftPubKey& their_pubkey, bool initiator m_key = CKey(); } +bool BIP324Cipher::AddVersionContents(std::span contents) noexcept +{ + if (!m_hybrid_transcript || m_version_contents_count >= 2) return false; + TranscriptWriter writer{*m_hybrid_transcript}; + WriteCompactSize(writer, contents.size()); + writer.write(contents); + ++m_version_contents_count; + return true; +} + +bool BIP324Cipher::SwitchToHybrid(std::span ss_mlkem) noexcept +{ + if (!*this || !m_retained_ecdh || !m_hybrid_transcript || m_version_contents_count != 2 || m_hybrid_switched) return false; + + // Determine salt (fixed string + network magic bytes, as snapshotted by Initialize). + std::string salt = std::string{"qbit_v2_hybrid_mlkem1024"} + std::string(std::begin(m_hybrid_magic), std::end(m_hybrid_magic)); + + // IKM = ss_ECDH (32) || ss_MLKEM (32) || TH (32), where finishing the transcript gives TH. + std::array ikm; + std::copy(m_retained_ecdh->begin(), m_retained_ecdh->end(), ikm.begin()); + std::copy(ss_mlkem.begin(), ss_mlkem.end(), ikm.begin() + ECDH_SECRET_SIZE); + m_hybrid_transcript->Finalize(UCharCast(ikm.data() + ECDH_SECRET_SIZE + 32)); + + // Derive the hybrid keys, and re-emplace all four ciphers, so their counters start at 0. + bool side = m_hybrid_send_is_initiator; + CHKDF_HMAC_SHA256_L32 hkdf(UCharCast(ikm.data()), ikm.size(), salt); + std::array hkdf_32_okm; + hkdf.Expand32("initiator_L", UCharCast(hkdf_32_okm.data())); + (side ? m_send_l_cipher : m_recv_l_cipher).emplace(hkdf_32_okm, REKEY_INTERVAL); + hkdf.Expand32("initiator_P", UCharCast(hkdf_32_okm.data())); + (side ? m_send_p_cipher : m_recv_p_cipher).emplace(hkdf_32_okm, REKEY_INTERVAL); + hkdf.Expand32("responder_L", UCharCast(hkdf_32_okm.data())); + (side ? m_recv_l_cipher : m_send_l_cipher).emplace(hkdf_32_okm, REKEY_INTERVAL); + hkdf.Expand32("responder_P", UCharCast(hkdf_32_okm.data())); + (side ? m_recv_p_cipher : m_send_p_cipher).emplace(hkdf_32_okm, REKEY_INTERVAL); + + // Replace the session id. The garbage terminators are not re-derived. + hkdf.Expand32("session_id", UCharCast(m_session_id.data())); + + // Wipe everything that could re-derive the keys, including the retained secret. + memory_cleanse(ikm.data(), ikm.size()); + memory_cleanse(hkdf_32_okm.data(), hkdf_32_okm.size()); + memory_cleanse(&hkdf, sizeof(hkdf)); + DiscardHybridSecret(); + m_hybrid_switched = true; + return true; +} + +void BIP324Cipher::DiscardHybridSecret() noexcept +{ + if (m_retained_ecdh) { + memory_cleanse(m_retained_ecdh->data(), m_retained_ecdh->size()); + m_retained_ecdh.reset(); + } + m_hybrid_transcript.reset(); +} + void BIP324Cipher::Encrypt(std::span contents, std::span aad, bool ignore, std::span output) noexcept { assert(output.size() == contents.size() + EXPANSION); diff --git a/src/bip324.h b/src/bip324.h index c6b82cb969..b46764f0e6 100644 --- a/src/bip324.h +++ b/src/bip324.h @@ -7,13 +7,16 @@ #include #include +#include #include #include #include +#include #include #include #include +#include /** The BIP324 packet cipher, encapsulating its key derivation, stream cipher, and AEAD. */ class BIP324Cipher @@ -40,10 +43,24 @@ class BIP324Cipher std::array m_send_garbage_terminator; std::array m_recv_garbage_terminator; + /** Hybrid key schedule state, only kept when Initialize() was called with retain_for_hybrid. + * The ECDH secret is in locked memory, excluded from core dumps, like m_key. */ + secure_unique_ptr m_retained_ecdh; + std::optional m_hybrid_transcript; + std::array m_hybrid_magic{}; + uint8_t m_version_contents_count{0}; + bool m_hybrid_send_is_initiator{false}; + bool m_hybrid_switched{false}; + public: /** No default constructor; keys must be provided to create a BIP324Cipher. */ BIP324Cipher() = delete; + /** Wipes any retained hybrid secret. */ + ~BIP324Cipher(); + BIP324Cipher(const BIP324Cipher&) = delete; + BIP324Cipher& operator=(const BIP324Cipher&) = delete; + /** Initialize a BIP324 cipher with specified key and encoding entropy (testing only). */ BIP324Cipher(const CKey& key, std::span ent32) noexcept; @@ -58,8 +75,35 @@ class BIP324Cipher * initiator is set to true if we are the initiator establishing the v2 P2P connection. * self_decrypt is only for testing, and swaps encryption/decryption keys, so that encryption * and decryption can be tested without knowing the other side's private key. + * retain_for_hybrid keeps the ECDH secret and starts the hybrid transcript, so that + * SwitchToHybrid() can later replace the keys. Without it, the ECDH secret is wiped here. + */ + void Initialize(const EllSwiftPubKey& their_pubkey, bool initiator, bool self_decrypt = false, + bool retain_for_hybrid = false) noexcept; + + /** Add the full version packet contents of one side to the hybrid transcript, the + * responder's first and the initiator's second. + * + * Returns false, changing nothing, unless Initialize() retained the hybrid state, no + * DiscardHybridSecret() or SwitchToHybrid() happened since, and fewer than two contents + * were added. */ - void Initialize(const EllSwiftPubKey& their_pubkey, bool initiator, bool self_decrypt = false) noexcept; + [[nodiscard]] bool AddVersionContents(std::span contents) noexcept; + + /** Replace all four packet ciphers (counters at 0) and the session id with keys derived from + * the retained ECDH secret, the ML-KEM shared secret and the transcript. The garbage + * terminators are unchanged, and the retained secret is wiped. + * + * Returns false, changing nothing, unless the cipher is initialized, the hybrid state is + * retained, both version contents were added and no earlier switch happened. + */ + [[nodiscard]] bool SwitchToHybrid(std::span ss_mlkem) noexcept; + + /** Wipe the retained ECDH secret and reset the transcript; the current ciphers stay in use. */ + void DiscardHybridSecret() noexcept; + + /** Whether the retained ECDH secret or the transcript is still held. */ + bool HoldsHybridSecret() const noexcept { return m_retained_ecdh != nullptr || m_hybrid_transcript.has_value(); } /** Determine whether this cipher is fully initialized. */ explicit operator bool() const noexcept { return m_send_l_cipher.has_value(); } diff --git a/src/bip324_pq.cpp b/src/bip324_pq.cpp new file mode 100644 index 0000000000..09ec881a94 --- /dev/null +++ b/src/bip324_pq.cpp @@ -0,0 +1,200 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +void StrongFill32(void*, std::span out) noexcept +{ + GetStrongRandBytes(out); +} + +const PQKemOps DEFAULT_KEM_OPS{ + .keygen = &mlkem::KeyGen, + .check_public_key = &mlkem::CheckPublicKey, + .encaps = &mlkem::Encaps, + .decaps = &mlkem::Decaps, +}; + +} // namespace + +PQRandomSource DefaultPQRandomSource() noexcept +{ + return {.fill32 = &StrongFill32, .context = nullptr}; +} + +const PQKemOps& DefaultPQKemOps() noexcept +{ + return DEFAULT_KEM_OPS; +} + +PQHandshake::ParsedContents PQHandshake::ParseContents(std::span contents) noexcept +{ + try { + SpanReader reader{contents}; + std::optional> first; + while (!reader.empty()) { + // ReadCompactSize rejects non-canonical encodings and truncation by throwing. + const uint64_t len{ReadCompactSize(reader)}; + // Check the length against what is left before reading or skipping anything, as + // SpanReader::ignore() does not check bounds. + if (len == 0 || len > reader.size()) return {.kind = ParseKind::INVALID_GRAMMAR}; + uint8_t header; + reader >> header; + const size_t payload_len{size_t(len - 1)}; + if (header == PQ_MLKEM1024 && !first) { + first = contents.subspan(contents.size() - reader.size(), payload_len); + } + reader.ignore(payload_len); + } + if (first) return {.kind = ParseKind::OWN_RECORD, .payload = *first}; + return {.kind = ParseKind::NO_RECORD}; + } catch (const std::ios_base::failure&) { + return {.kind = ParseKind::INVALID_GRAMMAR}; + } +} + +PQHandshake::Record PQHandshake::SerializeRecord(std::span payload) noexcept +{ + // CompactSize(1 + 1568) = CompactSize(0x0621) = fd 21 06. + static_assert(1 + mlkem::PUBLIC_KEY_BYTES == 0x0621); + Record record; + record[0] = std::byte{0xfd}; + record[1] = std::byte{0x21}; + record[2] = std::byte{0x06}; + record[3] = std::byte{PQ_MLKEM1024}; + std::ranges::copy(std::as_bytes(payload), record.begin() + 4); + return record; +} + +PQHandshake::PQHandshake(bool initiating, PQMode mode) noexcept + : PQHandshake{initiating, mode, DefaultPQRandomSource(), DefaultPQKemOps()} {} + +PQHandshake::PQHandshake(bool initiating, PQMode mode, PQRandomSource random, const PQKemOps& ops) noexcept + : m_initiating{initiating}, m_mode{mode}, m_random{random}, m_ops{ops} {} + +PQHandshake::~PQHandshake() +{ + ClearSecrets(); +} + +std::optional PQHandshake::CheckRecordLength(std::span payload) noexcept +{ + // Only a negotiating initiator before it accepts, or a responder holding its offer's key, + // has a record to check: anything else must ignore the peer's records. + if (!Assume(m_mode == PQMode::NEGOTIATE && (m_initiating ? m_snapshot.offer == PQOfferState::NONE : m_dk != nullptr))) { + SetFailure(PQFailure::CIPHER_STATE_INTERNAL); + return std::nullopt; + } + if (payload.size() != mlkem::PUBLIC_KEY_BYTES) { + SetFailure(m_initiating ? PQFailure::EK_LENGTH : PQFailure::CT_LENGTH); + return std::nullopt; + } + return Payload{UCharCast(payload.data()), mlkem::PUBLIC_KEY_BYTES}; +} + +mlkem::Error PQHandshake::MakeOffer(Record& offer) noexcept +{ + // Only a negotiating responder offers, and only once. + if (!Assume(!m_initiating && m_mode == PQMode::NEGOTIATE && m_snapshot.offer == PQOfferState::NONE && !m_dk)) { + SetFailure(PQFailure::KEYGEN_INTERNAL); + return mlkem::Error::INTERNAL; + } + // ProcRand allows at most 32 bytes per call, so the 64-byte seed d || z takes two draws. + mlkem::Secret seed; + m_random.fill32(m_random.context, seed.Bytes().first<32>()); + m_random.fill32(m_random.context, seed.Bytes().last<32>()); + auto dk{make_secure_unique()}; + mlkem::PublicKey ek; + if (m_ops.keygen(seed.Bytes(), ek, *dk) != mlkem::Error::NONE) { + SetFailure(PQFailure::KEYGEN_INTERNAL); + return mlkem::Error::INTERNAL; + } + offer = SerializeRecord(ek); + m_dk = std::move(dk); + return mlkem::Error::NONE; +} + +void PQHandshake::SetOfferSent() noexcept +{ + if (Assume(!m_initiating && m_dk != nullptr && m_snapshot.offer == PQOfferState::NONE)) { + m_snapshot.offer = PQOfferState::SENT; + } +} + +mlkem::Error PQHandshake::AcceptOffer(Payload ek, Record& accept, mlkem::SharedSecret& ss) noexcept +{ + // Only a negotiating initiator accepts, and only once. + if (!Assume(m_initiating && m_mode == PQMode::NEGOTIATE && m_snapshot.offer == PQOfferState::NONE)) { + SetFailure(PQFailure::CHECK_EK_INTERNAL); + ss.Clear(); + return mlkem::Error::INTERNAL; + } + m_snapshot.offer = PQOfferState::RECEIVED; + switch (m_ops.check_public_key(ek)) { + case mlkem::Error::NONE: + break; + case mlkem::Error::INVALID_PUBLIC_KEY: + SetFailure(PQFailure::EK_MODULUS); + ss.Clear(); + return mlkem::Error::INVALID_PUBLIC_KEY; + default: + SetFailure(PQFailure::CHECK_EK_INTERNAL); + ss.Clear(); + return mlkem::Error::INTERNAL; + } + mlkem::Secret coins; + m_random.fill32(m_random.context, coins.Bytes()); + mlkem::Ciphertext ct; + // Encaps repeats the modulus check; failing it now is a local fault, not the peer's. + if (m_ops.encaps(ek, coins.Bytes(), ct, ss) != mlkem::Error::NONE) { + SetFailure(PQFailure::ENCAPS_INTERNAL); + ss.Clear(); + return mlkem::Error::INTERNAL; + } + accept = SerializeRecord(ct); + return mlkem::Error::NONE; +} + +mlkem::Error PQHandshake::DecapsulateAccept(Payload ct, mlkem::SharedSecret& ss) noexcept +{ + if (!Assume(m_dk != nullptr)) { + SetFailure(PQFailure::DECAPS_INTERNAL); + ss.Clear(); + return mlkem::Error::INTERNAL; + } + const mlkem::Error result{m_ops.decaps(*m_dk, ct, ss)}; + // The key is single use. + ClearSecrets(); + if (result != mlkem::Error::NONE) { + SetFailure(PQFailure::DECAPS_INTERNAL); + ss.Clear(); + return mlkem::Error::INTERNAL; + } + return mlkem::Error::NONE; +} + +void PQHandshake::SetFailure(PQFailure failure) noexcept +{ + if (m_snapshot.failure == PQFailure::NONE) m_snapshot.failure = failure; + ClearSecrets(); +} + +void PQHandshake::ClearSecrets() noexcept +{ + if (m_dk) { + m_dk->Clear(); + m_dk.reset(); + } +} diff --git a/src/bip324_pq.h b/src/bip324_pq.h new file mode 100644 index 0000000000..2ef1cc9bba --- /dev/null +++ b/src/bip324_pq.h @@ -0,0 +1,222 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#ifndef QBIT_BIP324_PQ_H +#define QBIT_BIP324_PQ_H + +#include +#include + +#include +#include +#include +#include +#include +#include + +/** + * Hybrid post-quantum negotiation for the BIP324 v2 transport (doc/design/pq-transport.md). + * + * Version-packet contents are zero or more records CompactSize(len) || header || payload, where + * len >= 1 counts the header and the payload. A responder offers its ML-KEM-1024 encapsulation key + * in a PQ_MLKEM1024 record, and an initiator accepts with the ciphertext in a record with the same + * header; the sender's role says which one a record is. + */ + +/** Record header of the ML-KEM-1024 offer (responder) and accept (initiator). 0x00 is reserved. */ +inline constexpr uint8_t PQ_MLKEM1024 = 0xF0; +/** An offer or accept record: CompactSize(1569) (3 bytes), the header and the 1,568-byte payload. */ +inline constexpr size_t PQ_RECORD_BYTES = 1572; +/** A key confirmation on the wire: encrypted length (3), encrypted header (1) and tag (16). */ +inline constexpr size_t PQ_CONFIRMATION_BYTES = 20; + +static_assert(mlkem::PUBLIC_KEY_BYTES == mlkem::CIPHERTEXT_BYTES); +static_assert(PQ_RECORD_BYTES == 3 + 1 + mlkem::PUBLIC_KEY_BYTES); + +/** What one side of a v2 connection does about the hybrid key exchange. */ +enum class PQMode : uint8_t { + //! Today's v2: empty version contents, the peer's contents ignored. + OFF = 0, + //! Responders offer an encapsulation key, initiators accept a valid offer. + NEGOTIATE = 1, + //! Plain v2 toward an endpoint in the fallback set (outbound only); behaves like OFF. + FALLBACK = 2, +}; + +/** What getpeerinfo reports as transport_pq_status. */ +enum class PQStatus : uint8_t { V1 = 0, OFF = 1, PENDING = 2, HYBRID = 3, LEGACY_PEER = 4, FALLBACK = 5 }; + +/** Offer progress. SENT (responder): the offer was queued for sending (SetOfferSent()), not + * necessarily received. RECEIVED (initiator): an offer of the right length was handed to + * AcceptOffer(). */ +enum class PQOfferState : uint8_t { NONE = 0, SENT = 1, RECEIVED = 2 }; + +/** The first failure of a connection's negotiation; later failures don't replace it. */ +enum class PQFailure : uint8_t { + NONE = 0, + // malformed_record: the first own record of the peer's contents. + EK_LENGTH = 1, + EK_MODULUS = 2, + CT_LENGTH = 3, + // first_packet_failed: the peer's key confirmation. + CONFIRM_LENGTH = 4, + CONFIRM_TAG = 5, + CONFIRM_NOT_DECOY = 6, + // internal_error: local faults, never caused by peer input. + KEYGEN_INTERNAL = 7, + CHECK_EK_INTERNAL = 8, + ENCAPS_INTERNAL = 9, + DECAPS_INTERNAL = 10, + CIPHER_STATE_INTERNAL = 11, +}; + +/** Why the peer counts as a legacy peer: its contents carried no own record, or did not parse. */ +enum class PQLegacyReason : uint8_t { NONE = 0, NO_FEATURES = 1, PARSE_ERROR = 2 }; + +/** Why the transport closed before the peer's version packet authenticated (the transport's own + * reasons for an abandoned offer). The first one recorded is kept. */ +enum class PQAbort : uint8_t { NONE = 0, GARBAGE_TERMINATOR = 1, VERSION_LENGTH = 2, VERSION_TAG = 3 }; + +/** Entropy for key generation and encapsulation, 32 bytes per call. */ +struct PQRandomSource { + using Fill32 = void (*)(void*, std::span) noexcept; + Fill32 fill32; + void* context{nullptr}; +}; + +/** The ML-KEM operations a handshake uses. Must have static lifetime. */ +struct PQKemOps { + decltype(&mlkem::KeyGen) keygen; + decltype(&mlkem::CheckPublicKey) check_public_key; + decltype(&mlkem::Encaps) encaps; + decltype(&mlkem::Decaps) decaps; +}; + +/** Production entropy: GetStrongRandBytes. */ +PQRandomSource DefaultPQRandomSource() noexcept; +/** Production operations: the mlkem:: wrapper. */ +const PQKemOps& DefaultPQKemOps() noexcept; + +/** + * The version-contents parser and one connection's KEM state. + * + * There is no mutex: the owning transport's receive lock protects the object. The operations + * record their own failures (EK_MODULUS and the internal ones); callers record the others with + * SetFailure(). Internal ML-KEM errors never assert: they return mlkem::Error::INTERNAL, and the + * caller decides what happens to the connection. + */ +class PQHandshake +{ +public: + enum class ParseKind : uint8_t { NO_RECORD = 0, INVALID_GRAMMAR = 1, OWN_RECORD = 2 }; + struct ParsedContents { + ParseKind kind{ParseKind::NO_RECORD}; + //! OWN_RECORD only: the payload of the first PQ_MLKEM1024 record, pointing into the contents. + std::span payload{}; + }; + using Record = std::array; + using Payload = std::span; + struct Snapshot { + PQOfferState offer{PQOfferState::NONE}; + //! The peer's version packet authenticated. + bool version_received{false}; + //! The hybrid keys were installed. + bool switched{false}; + //! The peer's key confirmation verified. + bool confirmed{false}; + PQFailure failure{PQFailure::NONE}; + //! Set when the negotiation ended with a legacy peer. + PQLegacyReason legacy{PQLegacyReason::NONE}; + PQAbort abort{PQAbort::NONE}; + }; + + /** + * Parse version-packet contents in one pass, without copying or allocating. + * + * The whole contents must parse as records. Empty contents, or a framing error anywhere + * (truncation, a zero, non-canonical or oversized length, trailing bytes), give NO_RECORD or + * INVALID_GRAMMAR: no features, never a reason to disconnect. Otherwise the first + * PQ_MLKEM1024 record wins; later ones and unknown headers are ignored. Nothing about the + * payload is checked here. + */ + static ParsedContents ParseContents(std::span contents) noexcept; + + /** An offer or accept record carrying payload. */ + static Record SerializeRecord(std::span payload) noexcept; + + /** Production entropy and operations. */ + PQHandshake(bool initiating, PQMode mode) noexcept; + /** Test entropy and operations; ops must have static lifetime. */ + PQHandshake(bool initiating, PQMode mode, PQRandomSource random, const PQKemOps& ops) noexcept; + PQHandshake(bool initiating, PQMode mode, PQRandomSource random, const PQKemOps&& ops) = delete; + ~PQHandshake(); + PQHandshake(const PQHandshake&) = delete; + PQHandshake& operator=(const PQHandshake&) = delete; + + /** + * Check the length of the first own record of the peer's contents: the offer (for an + * initiator, before AcceptOffer()) or the accept (for a responder holding the decapsulation + * key of its offer) carries exactly 1,568 bytes. A wrong length records EK_LENGTH or + * CT_LENGTH, wipes the secrets and returns nullopt. A call in any other state is a local + * bug: it records CIPHER_STATE_INTERNAL and returns nullopt. + */ + std::optional CheckRecordLength(std::span payload) noexcept; + + /** + * Responder: generate a key pair from two independent 32-byte draws. Success writes the + * offer and keeps the decapsulation key; the caller queues the offer and then calls + * SetOfferSent(). Failure (KEYGEN_INTERNAL) writes no offer. + */ + [[nodiscard]] mlkem::Error MakeOffer(Record& offer) noexcept; + + /** Responder: the offer from MakeOffer() was queued for sending. */ + void SetOfferSent() noexcept; + + /** + * Initiator: check the offered key and encapsulate to it with one 32-byte draw. Returns + * INVALID_PUBLIC_KEY (EK_MODULUS) for a key that fails the modulus check, which is the + * peer's fault, and INTERNAL (CHECK_EK_INTERNAL, ENCAPS_INTERNAL) for local faults. Only + * success writes the accept record and the shared secret. + */ + [[nodiscard]] mlkem::Error AcceptOffer(Payload ek, Record& accept, mlkem::SharedSecret& ss) noexcept; + + /** + * Responder: decapsulate the accepted ciphertext. A corrupted ciphertext of the right + * length is not an error (implicit rejection). The decapsulation key is wiped either way; + * failure (DECAPS_INTERNAL) clears ss. + */ + [[nodiscard]] mlkem::Error DecapsulateAccept(Payload ct, mlkem::SharedSecret& ss) noexcept; + + void SetVersionReceived() noexcept { m_snapshot.version_received = true; } + void SetSwitched() noexcept { m_snapshot.switched = true; } + void SetConfirmed() noexcept { m_snapshot.confirmed = true; } + /** The peer's contents parsed as kind, without an own record: a legacy peer. */ + void SetLegacy(ParseKind kind) noexcept + { + m_snapshot.legacy = kind == ParseKind::INVALID_GRAMMAR ? PQLegacyReason::PARSE_ERROR : PQLegacyReason::NO_FEATURES; + } + void SetAbort(PQAbort abort) noexcept + { + if (m_snapshot.abort == PQAbort::NONE) m_snapshot.abort = abort; + } + /** Record a failure unless one is recorded already, and wipe the secrets. */ + void SetFailure(PQFailure failure) noexcept; + Snapshot GetSnapshot() const noexcept { return m_snapshot; } + /** Whether a decapsulation key is held (between MakeOffer() and its wipe). */ + bool HasDecapsulationKey() const noexcept { return m_dk != nullptr; } + /** Wipe and release the decapsulation key. */ + void ClearSecrets() noexcept; + +private: + const bool m_initiating; + const PQMode m_mode; + PQRandomSource m_random; + const PQKemOps& m_ops; + //! In locked memory, excluded from core dumps, like CKey: a responder holds it until the + //! accept arrives. + secure_unique_ptr m_dk; + Snapshot m_snapshot; +}; + +#endif // QBIT_BIP324_PQ_H diff --git a/src/bitcoin-cli.cpp b/src/bitcoin-cli.cpp index a03bd5663c..d7020a75a1 100644 --- a/src/bitcoin-cli.cpp +++ b/src/bitcoin-cli.cpp @@ -407,6 +407,7 @@ class NetinfoRequestHandler : public BaseRequestHandler std::string age; std::string services; std::string transport_protocol_type; + bool is_transport_pq; double min_ping; double ping; int64_t addr_processed; @@ -555,10 +556,12 @@ class NetinfoRequestHandler : public BaseRequestHandler const std::string services{FormatServices(services_names)}; const std::string sub_version{peer["subver"].isNull() ? "" : peer["subver"].get_str()}; const std::string transport{peer["transport_protocol_type"].isNull() ? "v1" : peer["transport_protocol_type"].get_str()}; + // A server without hybrid post-quantum transport, such as v1.0.0, has no transport_pq. + const bool is_transport_pq{peer["transport_pq"].isNull() ? false : peer["transport_pq"].get_bool()}; const bool is_addr_relay_enabled{peer["addr_relay_enabled"].isNull() ? false : peer["addr_relay_enabled"].get_bool()}; const bool is_bip152_hb_from{peer["bip152_hb_from"].isNull() ? false : peer["bip152_hb_from"].get_bool()}; const bool is_bip152_hb_to{peer["bip152_hb_to"].isNull() ? false : peer["bip152_hb_to"].get_bool()}; - m_peers.push_back({addr, sub_version, conn_type, NETWORK_SHORT_NAMES[network_id], age, services, transport, min_ping, ping, addr_processed, addr_rate_limited, last_blck, last_recv, last_send, last_trxn, peer_id, mapped_as, version, is_addr_relay_enabled, is_bip152_hb_from, is_bip152_hb_to, is_outbound, is_tx_relay}); + m_peers.push_back({addr, sub_version, conn_type, NETWORK_SHORT_NAMES[network_id], age, services, transport, is_transport_pq, min_ping, ping, addr_processed, addr_rate_limited, last_blck, last_recv, last_send, last_trxn, peer_id, mapped_as, version, is_addr_relay_enabled, is_bip152_hb_from, is_bip152_hb_to, is_outbound, is_tx_relay}); m_max_addr_length = std::max(addr.length() + 1, m_max_addr_length); m_max_addr_processed_length = std::max(ToString(addr_processed).length(), m_max_addr_processed_length); m_max_addr_rate_limited_length = std::max(ToString(addr_rate_limited).length(), m_max_addr_rate_limited_length); @@ -593,7 +596,7 @@ class NetinfoRequestHandler : public BaseRequestHandler peer.network, m_max_services_length, // variable spacing peer.services, - (peer.transport_protocol_type.size() == 2 && peer.transport_protocol_type[0] == 'v') ? peer.transport_protocol_type[1] : ' ', + peer.is_transport_pq ? "2p" : (peer.transport_protocol_type.size() == 2 && peer.transport_protocol_type[0] == 'v') ? peer.transport_protocol_type.substr(1) : "", PingTimeToString(peer.min_ping), PingTimeToString(peer.ping), peer.last_send ? ToString(time_now - peer.last_send) : "", @@ -720,6 +723,7 @@ class NetinfoRequestHandler : public BaseRequestHandler " \"2\" - P2P_V2: peer supports version 2 P2P transport protocol, as defined in BIP 324\n" " \"u\" - UNKNOWN: unrecognized bit flag\n" " v Version of transport protocol used for the connection\n" + " \"2p\" - v2 with hybrid post-quantum session keys (getpeerinfo \"transport_pq\" is true)\n" " mping Minimum observed ping time, in milliseconds (ms)\n" " ping Last observed ping time, in milliseconds (ms)\n" " send Time since last message sent to the peer, in seconds\n" diff --git a/src/common/args.cpp b/src/common/args.cpp index ce0b414cca..54910d0239 100644 --- a/src/common/args.cpp +++ b/src/common/args.cpp @@ -745,6 +745,10 @@ const std::vector TEST_OPTIONS_DOC{ "reindex_after_failure_noninteractive_yes (When asked for a reindex after failure interactively, simulate as-if answered with 'yes')", "bip94 (enforce BIP94 consensus rules)", "disable_witness_pruning (disable witness pruning in regtest)", + "pq_fail_first_packet (corrupt this node's ML-KEM shared secret on every hybrid post-quantum v2 handshake, so the peer's key confirmation fails)", + "mlkem_portable (use the portable ML-KEM-1024 code instead of native code)", + "pq_low_shed_threshold (start shedding inbound hybrid post-quantum v2 offers above 5 attempts per second instead of 1000)", + "mock_steady_clock (setmocktime also sets the steady clock that durations such as load shedding's seconds are measured on; setmocktime 0 clears both)", }; bool HasTestOption(const ArgsManager& args, const std::string& test_option) diff --git a/src/common/args.h b/src/common/args.h index 1e07294bc2..2734a3c0ae 100644 --- a/src/common/args.h +++ b/src/common/args.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -418,6 +419,16 @@ class ArgsManager fn(m_settings); } + /** + * Read settings with lock held. + */ + template + void LockSettings(Fn&& fn) const + { + LOCK(cs_args); + fn(std::as_const(m_settings)); + } + /** * Log the config file options and the command line arguments, * useful for troubleshooting. diff --git a/src/compat/cpu_features.cpp b/src/compat/cpu_features.cpp new file mode 100644 index 0000000000..ffe249b670 --- /dev/null +++ b/src/compat/cpu_features.cpp @@ -0,0 +1,53 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#include + +#include + +namespace cpu_features { + +X86Features DetectX86Features(CpuidFn cpuid, XGetBVFn xgetbv) noexcept +{ + X86Features f; + uint32_t a{0}, b{0}, c{0}, d{0}; + cpuid(0, 0, a, b, c, d); + f.max_basic_leaf = a; + if (f.max_basic_leaf >= 1) { + cpuid(1, 0, a, b, c, d); + f.leaf1_ecx = c; + } + if (f.max_basic_leaf >= 7) { + cpuid(7, 0, a, b, c, d); + f.leaf7_ebx = b; + } + // XGETBV faults unless the OS enabled it, which leaf 1 reports as OSXSAVE. + if ((f.leaf1_ecx & LEAF1_ECX_OSXSAVE) != 0 && (f.leaf1_ecx & LEAF1_ECX_AVX) != 0) { + f.xcr0 = xgetbv(0); + } + return f; +} + +X86Features DetectX86Features() noexcept +{ +#if defined(HAVE_GETCPUID) + return DetectX86Features(GetCPUID, XGetBV); +#else + return {}; +#endif +} + +bool HasUsableAVX2() noexcept +{ + static const bool usable{HasUsableAVX2(DetectX86Features())}; + return usable; +} + +bool HasMlkemX86Native() noexcept +{ + static const bool usable{HasMlkemX86Native(DetectX86Features())}; + return usable; +} + +} // namespace cpu_features diff --git a/src/compat/cpu_features.h b/src/compat/cpu_features.h new file mode 100644 index 0000000000..9855211bb9 --- /dev/null +++ b/src/compat/cpu_features.h @@ -0,0 +1,83 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#ifndef QBIT_COMPAT_CPU_FEATURES_H +#define QBIT_COMPAT_CPU_FEATURES_H + +#include + +/** + * Standalone x86 feature detection for code that dispatches at run time. + * + * This deliberately shares only the CPUID and XGETBV primitives with + * SHA256AutoDetect, whose selection logic is tuned for SHA256 (it skips leaf 7 + * without SSE4 and drops AVX2 when SHA-NI is present). + */ +namespace cpu_features { + +inline constexpr uint32_t LEAF1_ECX_SSSE3{uint32_t{1} << 9}; +inline constexpr uint32_t LEAF1_ECX_SSE41{uint32_t{1} << 19}; +inline constexpr uint32_t LEAF1_ECX_POPCNT{uint32_t{1} << 23}; +inline constexpr uint32_t LEAF1_ECX_OSXSAVE{uint32_t{1} << 27}; +inline constexpr uint32_t LEAF1_ECX_AVX{uint32_t{1} << 28}; +inline constexpr uint32_t LEAF7_EBX_AVX2{uint32_t{1} << 5}; +inline constexpr uint32_t LEAF7_EBX_BMI2{uint32_t{1} << 8}; +/** XCR0 bits for XMM (SSE) and YMM (AVX) register state saved by the OS. */ +inline constexpr uint64_t XCR0_XMM_YMM{0x6}; + +struct X86Features { + uint32_t max_basic_leaf{0}; + uint32_t leaf1_ecx{0}; + uint32_t leaf7_ebx{0}; + uint64_t xcr0{0}; +}; + +using CpuidFn = void (*)(uint32_t leaf, uint32_t subleaf, uint32_t& a, uint32_t& b, uint32_t& c, uint32_t& d); +using XGetBVFn = uint64_t (*)(uint32_t index); + +/** + * Query the supported leaves through the given primitives, then read XCR0 + * only if leaf 1 reports both OSXSAVE and AVX. Exposed so tests can supply + * synthetic primitives. + */ +X86Features DetectX86Features(CpuidFn cpuid, XGetBVFn xgetbv) noexcept; + +/** Query this CPU and OS. All fields are zero on non-x86 targets and unsupported compilers. */ +X86Features DetectX86Features() noexcept; + +/** Whether the CPU supports AVX2 and the OS saves YMM state, so AVX2 code can run. */ +constexpr bool HasUsableAVX2(const X86Features& f) noexcept +{ + return f.max_basic_leaf >= 7 && + (f.leaf1_ecx & LEAF1_ECX_OSXSAVE) != 0 && + (f.leaf1_ecx & LEAF1_ECX_AVX) != 0 && + (f.xcr0 & XCR0_XMM_YMM) == XCR0_XMM_YMM && + (f.leaf7_ebx & LEAF7_EBX_AVX2) != 0; +} + +/** HasUsableAVX2() for this CPU and OS, detected on first call and then cached. */ +bool HasUsableAVX2() noexcept; + +/** + * Whether this CPU and OS can run mlkem-native's x86_64 assembly. Besides + * AVX2 (with AVX and the OS saving YMM state), that assembly executes SSSE3, + * SSE4.1, POPCNT and BMI2 instructions, which a CPUID mask in a virtual + * machine can hide even when AVX2 is shown. The list comes from an audit of + * the assembled objects; doc/subtrees/mlkem-native.md says how to repeat it. + */ +constexpr bool HasMlkemX86Native(const X86Features& f) noexcept +{ + return HasUsableAVX2(f) && + (f.leaf1_ecx & LEAF1_ECX_SSSE3) != 0 && + (f.leaf1_ecx & LEAF1_ECX_SSE41) != 0 && + (f.leaf1_ecx & LEAF1_ECX_POPCNT) != 0 && + (f.leaf7_ebx & LEAF7_EBX_BMI2) != 0; +} + +/** HasMlkemX86Native() for this CPU and OS, detected on first call and then cached. */ +bool HasMlkemX86Native() noexcept; + +} // namespace cpu_features + +#endif // QBIT_COMPAT_CPU_FEATURES_H diff --git a/src/compat/cpuid.h b/src/compat/cpuid.h index 10a959df75..562e1d8b5a 100644 --- a/src/compat/cpuid.h +++ b/src/compat/cpuid.h @@ -22,5 +22,13 @@ void static inline GetCPUID(uint32_t leaf, uint32_t subleaf, uint32_t& a, uint32 #endif } +/** Read extended control register `index` (XCR0 for 0). Faults unless CPUID leaf 1 reports OSXSAVE. */ +static inline uint64_t XGetBV(uint32_t index) noexcept +{ + uint32_t a, d; + __asm__("xgetbv" : "=a"(a), "=d"(d) : "c"(index)); + return (uint64_t{d} << 32) | a; +} + #endif // defined(__x86_64__) || defined(__amd64__) || defined(__i386__) #endif // QBIT_COMPAT_CPUID_H diff --git a/src/crypto/mlkem.cpp b/src/crypto/mlkem.cpp new file mode 100644 index 0000000000..5dcb4bbe68 --- /dev/null +++ b/src/crypto/mlkem.cpp @@ -0,0 +1,199 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#include + +#include +#include +#include + +#include + +#include +#include + +static_assert(MLKEM1024_PUBLICKEYBYTES == mlkem::PUBLIC_KEY_BYTES); +static_assert(MLKEM1024_SECRETKEYBYTES == mlkem::SECRET_KEY_BYTES); +static_assert(MLKEM1024_CIPHERTEXTBYTES == mlkem::CIPHERTEXT_BYTES); +static_assert(MLKEM1024_BYTES == mlkem::SHARED_SECRET_BYTES); +static_assert(2 * MLKEM1024_SYMBYTES == mlkem::KEYGEN_SEED_BYTES); +static_assert(MLKEM1024_SYMBYTES == mlkem::ENCAPS_COINS_BYTES); +static_assert(MLK_ERR_FAIL == mlkem::upstream::ERR_FAIL); +static_assert(MLK_ERR_OUT_OF_MEMORY == mlkem::upstream::ERR_OUT_OF_MEMORY); +static_assert(MLK_ERR_RNG_FAIL == mlkem::upstream::ERR_RNG_FAIL); +static_assert(MLK_ERR_INVALID_PK == mlkem::upstream::ERR_INVALID_PK); +static_assert(MLK_ERR_INVALID_SK == mlkem::upstream::ERR_INVALID_SK); +static_assert(MLK_ERR_PCT_FAIL == mlkem::upstream::ERR_PCT_FAIL); + +namespace { + +constexpr std::string_view PORTABLE{"portable"}; + +//! When set, every operation that starts afterwards runs portable C only. +std::atomic_bool g_force_portable{false}; + +//! Live ForcePortableForTesting guards; while any is alive, operations that start run portable C only. +std::atomic_int g_force_portable_guards{0}; + +/** + * g_force_portable as the operation running on this thread read it at entry. + * The capability hooks read only this copy, so a change on another thread + * mid-operation cannot mix backends: the x86_64 backend keeps its own + * coefficient order, and a mixed operation would give a wrong result. + */ +thread_local bool t_force_portable{false}; + +//! Whether the CPU and OS can run the x86_64 backend. Written once, by DetectCapabilities(). +std::atomic_bool g_cpu_x86_native{false}; + +//! Results injected by InjectResultForTesting, indexed by Operation; 0 means none. +std::array g_injected_results{}; + +/** + * Detect CPU features on the first call. Every entry point calls this before + * entering the library, so no CPUID or XGETBV runs inside an operation, and + * the capability hooks read only the cached result. + */ +void DetectCapabilities() noexcept +{ + static const bool detected{[] { + g_cpu_x86_native.store(cpu_features::HasMlkemX86Native(), std::memory_order_relaxed); + return true; + }()}; + (void)detected; +} + +/** Call at every entry point, before the library runs: fixes the backend for this operation. */ +void BeginOperation() noexcept +{ + DetectCapabilities(); + t_force_portable = g_force_portable.load(std::memory_order_relaxed) || + g_force_portable_guards.load(std::memory_order_relaxed) > 0; +} + +int Injected(mlkem::Operation op, int result) noexcept +{ + const int injected{g_injected_results[static_cast(op)].load(std::memory_order_relaxed)}; + return injected != 0 ? injected : result; +} + +mlkem::Error MapResult(mlkem::Operation op, int result) noexcept +{ + if (result == 0) return mlkem::Error::NONE; + if (result == MLK_ERR_INVALID_PK && (op == mlkem::Operation::CHECK_PUBLIC_KEY || op == mlkem::Operation::ENCAPS)) { + return mlkem::Error::INVALID_PUBLIC_KEY; + } + if (result == MLK_ERR_INVALID_SK && op == mlkem::Operation::DECAPS) return mlkem::Error::INVALID_SECRET_KEY; + // Anything else, including codes this configuration never produces, is an internal failure. + return mlkem::Error::INTERNAL; +} + +/** The backend that runs for a compiled-in backend name, given the capability hooks' current answers. */ +std::string_view ActiveBackend(std::string_view compiled) noexcept +{ + if (compiled == "x86_64-avx2") return qbit_mlkem_has_avx2() ? compiled : PORTABLE; + if (compiled == "aarch64-neon" || compiled == "aarch64") return qbit_mlkem_has_neon() ? compiled : PORTABLE; + return PORTABLE; +} + +} // namespace + +extern "C" void qbit_mlkem_zeroize(void* ptr, size_t len) +{ + memory_cleanse(ptr, len); +} + +extern "C" int qbit_mlkem_has_avx2(void) +{ + return !t_force_portable && g_cpu_x86_native.load(std::memory_order_relaxed) ? 1 : 0; +} + +extern "C" int qbit_mlkem_has_neon(void) +{ +#if defined(__aarch64__) || defined(_M_ARM64) + // NEON is mandatory on AArch64. + return !t_force_portable ? 1 : 0; +#else + return 0; +#endif +} + +namespace mlkem { + +Error KeyGen(std::span seed64, PublicKey& ek, DecapsulationKey& dk) noexcept +{ + BeginOperation(); + const int result{Injected(Operation::KEYGEN, qbit_mlkem1024_keypair_derand(ek.data(), dk.Bytes().data(), seed64.data()))}; + const Error error{MapResult(Operation::KEYGEN, result)}; + if (error != Error::NONE) { + dk.Clear(); + memory_cleanse(ek.data(), ek.size()); + } + return error; +} + +Error CheckPublicKey(std::span ek) noexcept +{ + BeginOperation(); + return MapResult(Operation::CHECK_PUBLIC_KEY, Injected(Operation::CHECK_PUBLIC_KEY, qbit_mlkem1024_check_pk(ek.data()))); +} + +Error Encaps(std::span ek, std::span coins32, + Ciphertext& ct, SharedSecret& ss) noexcept +{ + BeginOperation(); + const int result{Injected(Operation::ENCAPS, qbit_mlkem1024_enc_derand(ct.data(), ss.Bytes().data(), ek.data(), coins32.data()))}; + const Error error{MapResult(Operation::ENCAPS, result)}; + if (error != Error::NONE) { + ss.Clear(); + memory_cleanse(ct.data(), ct.size()); + } + return error; +} + +Error Decaps(const DecapsulationKey& dk, std::span ct, SharedSecret& ss) noexcept +{ + BeginOperation(); + const int result{Injected(Operation::DECAPS, qbit_mlkem1024_dec(ss.Bytes().data(), ct.data(), dk.Bytes().data()))}; + const Error error{MapResult(Operation::DECAPS, result)}; + if (error != Error::NONE) ss.Clear(); + return error; +} + +void InitializeRuntime(bool force_portable) noexcept +{ + DetectCapabilities(); + g_force_portable.store(force_portable); +} + +BackendNames GetBackendNames() noexcept +{ + BeginOperation(); + return { + .arith = ActiveBackend(qbit_mlkem_compiled_arith_backend()), + .keccak = ActiveBackend(qbit_mlkem_compiled_keccak_backend()), + }; +} + +ForcePortableForTesting::ForcePortableForTesting() noexcept +{ + g_force_portable_guards.fetch_add(1); +} + +ForcePortableForTesting::~ForcePortableForTesting() +{ + g_force_portable_guards.fetch_sub(1); +} + +InjectResultForTesting::InjectResultForTesting(Operation op, int upstream_result) noexcept + : m_op{op}, m_previous{g_injected_results[static_cast(op)].exchange(upstream_result)} +{ +} + +InjectResultForTesting::~InjectResultForTesting() +{ + g_injected_results[static_cast(m_op)].store(m_previous); +} + +} // namespace mlkem diff --git a/src/crypto/mlkem.h b/src/crypto/mlkem.h new file mode 100644 index 0000000000..00903bee85 --- /dev/null +++ b/src/crypto/mlkem.h @@ -0,0 +1,156 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#ifndef QBIT_CRYPTO_MLKEM_H +#define QBIT_CRYPTO_MLKEM_H + +#include + +#include +#include +#include +#include +#include + +/** + * ML-KEM-1024 (FIPS 203), backed by the vendored mlkem-native library. + * + * Native code runs where it is built and the CPU allows it: x86_64 with AVX2, + * SSSE3, SSE4.1, POPCNT and BMI2 (and the OS saving AVX state), and AArch64 on + * macOS. Everything else runs portable C, AArch64 ELF (Linux, BSD) included, + * whose builds leave the assembly out to keep their BTI and PAC marking. See + * doc/subtrees/mlkem-native.md. The library is built into bitcoin_node only. + * + * Fixed-size spans make wrong-sized calls impossible; callers check network + * lengths before constructing them. Entropy is the caller's job: key + * generation takes two independent 32-byte draws, encapsulation one. + */ +namespace mlkem { +inline constexpr size_t PUBLIC_KEY_BYTES = 1568; +inline constexpr size_t SECRET_KEY_BYTES = 3168; +inline constexpr size_t CIPHERTEXT_BYTES = 1568; +inline constexpr size_t SHARED_SECRET_BYTES = 32; +inline constexpr size_t KEYGEN_SEED_BYTES = 64; +inline constexpr size_t ENCAPS_COINS_BYTES = 32; + +/** Fixed-size secret bytes, wiped with memory_cleanse on destruction, Clear() and move-from. */ +template +class Secret +{ + std::array m_bytes{}; + +public: + Secret() noexcept = default; + ~Secret() { Clear(); } + Secret(const Secret&) = delete; + Secret& operator=(const Secret&) = delete; + Secret(Secret&& other) noexcept : m_bytes{other.m_bytes} { other.Clear(); } + Secret& operator=(Secret&& other) noexcept + { + if (this != &other) { + m_bytes = other.m_bytes; + other.Clear(); + } + return *this; + } + std::span Bytes() noexcept { return m_bytes; } + std::span Bytes() const noexcept { return m_bytes; } + void Clear() noexcept { memory_cleanse(m_bytes.data(), m_bytes.size()); } +}; + +using PublicKey = std::array; +using Ciphertext = std::array; +using DecapsulationKey = Secret; +using SharedSecret = Secret; + +enum class Error : uint8_t { NONE = 0, INVALID_PUBLIC_KEY = 1, INVALID_SECRET_KEY = 2, INTERNAL = 3 }; + +/** + * Deterministic key generation (FIPS 203 ML-KEM.KeyGen_internal) from + * seed64 = d || z. On failure, ek and dk are cleared. + */ +[[nodiscard]] Error KeyGen(std::span seed64, PublicKey& ek, DecapsulationKey& dk) noexcept; + +/** The FIPS 203 section 7.2 modulus check: INVALID_PUBLIC_KEY if a coefficient is not below q. */ +[[nodiscard]] Error CheckPublicKey(std::span ek) noexcept; + +/** + * Deterministic encapsulation (FIPS 203 ML-KEM.Encaps_internal), including the + * modulus check of ek. On failure, ct and ss are cleared. + */ +[[nodiscard]] Error Encaps(std::span ek, std::span coins32, + Ciphertext& ct, SharedSecret& ss) noexcept; + +/** + * Decapsulation (FIPS 203 ML-KEM.Decaps), including the section 7.3 hash + * check of dk (INVALID_SECRET_KEY). A corrupted ciphertext of the right + * length is not an error: it yields the implicit-rejection secret, which + * differs from the encapsulated one. On failure, ss is cleared. + */ +[[nodiscard]] Error Decaps(const DecapsulationKey& dk, std::span ct, + SharedSecret& ss) noexcept; + +struct BackendNames { + std::string_view arith; //!< "x86_64-avx2", "aarch64-neon" or "portable" + std::string_view keccak; //!< "x86_64-avx2", "aarch64" or "portable" +}; + +/** + * Detect CPU features and set the portable override. Call once at startup, + * before networking starts. Operations work without it, with auto-detection. + */ +void InitializeRuntime(bool force_portable) noexcept; + +/** The implementation that runs now: native only if compiled in, supported by the CPU and not overridden. */ +BackendNames GetBackendNames() noexcept; + +/** + * Forces portable C while alive (RAII). Guards are counted, so they may be + * created and destroyed in any order, on any thread: portable C is forced + * while at least one is alive, and afterwards the startup override + * (InitializeRuntime()) applies again. Each operation reads the override + * once, at entry, so one that is already running finishes on the backend it + * started with. Test code only. + */ +class ForcePortableForTesting +{ +public: + ForcePortableForTesting() noexcept; + ~ForcePortableForTesting(); + ForcePortableForTesting(const ForcePortableForTesting&) = delete; + ForcePortableForTesting& operator=(const ForcePortableForTesting&) = delete; +}; + +/** mlkem-native's error codes (mlkem/mlkem_native.h), for InjectResultForTesting. */ +namespace upstream { +inline constexpr int ERR_FAIL{-1}; +inline constexpr int ERR_OUT_OF_MEMORY{-2}; +inline constexpr int ERR_RNG_FAIL{-3}; +inline constexpr int ERR_INVALID_PK{-4}; +inline constexpr int ERR_INVALID_SK{-5}; +inline constexpr int ERR_PCT_FAIL{-6}; +} // namespace upstream + +enum class Operation : uint8_t { KEYGEN, CHECK_PUBLIC_KEY, ENCAPS, DECAPS }; + +/** + * Test-only (RAII): while alive, `op` behaves as if mlkem-native had returned + * `upstream_result` (non-zero) after writing its outputs. This drives the + * wrapper's error mapping and output clearing, and lets callers test their + * handling of internal errors. Single-threaded test code only. + */ +class InjectResultForTesting +{ + Operation m_op; + int m_previous; + +public: + InjectResultForTesting(Operation op, int upstream_result) noexcept; + ~InjectResultForTesting(); + InjectResultForTesting(const InjectResultForTesting&) = delete; + InjectResultForTesting& operator=(const InjectResultForTesting&) = delete; +}; +} // namespace mlkem + +#endif // QBIT_CRYPTO_MLKEM_H diff --git a/src/crypto/mlkem_arith_backend.h b/src/crypto/mlkem_arith_backend.h new file mode 100644 index 0000000000..8d9a49625d --- /dev/null +++ b/src/crypto/mlkem_arith_backend.h @@ -0,0 +1,29 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#ifndef QBIT_CRYPTO_MLKEM_ARITH_BACKEND_H +#define QBIT_CRYPTO_MLKEM_ARITH_BACKEND_H + +// mlkem-native arithmetic backend (MLK_CONFIG_ARITH_BACKEND_FILE), used only +// when crypto/mlkem_config.h enables native code. +// +// Upstream's default selector, native/meta.h, picks the x86_64 backend only +// when the compiler itself targets AVX2, which baseline release builds do not. +// qbit includes the backend unconditionally instead: it is assembly plus +// constant tables, and every entry point asks mlk_sys_check_capability() +// before running, so CPUs without AVX2 take the portable C path. qbit answers +// MLK_SYS_CAP_X86_64_AVX2 for every extension the assembly executes (AVX2, +// SSSE3, SSE4.1, POPCNT, BMI2), not only the AVX2 its name says. + +#if defined(MLK_SYS_X86_64) +#define QBIT_MLKEM_ARITH_BACKEND_NAME "x86_64-avx2" +#include +#elif defined(MLK_SYS_AARCH64) +#define QBIT_MLKEM_ARITH_BACKEND_NAME "aarch64-neon" +#include +#else +#error "No mlkem-native arithmetic backend for this target." +#endif + +#endif // QBIT_CRYPTO_MLKEM_ARITH_BACKEND_H diff --git a/src/crypto/mlkem_backend.c b/src/crypto/mlkem_backend.c new file mode 100644 index 0000000000..933261e8ab --- /dev/null +++ b/src/crypto/mlkem_backend.c @@ -0,0 +1,29 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +// Reports which native backends mlkem-native was compiled with. This file is +// built with the same configuration and flags as mlkem-native's single +// compilation unit, so the backend headers it sees are the ones compiled in. + +#include + +#include + +const char* qbit_mlkem_compiled_arith_backend(void) +{ +#if defined(QBIT_MLKEM_ARITH_BACKEND_NAME) + return QBIT_MLKEM_ARITH_BACKEND_NAME; +#else + return "portable"; +#endif +} + +const char* qbit_mlkem_compiled_keccak_backend(void) +{ +#if defined(QBIT_MLKEM_KECCAK_BACKEND_NAME) + return QBIT_MLKEM_KECCAK_BACKEND_NAME; +#else + return "portable"; +#endif +} diff --git a/src/crypto/mlkem_config.h b/src/crypto/mlkem_config.h new file mode 100644 index 0000000000..488db496bf --- /dev/null +++ b/src/crypto/mlkem_config.h @@ -0,0 +1,89 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#ifndef QBIT_CRYPTO_MLKEM_CONFIG_H +#define QBIT_CRYPTO_MLKEM_CONFIG_H + +// qbit's configuration of the vendored mlkem-native library, selected with +// MLK_CONFIG_FILE in place of upstream's mlkem/mlkem_native_config.h, which +// documents every option used here. cmake/mlkem-native.cmake decides whether +// native code may be built and passes that decision in: +// +// QBIT_MLKEM_NATIVE build the native backends for this target (x86_64 with +// the SysV ABI, or AArch64 with NEON on Apple: AArch64 +// ELF targets stay portable because the assembly would +// strip their BTI and PAC marking); +// MLK_CONFIG_NO_ASM no assembly at all (MSVC and other toolchains without +// GNU assembler support). +// +// Neither set means portable C with inline-assembly value barriers. + +#define MLK_CONFIG_PARAMETER_SET 1024 +#define MLK_CONFIG_NAMESPACE_PREFIX qbit_mlkem1024 +// Only the deterministic APIs exist: callers supply all entropy. +#define MLK_CONFIG_NO_RANDOMIZED_API + +#if defined(MLK_BUILD_INTERNAL) + +#define MLK_CONFIG_CUSTOM_ZEROIZE +#define MLK_CONFIG_CUSTOM_CAPABILITY_FUNC + +// After the options sys.h reads, before the code that needs its definitions. +#include + +#if defined(QBIT_MLKEM_NATIVE) +#if defined(MLK_CONFIG_NO_ASM) +#error "QBIT_MLKEM_NATIVE and MLK_CONFIG_NO_ASM are both set; cmake/mlkem-native.cmake sets at most one." +#endif +#if defined(__has_feature) +#if __has_feature(memory_sanitizer) +#error "MemorySanitizer cannot see writes made by assembly; configure with WITH_MLKEM_NATIVE=AUTO or OFF." +#endif +#endif +#if defined(MLK_SYS_AARCH64) && defined(__ELF__) +#error "mlkem-native AArch64 assembly has no BTI landing pads or GNU property note; AArch64 ELF builds are portable only." +#endif +// Enable a backend only where qbit selects and tests one. Never define these +// to 0: upstream checks whether they are defined. +#if (defined(MLK_SYS_X86_64) && defined(MLK_SYSV_ABI_SUPPORTED)) || \ + (defined(MLK_SYS_AARCH64) && defined(MLK_SYS_AARCH64_NEON) && defined(MLK_SYS_APPLE)) +#define MLK_CONFIG_USE_NATIVE_BACKEND_ARITH +#define MLK_CONFIG_USE_NATIVE_BACKEND_FIPS202 +#define MLK_CONFIG_ARITH_BACKEND_FILE "crypto/mlkem_arith_backend.h" +#define MLK_CONFIG_FIPS202_BACKEND_FILE "crypto/mlkem_fips202_backend.h" +#else +#error "QBIT_MLKEM_NATIVE is set, but qbit has no native ML-KEM backend for this target; cmake/mlkem-native.cmake and this header disagree." +#endif +#endif // QBIT_MLKEM_NATIVE + +#if !defined(__ASSEMBLER__) +#include + +static MLK_INLINE void mlk_zeroize(void* ptr, size_t len) +{ + qbit_mlkem_zeroize(ptr, len); +} + +// Backends call this 80 to 100 times per operation, so it reads only state +// that crypto/mlkem.cpp cached before the operation started. Capabilities qbit +// does not use, MLK_SYS_CAP_AARCH64_SHA3 included, are never available. +MLK_MUST_CHECK_RETURN_VALUE +static MLK_INLINE int mlk_sys_check_capability(mlk_sys_cap cap) +{ +#if defined(MLK_SYS_X86_64) + // Upstream gates its whole x86_64 backend on this one capability, so the + // answer covers every extension that assembly executes, not only AVX2. + if (cap == MLK_SYS_CAP_X86_64_AVX2) return qbit_mlkem_has_avx2() ? 1 : 0; +#endif +#if defined(MLK_SYS_AARCH64) + if (cap == MLK_SYS_CAP_AARCH64_NEON) return qbit_mlkem_has_neon() ? 1 : 0; +#endif + (void)cap; + return 0; +} +#endif // !__ASSEMBLER__ + +#endif // MLK_BUILD_INTERNAL + +#endif // QBIT_CRYPTO_MLKEM_CONFIG_H diff --git a/src/crypto/mlkem_fips202_backend.h b/src/crypto/mlkem_fips202_backend.h new file mode 100644 index 0000000000..26138ce5db --- /dev/null +++ b/src/crypto/mlkem_fips202_backend.h @@ -0,0 +1,55 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#ifndef QBIT_CRYPTO_MLKEM_FIPS202_BACKEND_H +#define QBIT_CRYPTO_MLKEM_FIPS202_BACKEND_H + +// mlkem-native Keccak backend (MLK_CONFIG_FIPS202_BACKEND_FILE), used only +// when crypto/mlkem_config.h enables native code. See +// crypto/mlkem_arith_backend.h for why the backend is chosen here rather than +// by upstream's fips202/native/auto.h. + +#if defined(MLK_SYS_X86_64) +#define QBIT_MLKEM_KECCAK_BACKEND_NAME "x86_64-avx2" +#include + +#elif defined(MLK_SYS_AARCH64) +#define QBIT_MLKEM_KECCAK_BACKEND_NAME "aarch64" +// The Keccak upstream picks for AArch64 without the SHA3 extension, used on +// Apple too even though Apple CPUs have it, so that every native AArch64 build +// runs the same code. (Native AArch64 is Apple-only for now; see +// crypto/mlkem_config.h.) +#include + +// Single-lane Keccak. This mirrors mlkem/src/fips202/native/aarch64/x1_scalar.h +// of mlkem-native v2.0.0 (d1b2fe782888bdb761a50336012923180be7f502): the same +// macros and the same proved assembly routine. The one difference is the +// capability check. Upstream's x1 entry point runs its scalar assembly +// unconditionally, so the portable override could not reach it, and +// mlkem::GetBackendNames() would claim portable Keccak while assembly ran. +// Here it falls back to C whenever the AArch64 capability is off. On every +// library update, diff upstream's x1_scalar.h against this block (see +// doc/subtrees/mlkem-native.md). +#define MLK_USE_NATIVE_FIPS202_X1 +#define MLK_FIPS202_AARCH64_NEED_X1_SCALAR +#if !defined(__ASSEMBLER__) +#include +#include +MLK_MUST_CHECK_RETURN_VALUE +static MLK_INLINE int mlk_keccak_f1600_x1_native(uint64_t* state) +{ + if (!mlk_sys_check_capability(MLK_SYS_CAP_AARCH64_NEON)) { + return MLK_NATIVE_FUNC_FALLBACK; + } + + mlk_keccak_f1600_x1_scalar_aarch64_asm(state, mlk_keccakf1600_round_constants); + return MLK_NATIVE_FUNC_SUCCESS; +} +#endif // !__ASSEMBLER__ + +#else +#error "No mlkem-native Keccak backend for this target." +#endif + +#endif // QBIT_CRYPTO_MLKEM_FIPS202_BACKEND_H diff --git a/src/crypto/mlkem_shim.h b/src/crypto/mlkem_shim.h new file mode 100644 index 0000000000..a7824fa339 --- /dev/null +++ b/src/crypto/mlkem_shim.h @@ -0,0 +1,44 @@ +// Copyright (c) 2026-present The qbit core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit. + +#ifndef QBIT_CRYPTO_MLKEM_SHIM_H +#define QBIT_CRYPTO_MLKEM_SHIM_H + +// C ABI between mlkem-native, which is C compiled with crypto/mlkem_config.h, +// and qbit's C++ code. mlkem-native's assembly includes the configuration too, +// so the declarations are hidden from the assembler. + +#if !defined(__ASSEMBLER__) +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Implemented in C++, in crypto/mlkem.cpp. + +/** Wipe len bytes at ptr. Forwards to memory_cleanse. */ +void qbit_mlkem_zeroize(void* ptr, size_t len); +/** + * 1 if mlkem-native may run its x86_64 assembly now, else 0. Besides AVX2 that + * assembly needs SSSE3, SSE4.1, POPCNT and BMI2 (cpu_features::HasMlkemX86Native). + * Reads cached state only. + */ +int qbit_mlkem_has_avx2(void); +/** 1 if mlkem-native may run its AArch64 code now, else 0. Reads cached state only. */ +int qbit_mlkem_has_neon(void); + +// Implemented in C, in crypto/mlkem_backend.c, with mlkem-native's configuration. + +/** Name of the native arithmetic backend compiled in, or "portable". */ +const char* qbit_mlkem_compiled_arith_backend(void); +/** Name of the native Keccak backend compiled in, or "portable". */ +const char* qbit_mlkem_compiled_keccak_backend(void); + +#ifdef __cplusplus +} +#endif +#endif // !__ASSEMBLER__ + +#endif // QBIT_CRYPTO_MLKEM_SHIM_H diff --git a/src/crypto/sha256.cpp b/src/crypto/sha256.cpp index d75d01b6b7..0bb381a9e5 100644 --- a/src/crypto/sha256.cpp +++ b/src/crypto/sha256.cpp @@ -571,11 +571,9 @@ bool SelfTest() { #if !defined(DISABLE_OPTIMIZED_SHA256) #if (defined(__x86_64__) || defined(__amd64__) || defined(__i386__)) /** Check whether the OS has enabled AVX registers. */ -bool AVXEnabled() +bool AVXEnabled(sha256_implementation::XGetBVFn xgetbv) { - uint32_t a, d; - __asm__("xgetbv" : "=a"(a), "=d"(d) : "c"(0)); - return (a & 6) == 6; + return (xgetbv(0) & 6) == 6; } #endif #endif // DISABLE_OPTIMIZED_SHA256 @@ -583,6 +581,17 @@ bool AVXEnabled() std::string SHA256AutoDetect(sha256_implementation::UseImplementation use_implementation) +{ +#if defined(HAVE_GETCPUID) + return SHA256AutoDetect(use_implementation, GetCPUID, XGetBV); +#else + return SHA256AutoDetect(use_implementation, nullptr, nullptr); +#endif +} + +std::string SHA256AutoDetect(sha256_implementation::UseImplementation use_implementation, + [[maybe_unused]] sha256_implementation::CpuidFn cpuid, + [[maybe_unused]] sha256_implementation::XGetBVFn xgetbv) { std::string ret = "standard"; Transform = sha256::Transform; @@ -601,17 +610,29 @@ std::string SHA256AutoDetect(sha256_implementation::UseImplementation use_implem [[maybe_unused]] bool enabled_avx = false; uint32_t eax, ebx, ecx, edx; - GetCPUID(1, 0, eax, ebx, ecx, edx); + // Fixes an upstream Bitcoin Core bug, which reads leaf 7 without checking + // the maximum basic leaf. A CPU answers a leaf above it with the data of + // another leaf: in a virtual machine limited to leaf 1, leaf 7's EBX is + // leaf 1's, whose bits 31:24 are the APIC ID, so bit 29 can claim SHA-NI + // and the self-test below would then die with SIGILL. + cpuid(0, 0, eax, ebx, ecx, edx); + const uint32_t max_basic_leaf{eax}; + cpuid(1, 0, eax, ebx, ecx, edx); if (use_implementation & sha256_implementation::USE_SSE4) { - have_sse4 = (ecx >> 19) & 1; + // Fixes an upstream Bitcoin Core bug, which checks SSE4.1 only. The + // SSE4 code and the SSE4.1 and SHA-NI code chosen under this check + // also execute SSSE3 instructions (pshufb, palignr), which a CPU with + // SSE4.1 need not report: a virtual machine can mask SSSE3 alone, and + // the self-test below would then die with SIGILL. + have_sse4 = ((ecx >> 19) & 1) && ((ecx >> 9) & 1); } have_xsave = (ecx >> 27) & 1; have_avx = (ecx >> 28) & 1; if (have_xsave && have_avx) { - enabled_avx = AVXEnabled(); + enabled_avx = AVXEnabled(xgetbv); } - if (have_sse4) { - GetCPUID(7, 0, eax, ebx, ecx, edx); + if (have_sse4 && max_basic_leaf >= 7) { + cpuid(7, 0, eax, ebx, ecx, edx); if (use_implementation & sha256_implementation::USE_AVX2) { have_avx2 = (ebx >> 5) & 1; } diff --git a/src/crypto/sha256.h b/src/crypto/sha256.h index f8a3b2c930..dc71b603cd 100644 --- a/src/crypto/sha256.h +++ b/src/crypto/sha256.h @@ -36,6 +36,10 @@ enum UseImplementation : uint8_t { USE_SSE4_AND_SHANI = USE_SSE4 | USE_SHANI, USE_ALL = USE_SSE4 | USE_AVX2 | USE_SHANI, }; + +/** The x86 CPUID and XGETBV queries, as compat/cpuid.h's GetCPUID and XGetBV. */ +using CpuidFn = void (*)(uint32_t leaf, uint32_t subleaf, uint32_t& a, uint32_t& b, uint32_t& c, uint32_t& d); +using XGetBVFn = uint64_t (*)(uint32_t index); } /** Autodetect the best available SHA256 implementation. @@ -43,6 +47,14 @@ enum UseImplementation : uint8_t { */ std::string SHA256AutoDetect(sha256_implementation::UseImplementation use_implementation = sha256_implementation::USE_ALL); +/** SHA256AutoDetect with the x86 CPUID and XGETBV queries answered by the + * given functions, so that tests can present synthetic CPUs; other targets + * ignore them. The selected code runs its self-test, so a synthetic CPU must + * not claim an extension this one lacks. + */ +std::string SHA256AutoDetect(sha256_implementation::UseImplementation use_implementation, + sha256_implementation::CpuidFn cpuid, sha256_implementation::XGetBVFn xgetbv); + /** Compute multiple double-SHA256's of 64-byte blobs. * output: pointer to a blocks*32 byte output buffer * input: pointer to a blocks*64 byte input buffer diff --git a/src/i2p.cpp b/src/i2p.cpp index 8b48615afe..8b6a45b8e7 100644 --- a/src/i2p.cpp +++ b/src/i2p.cpp @@ -21,11 +21,18 @@ #include #include +#include +#include +#include #include +#include +#include #include #include #include #include +#include +#include using util::Split; @@ -117,19 +124,236 @@ static CNetAddr DestB64ToAddr(const std::string& dest) namespace sam { +namespace { +/** + * i2cp.leaseSetEncType for new sessions: the post-quantum hybrid MLKEM768-X25519 (6), and + * ECIES-X25519 (4) for peers without it. + */ +constexpr std::string_view LEASESET_ENC_TYPES_HYBRID{"6,4"}; + +/** + * i2cp.leaseSetEncType for routers that reject the hybrid type: ECIES-X25519 (4) and ElGamal (0). + */ +constexpr std::string_view LEASESET_ENC_TYPES_LEGACY{"4,0"}; + +/** + * Longest part of a router-supplied RESULT= or MESSAGE= value that is logged. + */ +constexpr size_t MAX_LOGGED_REPLY_VALUE{200}; + +/** + * qbit-only: what is known about a SAM router's support for i2cp.leaseSetEncType=6,4. + */ +struct RouterHybridState { + /** + * Sessions in a row in which the router rejected 6,4 naming the encryption type and accepted + * 4,0. Sessions in which the rejection did not name it are not counted and do not break the row. + */ + unsigned fallbacks{0}; + /** Whether the warning about the router was logged. */ + bool warned{false}; + /** Position of the latest update among all updates, to forget the oldest router. */ + uint64_t updated{0}; +}; + +/** + * qbit-only: `RouterHybridState` of SAM routers, by `Proxy::ToString()` (TCP endpoint or Unix + * socket path). Kept for the process lifetime and shared by all sessions, so that later sessions + * through a router (e.g. transient ones) start with 4,0 once it has rejected 6,4 naming the + * encryption type in `SAM_HYBRID_FALLBACKS_TO_REMEMBER` sessions in a row. Only such rejections + * add a router. At most `SAM_MAX_REMEMBERED_ROUTERS` routers are kept, so a forgotten router may + * be warned about again. + */ +GlobalMutex g_router_hybrid_mutex; +std::map g_router_hybrid GUARDED_BY(g_router_hybrid_mutex); +uint64_t g_router_hybrid_updates GUARDED_BY(g_router_hybrid_mutex){0}; + +/** + * Whether sessions through `router` start with 4,0 without asking for 6,4. + */ +bool RouterLacksHybrid(const std::string& router) +{ + LOCK(g_router_hybrid_mutex); + const auto it{g_router_hybrid.find(router)}; + return it != g_router_hybrid.end() && it->second.fallbacks >= SAM_HYBRID_FALLBACKS_TO_REMEMBER; +} + +/** + * A session through `router` was created with 6,4: its rejections so far were not in a row. + */ +void NoteHybridAccepted(const std::string& router) +{ + LOCK(g_router_hybrid_mutex); + const auto it{g_router_hybrid.find(router)}; + if (it != g_router_hybrid.end()) { + it->second.fallbacks = 0; + it->second.updated = ++g_router_hybrid_updates; + } +} + +struct HybridFallbackNoted { + /** This is the first such session through the router: warn about it. */ + bool warn; + /** From now on sessions through the router start with 4,0. */ + bool remembered; +}; + +/** + * A session through `router` was created with 4,0 after the router rejected 6,4 naming the + * encryption type (see `NamesEncType()`), evidence that it lacks 6,4. A rejection that does not + * name it may be a passing router error and must not be noted here. + */ +HybridFallbackNoted NoteHybridFallback(const std::string& router) +{ + LOCK(g_router_hybrid_mutex); + auto it{g_router_hybrid.find(router)}; + if (it == g_router_hybrid.end()) { + if (g_router_hybrid.size() >= SAM_MAX_REMEMBERED_ROUTERS) { + g_router_hybrid.erase(std::ranges::min_element(g_router_hybrid, {}, [](const auto& entry) { return entry.second.updated; })); + } + it = g_router_hybrid.emplace(router, RouterHybridState{}).first; + } + RouterHybridState& state{it->second}; + state.updated = ++g_router_hybrid_updates; + const bool was_remembered{state.fallbacks >= SAM_HYBRID_FALLBACKS_TO_REMEMBER}; + state.fallbacks = std::min(state.fallbacks + 1, SAM_HYBRID_FALLBACKS_TO_REMEMBER); + return {.warn = !std::exchange(state.warned, true), + .remembered = !was_remembered && state.fallbacks >= SAM_HYBRID_FALLBACKS_TO_REMEMBER}; +} + +/** + * Whether a non-OK RESULT of "SESSION STATUS" can be about the leaseset encryption type. + * These cannot: the session id or the destination is the problem. + */ +bool MayBeEncTypeRejection(std::string_view result) +{ + return result != "DUPLICATED_ID" && result != "DUPLICATED_DEST" && result != "INVALID_ID" && + result != "INVALID_KEY"; +} + +/** + * `result` if it is one of the RESULT values of the SAM v3 protocol, otherwise "other". + */ +std::string_view KnownSamResult(std::string_view result) +{ + static constexpr std::array known{ + "OK", "CANT_REACH_PEER", "DUPLICATED_ID", "DUPLICATED_DEST", "I2P_ERROR", + "INVALID_ID", "INVALID_KEY", "KEY_NOT_FOUND", "PEER_NOT_FOUND", "TIMEOUT", + "NOVERSION", "LEASESET_NOT_FOUND", "ALREADY_ACCEPTING"}; + return std::ranges::find(known, result) != known.end() ? result : "other"; +} + +/** + * Router-supplied text, made safe and short enough for the log. + */ +std::string LoggableReplyValue(std::string_view value) +{ + return SanitizeString(value.substr(0, MAX_LOGGED_REPLY_VALUE)); +} + +/** + * The MESSAGE= value of a SAM reply, which may be quoted and contain spaces. It is router text, not + * safe for the log as is. + */ +std::string_view ReplyMessage(std::string_view reply) +{ + static constexpr std::string_view key{" MESSAGE="}; + const auto pos{reply.find(key)}; + if (pos == std::string_view::npos) { + return ""; + } + std::string_view value{reply.substr(pos + key.size())}; + if (value.starts_with('"')) { + value.remove_prefix(1); + return value.substr(0, value.find('"')); + } + return value.substr(0, value.find(' ')); +} + +/** + * The MESSAGE= value of a SAM reply, which may be quoted and contain spaces, ready for the log. + */ +std::string LoggableReplyMessage(std::string_view reply) +{ + return LoggableReplyValue(ReplyMessage(reply)); +} + +/** + * Whether the MESSAGE= value of a rejection names the encryption type, as Java I2P older than + * 2.10.0 does when asked for 6,4: "... Unsupported crypto type: 6". Only that is evidence that + * the router lacks 6,4; another rejection may be a passing router error. Matched before the + * value is sanitized or capped for the log, and case-insensitively for ASCII. + */ +bool NamesEncType(std::string_view message) +{ + static constexpr std::array phrases{"crypto type", "encryption type", "enc type", "enctype"}; + const std::string lower{ToLower(message)}; + return std::ranges::any_of(phrases, [&](std::string_view phrase) { return lower.find(phrase) != std::string::npos; }); +} + +/** + * `sock`, with every wait ending by `deadline` on the steady clock. `Sock::SendComplete()` and + * `Sock::RecvUntilTerminator()` measure their timeout on the mockable clock, which a wall clock + * step moves and `setmocktime` freezes. Called through this, they also give up once `deadline` + * passes, at their next wait. Only for those two. + */ +class SteadyDeadlineSock final : public Sock +{ +public: + SteadyDeadlineSock(const Sock& sock, SAMDeadline deadline, const std::string& request) + : Sock{INVALID_SOCKET}, m_sock{sock}, m_deadline{deadline}, m_request{request} {} + + ssize_t Send(const void* data, size_t len, int flags) const override { return m_sock.Send(data, len, flags); } + ssize_t Recv(void* buf, size_t len, int flags) const override { return m_sock.Recv(buf, len, flags); } + + bool Wait(std::chrono::milliseconds timeout, Event requested, Event* occurred = nullptr) const override + { + const auto now{std::chrono::steady_clock::now()}; + if (now >= m_deadline) { + throw std::runtime_error(strprintf("Timed out during \"%s\"", m_request)); + } + return m_sock.Wait(std::min(timeout, std::chrono::ceil(m_deadline - now)), requested, occurred); + } + + SteadyDeadlineSock& operator=(Sock&&) override + { + assert(false && "Move of Sock into SteadyDeadlineSock not allowed."); + return *this; + } + +private: + const Sock& m_sock; + const SAMDeadline m_deadline; + /** The request as logged. */ + const std::string& m_request; +}; +} // namespace + +void ResetRouterHybridStateForTest() +{ + LOCK(g_router_hybrid_mutex); + g_router_hybrid.clear(); + g_router_hybrid_updates = 0; +} + Session::Session(const fs::path& private_key_file, const Proxy& control_host, - CThreadInterrupt* interrupt) + CThreadInterrupt* interrupt, + std::chrono::steady_clock::duration create_timeout) : m_private_key_file{private_key_file}, m_control_host{control_host}, m_interrupt{interrupt}, + m_create_timeout{create_timeout}, m_transient{false} { } -Session::Session(const Proxy& control_host, CThreadInterrupt* interrupt) +Session::Session(const Proxy& control_host, + CThreadInterrupt* interrupt, + std::chrono::steady_clock::duration create_timeout) : m_control_host{control_host}, m_interrupt{interrupt}, + m_create_timeout{create_timeout}, m_transient{true} { } @@ -294,20 +518,39 @@ Session::Reply Session::SendRequestAndGetReply(const Sock& sock, const std::string& request, bool check_result_ok) const { - sock.SendComplete(request + "\n", MAX_WAIT_FOR_IO, *m_interrupt); + return SendRequestAndGetReply(sock, request, check_result_ok, SAMDeadline::max()); +} +Session::Reply Session::SendRequestAndGetReply(const Sock& sock, + const std::string& request, + bool check_result_ok, + SAMDeadline deadline) const +{ Reply reply; // Don't log the full "SESSION CREATE ..." because it contains our private key. reply.request = request.starts_with("SESSION CREATE") ? "SESSION CREATE ..." : request; + const auto time_left = [&]() -> std::chrono::milliseconds { + const auto now{std::chrono::steady_clock::now()}; + if (now >= deadline) { + throw std::runtime_error(strprintf("Timed out during \"%s\"", reply.request)); + } + return std::chrono::ceil(deadline - now); + }; + + // The timeouts below are on the mockable clock, `bounded` holds `deadline` on the steady one. + const SteadyDeadlineSock bounded{sock, deadline, reply.request}; + + bounded.SendComplete(request + "\n", std::min(MAX_WAIT_FOR_IO, time_left()), *m_interrupt); + // It could take a few minutes for the I2P router to reply as it is querying the I2P network // (when doing name lookup, for example). Notice: `RecvUntilTerminator()` is checking // `m_interrupt` more often, so we would not be stuck here for long if `m_interrupt` is // signaled. static constexpr auto recv_timeout = 3min; - reply.full = sock.RecvUntilTerminator('\n', recv_timeout, *m_interrupt, MAX_MSG_SIZE); + reply.full = bounded.RecvUntilTerminator('\n', std::min(recv_timeout, time_left()), *m_interrupt, MAX_MSG_SIZE); for (const auto& kv : Split(reply.full, ' ')) { const auto pos{std::ranges::find(kv, '=')}; @@ -320,7 +563,7 @@ Session::Reply Session::SendRequestAndGetReply(const Sock& sock, if (check_result_ok && reply.Get("RESULT") != "OK") { throw std::runtime_error( - strprintf("Unexpected reply to \"%s\": \"%s\"", request, reply.full)); + strprintf("Unexpected reply to \"%s\": \"%s\"", reply.request, reply.full)); } return reply; @@ -339,6 +582,29 @@ std::unique_ptr Session::Hello() const return sock; } +std::unique_ptr Session::Hello(SAMDeadline deadline) const +{ + // Like Proxy::Connect() without a deadline, give up connecting after -timeout. + const SAMDeadline connect_deadline{ + std::min(deadline, std::chrono::steady_clock::now() + std::chrono::milliseconds{nConnectTimeout})}; + + auto sock = m_control_host.Connect(connect_deadline, *m_interrupt); + + if (!sock) { + if (*m_interrupt) { + throw std::runtime_error(strprintf("Interrupted while connecting to %s", m_control_host.ToString())); + } + if (std::chrono::steady_clock::now() >= deadline) { + throw std::runtime_error(strprintf("Timed out connecting to %s", m_control_host.ToString())); + } + throw std::runtime_error(strprintf("Cannot connect to %s", m_control_host.ToString())); + } + + SendRequestAndGetReply(*sock, "HELLO VERSION MIN=3.1 MAX=3.1", /*check_result_ok=*/true, deadline); + + return sock; +} + void Session::CheckControlSock() { LOCK(m_mutex); @@ -350,20 +616,20 @@ void Session::CheckControlSock() } } -void Session::DestGenerate(const Sock& sock) +void Session::DestGenerate(const Sock& sock, SAMDeadline deadline) { // https://geti2p.net/spec/common-structures#key-certificates // "7" or "EdDSA_SHA512_Ed25519" - "Recent Router Identities and Destinations". // Use "7" because i2pd <2.24.0 does not recognize the textual form. // If SIGNATURE_TYPE is not specified, then the default one is DSA_SHA1. - const Reply& reply = SendRequestAndGetReply(sock, "DEST GENERATE SIGNATURE_TYPE=7", false); + const Reply& reply = SendRequestAndGetReply(sock, "DEST GENERATE SIGNATURE_TYPE=7", false, deadline); m_private_key = DecodeI2PBase64(reply.Get("PRIV")); } -void Session::GenerateAndSavePrivateKey(const Sock& sock) +void Session::GenerateAndSavePrivateKey(const Sock& sock, SAMDeadline deadline) { - DestGenerate(sock); + DestGenerate(sock, deadline); // umask is set to 0077 in common/system.cpp, which is ok. if (!WriteBinaryFile(m_private_key_file, @@ -413,44 +679,141 @@ void Session::CreateIfNotCreatedAlready() } const auto session_type = m_transient ? "transient" : "persistent"; - const auto session_id = GetRandHash().GetHex().substr(0, 10); // full is overkill, too verbose in the logs + const SAMDeadline deadline{std::chrono::steady_clock::now() + m_create_timeout}; + const std::string router{m_control_host.ToString()}; + + bool hybrid{!RouterLacksHybrid(router)}; + if (!hybrid) { + LogPrintLevel(BCLog::I2P, BCLog::Level::Debug, "%s rejected leaseSetEncType=%s in earlier sessions, using %s\n", + router, LEASESET_ENC_TYPES_HYBRID, LEASESET_ENC_TYPES_LEGACY); + } + // The rejection of 6,4, if this is the retry with 4,0. + struct HybridRejection { + std::string result; + /** MESSAGE=, ready for the log. */ + std::string message; + /** Whether MESSAGE= names the encryption type, see `NamesEncType()`. */ + bool names_enc_type; + }; + std::optional hybrid_rejection; + + // Our persistent destination (private key), read or generated once and kept for a retry. + std::optional private_key_b64; + + for (unsigned attempt{1};; ++attempt) { + const auto session_id = GetRandHash().GetHex().substr(0, 10); // full is overkill, too verbose in the logs + const std::string_view enc_types{hybrid ? LEASESET_ENC_TYPES_HYBRID : LEASESET_ENC_TYPES_LEGACY}; + + LogPrintLevel(BCLog::I2P, BCLog::Level::Debug, "Creating %s SAM session %s with %s\n", session_type, session_id, router); + + auto sock = Hello(deadline); + + std::string request; + if (m_transient) { + // The destination (private key) is generated upon session creation and returned + // in the reply in DESTINATION=. + request = strprintf("SESSION CREATE STYLE=STREAM ID=%s DESTINATION=TRANSIENT SIGNATURE_TYPE=7 " + "i2cp.leaseSetEncType=%s inbound.quantity=1 outbound.quantity=1", + session_id, + enc_types); + } else { + if (!private_key_b64) { + // Read our persistent destination (private key) from disk or generate + // one and save it to disk. Then use it when creating the session. + const auto& [read_ok, data] = ReadBinaryFile(m_private_key_file); + if (read_ok) { + m_private_key.assign(data.begin(), data.end()); + } else { + GenerateAndSavePrivateKey(*sock, deadline); + } + private_key_b64 = SwapBase64(EncodeBase64(m_private_key)); + } + request = strprintf("SESSION CREATE STYLE=STREAM ID=%s DESTINATION=%s " + "i2cp.leaseSetEncType=%s inbound.quantity=3 outbound.quantity=3", + session_id, + *private_key_b64, + enc_types); + } - LogPrintLevel(BCLog::I2P, BCLog::Level::Debug, "Creating %s SAM session %s with %s\n", session_type, session_id, m_control_host.ToString()); + const Reply reply = SendRequestAndGetReply(*sock, request, /*check_result_ok=*/false, deadline); - auto sock = Hello(); + // A reply without RESULT= is not a rejection, this throws. + const std::string result{reply.Get("RESULT")}; - if (m_transient) { - // The destination (private key) is generated upon session creation and returned - // in the reply in DESTINATION=. - const Reply& reply = SendRequestAndGetReply( - *sock, - strprintf("SESSION CREATE STYLE=STREAM ID=%s DESTINATION=TRANSIENT SIGNATURE_TYPE=7 " - "i2cp.leaseSetEncType=4,0 inbound.quantity=1 outbound.quantity=1", - session_id)); + if (result == "OK") { + if (m_transient) { + m_private_key = DecodeI2PBase64(reply.Get("DESTINATION")); + } + const CService my_addr{DestBinToAddr(MyDestination()), I2P_SAM31_PORT}; - m_private_key = DecodeI2PBase64(reply.Get("DESTINATION")); - } else { - // Read our persistent destination (private key) from disk or generate - // one and save it to disk. Then use it when creating the session. - const auto& [read_ok, data] = ReadBinaryFile(m_private_key_file); - if (read_ok) { - m_private_key.assign(data.begin(), data.end()); - } else { - GenerateAndSavePrivateKey(*sock); + m_my_addr = my_addr; + m_session_id = session_id; + m_control_sock = std::move(sock); + break; } - const std::string& private_key_b64 = SwapBase64(EncodeBase64(m_private_key)); + // qbit-only, upstream Bitcoin Core does not retry: Java I2P older than 2.10.0 fails the + // session if asked for the hybrid type, so retry once without it. Any explicit rejection, + // a well-formed "SESSION STATUS" whose RESULT may be about the encryption type (including + // RESULT=TIMEOUT), is retried; whether it counts against the router is decided below. No + // reply (a timeout waiting for one, EOF, a socket error) or a malformed reply never is. + const bool retry{hybrid && attempt < SAM_CREATE_MAX_ATTEMPTS && + reply.full.starts_with("SESSION STATUS ") && !result.empty() && + MayBeEncTypeRejection(result)}; + if (!retry) { + throw std::runtime_error(strprintf("Unexpected reply to \"%s\": \"%s\"", reply.request, reply.full)); + } - SendRequestAndGetReply(*sock, - strprintf("SESSION CREATE STYLE=STREAM ID=%s DESTINATION=%s " - "i2cp.leaseSetEncType=4,0 inbound.quantity=3 outbound.quantity=3", - session_id, - private_key_b64)); + hybrid_rejection = HybridRejection{.result = result, + .message = LoggableReplyMessage(reply.full), + .names_enc_type = NamesEncType(ReplyMessage(reply.full))}; + + LogPrintLevel(BCLog::I2P, BCLog::Level::Debug, + "%s rejected SAM session %s with leaseSetEncType=%s: RESULT=%s MESSAGE=\"%s\", retrying with %s\n", + router, + session_id, + LEASESET_ENC_TYPES_HYBRID, + LoggableReplyValue(result), + hybrid_rejection->message, + LEASESET_ENC_TYPES_LEGACY); + + hybrid = false; + // `sock` is closed here, the retry uses a fresh connection, HELLO and session id. } - m_my_addr = CService(DestBinToAddr(MyDestination()), I2P_SAM31_PORT); - m_session_id = session_id; - m_control_sock = std::move(sock); + if (hybrid_rejection && hybrid_rejection->names_enc_type) { + // Only such rejections in a row turn 6,4 off for the router: one may be a transient error. + const auto noted{NoteHybridFallback(router)}; + if (noted.warn) { + LogWarning("I2P: the router rejected leaseSetEncType=6,4 (RESULT=%s) and accepted 4,0, so this session has no post-quantum leaseset. Java I2P older than 2.10.0 does this; upgrade the router to get it.\n", + KnownSamResult(hybrid_rejection->result)); + } + if (noted.remembered) { + LogPrintLevel(BCLog::I2P, BCLog::Level::Info, + "%s rejected leaseSetEncType=%s and accepted %s in %u sessions in a row, later sessions through it use %s until qbit restarts\n", + router, + LEASESET_ENC_TYPES_HYBRID, + LEASESET_ENC_TYPES_LEGACY, + SAM_HYBRID_FALLBACKS_TO_REMEMBER, + LEASESET_ENC_TYPES_LEGACY); + } + } else if (hybrid_rejection) { + // A rejection that does not name the encryption type may be a passing router error + // (e.g. failing to build tunnels): it is no evidence either way, so the count of + // rejections in a row neither grows nor starts anew, and there is no warning. + LogPrintLevel(BCLog::I2P, BCLog::Level::Info, + "%s SAM session %s uses leaseSetEncType=%s because %s rejected %s with RESULT=%s MESSAGE=\"%s\", which does not name the encryption type; the next session asks for %s again\n", + Capitalize(session_type), + m_session_id, + LEASESET_ENC_TYPES_LEGACY, + router, + LEASESET_ENC_TYPES_HYBRID, + KnownSamResult(hybrid_rejection->result), + hybrid_rejection->message, + LEASESET_ENC_TYPES_HYBRID); + } else if (hybrid) { + NoteHybridAccepted(router); + } LogPrintLevel(BCLog::I2P, BCLog::Level::Info, "%s SAM session %s created, my address=%s\n", Capitalize(session_type), diff --git a/src/i2p.h b/src/i2p.h index d7b2f84d6e..62f2b4f9d3 100644 --- a/src/i2p.h +++ b/src/i2p.h @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -50,6 +51,43 @@ namespace sam { */ static constexpr size_t MAX_MSG_SIZE{65536}; +/** + * Point in time by which a multi-step exchange with the SAM proxy must complete. + */ +using SAMDeadline = std::chrono::steady_clock::time_point; + +/** + * Bound on creating a SAM session. It covers connecting to the SAM proxy, "HELLO", the + * optional "DEST GENERATE" and every "SESSION CREATE" attempt together. + */ +static constexpr auto SAM_CREATE_TIMEOUT = std::chrono::seconds{180}; + +/** + * Most "SESSION CREATE" attempts when creating one session: the first one with + * i2cp.leaseSetEncType=6,4 and, if the router explicitly rejects it, one with 4,0. + */ +static constexpr unsigned SAM_CREATE_MAX_ATTEMPTS = 2; + +/** + * Sessions in a row in which a router rejects i2cp.leaseSetEncType=6,4 naming the encryption type + * and accepts 4,0, after which later sessions through it start with 4,0 until qbit restarts. A + * session created with 6,4 starts the count anew, so one transient rejection does not turn 6,4 + * off. A session whose rejection does not name the encryption type neither counts nor starts the + * count anew. + */ +static constexpr unsigned SAM_HYBRID_FALLBACKS_TO_REMEMBER = 2; + +/** + * Most SAM routers whose rejections of i2cp.leaseSetEncType=6,4 are remembered. Beyond that, + * the least recently updated router is forgotten. + */ +static constexpr size_t SAM_MAX_REMEMBERED_ROUTERS = 64; + +/** + * Forget the rejections of i2cp.leaseSetEncType=6,4 remembered for all routers. Only for tests. + */ +void ResetRouterHybridStateForTest(); + /** * I2P SAM session. */ @@ -66,10 +104,12 @@ class Session * possible and executing methods throw an exception. Notice: only a pointer to the * `CThreadInterrupt` object is saved, so it must not be destroyed earlier than this * `Session` object. + * @param[in] create_timeout Bound on creating the SAM session, see `SAM_CREATE_TIMEOUT`. */ Session(const fs::path& private_key_file, const Proxy& control_host, - CThreadInterrupt* interrupt); + CThreadInterrupt* interrupt, + std::chrono::steady_clock::duration create_timeout = SAM_CREATE_TIMEOUT); /** * Construct a transient session which will generate its own I2P private key @@ -81,8 +121,11 @@ class Session * possible and executing methods throw an exception. Notice: only a pointer to the * `CThreadInterrupt` object is saved, so it must not be destroyed earlier than this * `Session` object. + * @param[in] create_timeout Bound on creating the SAM session, see `SAM_CREATE_TIMEOUT`. */ - Session(const Proxy& control_host, CThreadInterrupt* interrupt); + Session(const Proxy& control_host, + CThreadInterrupt* interrupt, + std::chrono::steady_clock::duration create_timeout = SAM_CREATE_TIMEOUT); /** * Destroy the session, closing the internally used sockets. The sockets that have been @@ -168,6 +211,21 @@ class Session const std::string& request, bool check_result_ok = true) const; + /** + * Send request and get a reply from the SAM proxy, giving up at `deadline`. + * Like the overload above, except that sending and receiving also end at `deadline`. + * @param[in] sock A socket that is connected to the SAM proxy. + * @param[in] request Raw request to send, a newline terminator is appended to it. + * @param[in] check_result_ok If true then after receiving the reply a check is made + * whether it contains "RESULT=OK" and an exception is thrown if it does not. + * @param[in] deadline Give up at this time. + * @throws std::runtime_error if an error occurs, `deadline` passes or `m_interrupt` is signaled + */ + Reply SendRequestAndGetReply(const Sock& sock, + const std::string& request, + bool check_result_ok, + SAMDeadline deadline) const; + /** * Open a new connection to the SAM proxy. * @return a connected socket @@ -175,6 +233,15 @@ class Session */ std::unique_ptr Hello() const EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + /** + * Open a new connection to the SAM proxy, giving up at `deadline` or when `m_interrupt` is + * signaled. Connecting is also limited by `nConnectTimeout`, whichever ends first. + * @param[in] deadline Give up at this time. + * @return a connected socket + * @throws std::runtime_error if an error occurs + */ + std::unique_ptr Hello(SAMDeadline deadline) const EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + /** * Check the control socket for errors and possibly disconnect. */ @@ -183,17 +250,19 @@ class Session /** * Generate a new destination with the SAM proxy and set `m_private_key` to it. * @param[in] sock Socket to use for talking to the SAM proxy. + * @param[in] deadline Give up at this time. * @throws std::runtime_error if an error occurs */ - void DestGenerate(const Sock& sock) EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + void DestGenerate(const Sock& sock, SAMDeadline deadline) EXCLUSIVE_LOCKS_REQUIRED(m_mutex); /** * Generate a new destination with the SAM proxy, set `m_private_key` to it and save * it on disk to `m_private_key_file`. * @param[in] sock Socket to use for talking to the SAM proxy. + * @param[in] deadline Give up at this time. * @throws std::runtime_error if an error occurs */ - void GenerateAndSavePrivateKey(const Sock& sock) EXCLUSIVE_LOCKS_REQUIRED(m_mutex); + void GenerateAndSavePrivateKey(const Sock& sock, SAMDeadline deadline) EXCLUSIVE_LOCKS_REQUIRED(m_mutex); /** * Derive own destination from `m_private_key`. @@ -204,7 +273,9 @@ class Session /** * Create the session if not already created. Reads the private key file and connects to the - * SAM proxy. + * SAM proxy. Asks for i2cp.leaseSetEncType=6,4 unless this router is known to reject it, and + * retries once with 4,0 if the router explicitly rejects it. All of it must complete within + * `m_create_timeout`. `m_session_id` and `m_control_sock` are set only on success. * @throws std::runtime_error if an error occurs */ void CreateIfNotCreatedAlready() EXCLUSIVE_LOCKS_REQUIRED(m_mutex); @@ -237,6 +308,11 @@ class Session */ CThreadInterrupt* const m_interrupt; + /** + * Bound on creating the SAM session, see `SAM_CREATE_TIMEOUT`. + */ + const std::chrono::steady_clock::duration m_create_timeout; + /** * Mutex protecting the members that can be concurrently accessed. */ diff --git a/src/init.cpp b/src/init.cpp index 297a4f2824..4238628ece 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -565,6 +566,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-i2pacceptincoming", strprintf("Whether to accept inbound I2P connections (default: %i). Ignored if -i2psam is not set. Listening for inbound I2P connections is done through the SAM proxy, not by binding to a local address and port.", DEFAULT_I2P_ACCEPT_INCOMING), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION); argsman.AddArg("-onlynet=", "Make automatic outbound connections only to network (" + Join(GetNetworkNames(), ", ") + "). Inbound and manual connections are not affected by this option. It can be specified multiple times to allow multiple networks.", ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION); argsman.AddArg("-v2transport", strprintf("Support v2 transport (default: %u)", DEFAULT_V2_TRANSPORT), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION); + argsman.AddArg("-v2pqtransport", strprintf("Support hybrid post-quantum key exchange (ML-KEM-1024) in v2 transport. Has no effect with -v2transport=0. A non-numeric value, or a double negative such as -nov2pqtransport=0, warns at startup and states how it was read (default: %u)", DEFAULT_V2_PQ_TRANSPORT), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION); argsman.AddArg("-peerbloomfilters", strprintf("Support filtering of blocks and transaction with bloom filters (default: %u)", DEFAULT_PEERBLOOMFILTERS), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION); argsman.AddArg("-peerblockfilters", strprintf("Serve compact block filters to peers per BIP 157 (default: %u)", DEFAULT_PEERBLOCKFILTERS), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION); argsman.AddArg("-txreconciliation", strprintf("Enable transaction reconciliations per BIP 330 (default: %d)", DEFAULT_TXRECONCILIATION_ENABLE), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CONNECTION); @@ -649,6 +651,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-limitdescendantsize=", strprintf("Do not accept transactions if any ancestor would have more than kilobytes of in-mempool descendants (default: %u).", DEFAULT_DESCENDANT_SIZE_LIMIT_KVB), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::DEBUG_TEST); argsman.AddArg("-test=