diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8855907..10b56eb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,9 +180,6 @@ jobs: # for 24.04, and a runner that silently rolled to the next LTS would take that pin # with it. `runs-on` cannot read `env`, so this is a literal. runs-on: ubuntu-24.04 - # Bound a hung toolchain install or test run. The full R/Julia suite normally - # completes well inside this budget; the GitHub default is six hours. - timeout-minutes: 60 # The repository's root .Rprofile sources renv/activate.R, so R started from # the checkout auto-activates renv and rewrites the library paths. renv is a # local-development convenience; CI installs into the system library and @@ -420,7 +417,7 @@ jobs: shell: bash run: | set +e - julia --project=. -t2 --compiled-modules=no test/runtests.jl numeric_policy exact_summaries high_precision derived_analyses validated_statistics 2>&1 | tee /tmp/validated-statistics-tests.log + julia --project=. -t2 --compiled-modules=no test/runtests.jl validated_statistics numeric_policy exact_summaries high_precision derived_analyses 2>&1 | tee /tmp/validated-statistics-tests.log # Preserve both exits immediately: a successful Julia run must not # hide a failed log write (e.g. a full runner filesystem). statuses=("${PIPESTATUS[@]}") diff --git a/.github/workflows/doi.yml b/.github/workflows/doi.yml index 6b8b95a3..f250305e 100644 --- a/.github/workflows/doi.yml +++ b/.github/workflows/doi.yml @@ -42,7 +42,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | relevant=false - patterns=('src/core/doi.jl' 'docs/doi-publication.md' 'scripts/link-doi.sh' '.github/workflows/doi.yml') + patterns=('src/core/doi.jl' 'src/server/routes/doi.jl' 'test/unit/test_doi.jl' 'CITATION.cff' 'docs/doi-publication.md' 'scripts/link-doi.sh' '.github/workflows/doi.yml') changed="$RUNNER_TEMP/doi-changed-files" if ! git diff --no-renames --name-only -z "$BASE_SHA...HEAD" > "$changed"; then echo "ERROR: git diff $BASE_SHA...HEAD failed" >&2 @@ -67,21 +67,38 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Shell syntax check run: bash -n scripts/link-doi.sh - - name: YAML parse - run: python3 -c 'import yaml,sys; yaml.safe_load(open("docs/doi-publication.md"))' 2>/dev/null || true + - name: Citation metadata check + run: | + python3 - <<'PY' + from pathlib import Path + import sys + + cff = Path("CITATION.cff").read_text(encoding="utf-8") + required_prefixes = ("cff-version:", "title:", "authors:", "license:") + missing = [p for p in required_prefixes if not any(line.startswith(p) for line in cff.splitlines())] + if missing: + sys.exit(f"CITATION.cff missing required top-level keys: {', '.join(missing)}") + PY - name: Receipt schema shape run: | python3 - <<'PY' - import json, sys - # A valid receipt carries these keys; this is a shape check only. - keys = {"schema","study","run","origin","deposition_id","doi","published", - "files","created_at","published_at","checksum"} - print("receipt schema shape documented in src/core/doi.jl") + from pathlib import Path + import sys + + source = Path("src/core/doi.jl").read_text(encoding="utf-8") + keys = ("schema", "study", "run", "origin", "deposition_id", "doi", + "published", "files", "created_at", "published_at", "checksum") + missing = [k for k in keys if f'"{k}"' not in source and f":{k}" not in source] + if missing: + sys.exit(f"src/core/doi.jl missing receipt schema keys: {', '.join(missing)}") + print("verified DOI receipt schema keys in src/core/doi.jl") PY # Julia lifecycle and real-HTTP checks require a provisioned machine with the diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index ce1213c2..3d21dca0 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -60,9 +60,9 @@ jobs: contents: read pull-requests: read with: - # No K9 contracts are committed yet: report grey, never pretend that - # DEED syntax validation establishes K9 semantic conformance. - require-files: false + # Require the committed .machine_readable/contractiles/ K9 corpus across + # L0–L2; an accidentally emptied corpus or orange state fails closed. + require-files: true fail-on-indeterminate: true hypatia: @@ -164,7 +164,7 @@ jobs: badges: name: Publish badges - needs: [deed, hypatia] + needs: [deed, k9, hypatia] if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 5 @@ -172,6 +172,13 @@ jobs: contents: write actions: read steps: + - name: Download the K9 conformance grid + continue-on-error: true + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: k9-grid + path: k9-grid + - name: Download the verdict grid continue-on-error: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -183,6 +190,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEED_RESULT: ${{ needs.deed.result }} + K9_RESULT: ${{ needs.k9.result }} run: | set -euo pipefail # shields.io endpoint JSON, one file per lane, on an orphan branch @@ -195,6 +203,14 @@ jobs: *) msg=indeterminate; colour=orange ;; esac jq -n --arg m "$msg" --arg c "$colour" '{schemaVersion: 1, label: "DEED syntax", message: $m, color: $c}' > out/deed.json + if [ "$K9_RESULT" = "success" ] && [ -s k9-grid/badge.json ]; then + cp k9-grid/badge.json out/k9-grid.json + elif [ "$K9_RESULT" = "failure" ]; then + jq -n '{schemaVersion: 1, label: "K9 contracts", message: "failing", color: "red"}' > out/k9-grid.json + else + jq -n '{schemaVersion: 1, label: "K9 contracts", message: "indeterminate", color: "orange"}' > out/k9-grid.json + fi + [ -s k9-grid/report.json ] && cp k9-grid/report.json out/k9-grid.report.json if [ -s verdict-grid/badge.json ]; then cp verdict-grid/badge.json out/hypatia-grid.json else diff --git a/.gitignore b/.gitignore index bb8f8512..dcc294bf 100644 --- a/.gitignore +++ b/.gitignore @@ -342,6 +342,8 @@ proofs/agda/_build/ # Occupancy proof dependencies and reproduction logs /_build/ -# Fork governance: the gitbot-fleet enrolment manifest and report-backs are tracked. +# Fork governance: the gitbot-fleet enrolment manifest, report-backs, and K9 contractiles are tracked. !.gitbot-fleet/ !.gitbot-fleet/** +!.machine_readable/ +!.machine_readable/** diff --git a/.machine_readable/contractiles/intend/intend.k9.ncl b/.machine_readable/contractiles/intend/intend.k9.ncl new file mode 100644 index 00000000..017b3779 --- /dev/null +++ b/.machine_readable/contractiles/intend/intend.k9.ncl @@ -0,0 +1,69 @@ +K9! +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# intend.k9.ncl — Architectural commitments and north-star intent declarations +# for the hyperpolymath/MetaManifold-WebUI fork. Evaluated at 'Yard (reporting +# authority; non-gating drift reference). +{ + pedigree = { + schema_version = "1.0.0", + component_type = "contractile:intend", + contractile_verb = "intend", + semantics = "commitment-and-aspiration", + tier = 'Yard, + authority = 'reporting, + security = { + leash = 'Yard, + signature_required = false, + trust_level = "contract-eval-only", + allow_network = false, + allow_filesystem_write = false, + allow_subprocess = false, + capabilities = [], + }, + metadata = { + name = "metamanifold-webui-intend", + version = "1.0.0", + description = "North-star commitments for exact ecological statistics, machine-checked Agda proofs, epistemic cladistic overlays, and clean upstream-candidate separation.", + author = "Jonathan D.A. Jewell ", + }, + warnings = [ + "Evaluated at Yard with reporting authority; records architectural intent without executing host commands.", + ], + side_effects = [], + }, + + config = { + commitments = [ + { + id = "exact-count-aggregation", + area = "src/analysis/exact_summaries.jl", + status = "verified", + summary = "Aggregate amplicon counts with arbitrary-precision integers and rationals before controlled high-precision projection.", + }, + { + id = "constructive-formal-proofs", + area = "proofs/agda/", + status = "verified", + summary = "Maintain machine-checked Agda proofs for Benjamini-Hochberg adjustment and TSS/RLE scaling invariants.", + }, + { + id = "warranted-cladistic-overlays", + area = "src/analysis/clade_cumulus.jl", + status = "verified", + summary = "Keep CladeCumulus topology proposals support-warranted, immutable as sidecars, and invariant in tip-level Faith PD across reaggregation.", + }, + { + id = "honest-fleet-evidence", + area = ".gitbot-fleet/enrolment.json", + status = "in_progress", + summary = "Surface genuine Hypatia, panic-attack, pons-asinorum, K9, and DEED verdicts while keeping unenrolled or unexecuted bot slots explicitly marked rather than fabricated.", + }, + ], + }, + + validation = { + commitment_count_positive = std.array.length config.commitments >= 4, + }, +} diff --git a/.machine_readable/contractiles/must/must.k9.ncl b/.machine_readable/contractiles/must/must.k9.ncl new file mode 100644 index 00000000..5aa9e87c --- /dev/null +++ b/.machine_readable/contractiles/must/must.k9.ncl @@ -0,0 +1,81 @@ +K9! +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# must.k9.ncl — Release-blocking invariant declarations for the +# hyperpolymath/MetaManifold-WebUI fork. Evaluated at 'Yard (contract-eval-only); +# verification commands run in dedicated CI jobs rather than inside this file. +{ + pedigree = { + schema_version = "1.0.0", + component_type = "contractile:must", + contractile_verb = "must", + semantics = "invariant-assertion", + tier = 'Yard, + authority = 'blocking, + security = { + leash = 'Yard, + signature_required = false, + trust_level = "contract-eval-only", + allow_network = false, + allow_filesystem_write = false, + allow_subprocess = false, + capabilities = [], + }, + metadata = { + name = "metamanifold-webui-must", + version = "1.0.0", + description = "Release-blocking fork invariants for DEED/K9 governance, proof soundness, high-precision oracles, and upstream AGPL-3.0 preservation.", + author = "Jonathan D.A. Jewell ", + }, + warnings = [ + "Evaluated at Yard as typed configuration only; invariant scripts execute in pinned GitHub Actions steps.", + ], + side_effects = [], + }, + + config = { + repository = "hyperpolymath/MetaManifold-WebUI", + upstream_repository = "JoshuaJewell/MetaManifold-WebUI", + upstream_license = "AGPL-3.0-only", + fork_addition_license = "MPL-2.0", + invariants = [ + { + id = "repo-deed-present", + target = "MetaManifold-WebUI_chora.deed", + check_command = "deed_lint.js MetaManifold-WebUI_chora.deed", + rationale = "Repository deed must remain syntactically and structurally valid.", + }, + { + id = "governance-advisory-sarif-boundary", + target = "scripts/check-governance-contract.sh", + check_command = "bash scripts/check-governance-contract.sh", + rationale = "Pull-request Hypatia scans must publish verdict grids without creating blocking SARIF upload gates, and standards workflow pins must match.", + }, + { + id = "agda-axiom-audit", + target = "proofs/tests/axiom-audit.sh", + check_command = "bash proofs/tests/axiom-audit.sh", + rationale = "Agda proofs must retain --safe --without-K with zero postulates or escape pragmas.", + }, + { + id = "high-precision-reference-oracle", + target = "test/references/check_high_precision_references.py", + check_command = "python3 test/references/check_high_precision_references.py --check test/unit/test_high_precision.jl", + rationale = "Independent arbitrary-precision reference constants must match the Julia test suite.", + }, + { + id = "upstream-license-untouched", + target = "LICENSE", + check_command = "test -s LICENSE && test -s CITATION.cff", + rationale = "Upstream AGPL-3.0-only LICENSE, CITATION.cff, and existing copyright headers are never rewritten on this fork.", + }, + ], + }, + + validation = { + has_upstream_license = config.upstream_license == "AGPL-3.0-only", + has_fork_license = config.fork_addition_license == "MPL-2.0", + invariant_count_positive = std.array.length config.invariants >= 5, + }, +} diff --git a/.machine_readable/contractiles/trust/trust.k9.ncl b/.machine_readable/contractiles/trust/trust.k9.ncl new file mode 100644 index 00000000..6fe5b1d6 --- /dev/null +++ b/.machine_readable/contractiles/trust/trust.k9.ncl @@ -0,0 +1,68 @@ +K9! +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# trust.k9.ncl — Security and data-integrity boundary declarations for the +# hyperpolymath/MetaManifold-WebUI fork. Evaluated at 'Yard (contract-eval-only). +{ + pedigree = { + schema_version = "1.0.0", + component_type = "contractile:trust", + contractile_verb = "trust", + semantics = "boundary-verification", + tier = 'Yard, + authority = 'blocking, + security = { + leash = 'Yard, + signature_required = false, + trust_level = "contract-eval-only", + allow_network = false, + allow_filesystem_write = false, + allow_subprocess = false, + capabilities = [], + }, + metadata = { + name = "metamanifold-webui-trust", + version = "1.0.0", + description = "Trust-boundary declarations covering strict I-JSON/JCS serialisation, HTTP JSON input validation, read-only DuckDB analysis access, and SHA-pinned workflows.", + author = "Jonathan D.A. Jewell ", + }, + warnings = [ + "Evaluated at Yard as typed configuration only; boundary tests run inside the CI workflow.", + ], + side_effects = [], + }, + + config = { + max_statistics_request_bytes = 16777216, + max_ieee754_safe_integer = 9007199254740991, + boundaries = [ + { + id = "strict-ijson-jcs-wire-boundary", + surface = "src/core/canonical.jl", + rule = "Validated statistics and canonical responses enforce RFC 7493 I-JSON input parsing and RFC 8785 JCS deterministic output.", + }, + { + id = "http-json-body-validation", + surface = "src/server/server.jl", + rule = "HTTP JSON route handlers reject malformed or non-object JSON payloads with HTTP 400 rather than throwing unhandled 500 exceptions, without mutating request byte buffers.", + }, + { + id = "read-only-duckdb-analysis", + surface = "src/server/routes/duckdb_helpers.jl", + rule = "Analysis and validated-statistics routes open run DuckDB files in read-only mode and stream exact integer counts into BigInt accumulators.", + }, + { + id = "immutable-ci-and-tool-pins", + surface = "test/unit/test_install_pins.jl", + rule = "Workflow actions and bioinformatics tool versions remain pinned and verified by unit tests.", + }, + ], + }, + + validation = { + request_limit_positive = config.max_statistics_request_bytes > 0, + safe_integer_bound = config.max_ieee754_safe_integer == 9007199254740991, + boundary_count_positive = std.array.length config.boundaries >= 4, + }, +} diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index 10bfb6f9..00ae081e 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -29,4 +29,5 @@ (history-scan ".github/workflows/secret-scanner.yml") (security-practices ".github/workflows/scorecard.yml") (fleet-enrolment ".gitbot-fleet/enrolment.json") - (scope "DEED syntax is not K9 semantic conformance. No K9 contracts are committed; the K9 corpus is grey, not a pass."))) + (contractiles ".machine_readable/contractiles/") + (scope "DEED syntax and K9 semantic conformance are validated separately: Yard-tier K9 contracts (must, trust, intend) are checked across L0-L2; L3 signatures remain outside CI."))) diff --git a/MetaManifold-WebUI_chora.deed b/MetaManifold-WebUI_chora.deed index 2f6653eb..06537522 100644 --- a/MetaManifold-WebUI_chora.deed +++ b/MetaManifold-WebUI_chora.deed @@ -17,10 +17,11 @@ :analysis "src/analysis/" :cladistics "docs/cladistics/" :proofs "proofs/" + :contractiles ".machine_readable/contractiles/" :enforcement ".github/workflows/") (status :evidence-source ".github/workflows/governance.yml" - :evidence-status "Fork governance lane only: DEED syntax, the K9 conformance grid (no contracts yet; grey corpus), and the Hypatia verdict grid with gitbot-fleet report-backs. Upstream owns the code and its CI; this deed describes the fork.") + :evidence-status "Fork governance lane: DEED syntax, K9 L0-L2 contract conformance (must, trust, intend at Yard tier; L3 signatures not verified in CI), and the Hypatia verdict grid with gitbot-fleet report-backs. Upstream owns the application code and its CI; this deed describes the fork.") (policies (licensing :initial-assignment "New files the fork authors carry MPL-2.0 with an SPDX header, as upstream CONTRIBUTING.md permits." diff --git a/README.md b/README.md index 82f8abf7..9850e50b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Fork governance (hyperpolymath/MetaManifold-WebUI only; nothing here is part of upstream): [![Governance (fork)](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/workflows/governance.yml/badge.svg)](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/workflows/governance.yml) [![DEED syntax](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fhyperpolymath%2FMetaManifold-WebUI%2Fgovernance-badges%2Fdeed.json)](MetaManifold-WebUI_chora.deed) +[![K9 contracts](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fhyperpolymath%2FMetaManifold-WebUI%2Fgovernance-badges%2Fk9-grid.json)](.machine_readable/contractiles/) [![Hypatia grid](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fhyperpolymath%2FMetaManifold-WebUI%2Fgovernance-badges%2Fhypatia-grid.json)](https://github.com/hyperpolymath/standards/blob/main/docs/VERDICT-GRID-SPEC.adoc) The grid grades the worst finding each reporter has (purple critical, red high, yellow medium, blue minor, green clean), with a scanner that did not run in orange and a fleet member not enrolled for this repository in grey; enrolment is in `.gitbot-fleet/enrolment.json`. diff --git a/SECURITY.md b/SECURITY.md index 22ba5bd2..4419f9a1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -40,4 +40,5 @@ PRs still require review and passing tests. A green workflow is not a claim that all security findings are resolved. Missing tools, inaccessible GitHub alert APIs, absent fleet report-backs and -an empty K9 corpus must remain visible as missing or inapplicable evidence. +unverified K9 L3 signatures must remain visible as missing or inapplicable +evidence. diff --git a/bench/advanced_zero_handling/benchmark.jl b/bench/advanced_zero_handling/benchmark.jl index ebeb206d..e757c452 100644 --- a/bench/advanced_zero_handling/benchmark.jl +++ b/bench/advanced_zero_handling/benchmark.jl @@ -92,7 +92,11 @@ function main() isnothing(output) ? println(rendered) : write(output, rendered * "\n") if !isnothing(baseline_path) - baseline = JSON3.read(read(baseline_path, String)) + baseline = try + JSON3.read(read(baseline_path, String)) + catch e + error("Failed to read benchmark baseline JSON from '$baseline_path': $(sprint(showerror, e))") + end regressions = String[] for old in baseline.results old.operation in ("replacement/bayesian_multiplicative", "dispersion/ml") || continue diff --git a/frontend/src/components/PublicationTablesPanel.tsx b/frontend/src/components/PublicationTablesPanel.tsx index 48efdbc1..b9fed1fe 100644 --- a/frontend/src/components/PublicationTablesPanel.tsx +++ b/frontend/src/components/PublicationTablesPanel.tsx @@ -197,6 +197,7 @@ export function PublicationTablesPanel({ study, runs }: { [request, heatmap, hideZeros]) // Heatmap and zero-hiding only restyle the preview, so rebuild it in place. + // Only presentation changes trigger this; data changes clear the preview instead. const hasTable = built !== null useEffect(() => { if (!hasTable || !presented) return @@ -205,8 +206,6 @@ export function PublicationTablesPanel({ study, runs }: { .then(r => { if (!cancelled) setBuilt(r.table) }) .catch(err => { if (!cancelled) toast.error(`Publication table: ${errorMessage(err)}`) }) return () => { cancelled = true } - // Only presentation changes trigger this; data changes clear the preview instead. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [heatmap, hideZeros]) const act = async (key: string, f: () => Promise) => { diff --git a/frontend/src/components/alphaMetrics.tsx b/frontend/src/components/alphaMetrics.tsx index 98f1d062..c2e6b79c 100644 --- a/frontend/src/components/alphaMetrics.tsx +++ b/frontend/src/components/alphaMetrics.tsx @@ -85,8 +85,16 @@ export function filterAlphaFigure( const raw = figure as PlotlyFigure if (!raw.data || !raw.layout) return figure - // Deep clone to strip Plotly-internal state from any prior render - const fig = JSON.parse(JSON.stringify(raw)) as PlotlyFigure + // Deep clone to strip Plotly-internal state from any prior render; fall back + // to the original figure if JSON cloning fails rather than crashing render. + let fig: PlotlyFigure + try { + const encoded = JSON.stringify(raw) + if (encoded === undefined) return figure + fig = JSON.parse(encoded) as PlotlyFigure + } catch { + return figure + } // Determine which panels to keep (in original order) const kept = ALPHA_METRICS.filter(m => selected.has(m)) diff --git a/frontend/src/figure/charts.ts b/frontend/src/figure/charts.ts index dcb58c29..1ff3bd32 100644 --- a/frontend/src/figure/charts.ts +++ b/frontend/src/figure/charts.ts @@ -115,7 +115,14 @@ export function paneFigure(raw: unknown, pane: FigurePane, doc: FigureDoc, opts: widthPx: number heightPx: number }): PlotSpec { - const fig = JSON.parse(JSON.stringify(raw)) as PlotSpec + let fig: PlotSpec + try { + const encoded = JSON.stringify(raw) + if (encoded === undefined) throw new TypeError('Chart figure must be JSON-serialisable') + fig = JSON.parse(encoded) as PlotSpec + } catch (err) { + throw new Error(`Invalid chart figure payload: ${err instanceof Error ? err.message : String(err)}`) + } const base = doc.font.sizePt * PX_PER_PT const tick = tickPt(doc) * PX_PER_PT const style = pane.style ?? {} diff --git a/scripts/check-governance-contract.sh b/scripts/check-governance-contract.sh index 9d46d095..4fc7c897 100755 --- a/scripts/check-governance-contract.sh +++ b/scripts/check-governance-contract.sh @@ -152,11 +152,14 @@ not_contains "$k9_workflow" 'ref: ${{ job.workflow_sha }}' \ 'the K9 standards checkout cannot use the caller SHA' contains "$k9_workflow" 'if [ -f "$RUNNER_TEMP/corpus.log" ]; then' \ 'optional corpus-log copying cannot fail an empty K9 corpus' +contains "$caller" 'require-files: true' \ + 'the committed K9 corpus cannot silently become empty' contains "$caller" 'fail-on-indeterminate: true' \ 'missing K9 semantic evidence fails closed' contains "$caller" 'needs: [deed, k9, hypatia]' \ 'the PR verdict waits for all three independent lanes' contains "$caller" 'name: k9-grid' 'the K9 artifact remains visible in the PR verdict' +contains "$caller" 'out/k9-grid.json' 'the governance-badges branch publishes the K9 badge' contains "$caller" '${K9_VERDICT:-orange}' 'an absent K9 verdict is indeterminate' secret_workflow="$(cat .github/workflows/secret-scanner.yml)" diff --git a/scripts/tests/test-governance-contract.sh b/scripts/tests/test-governance-contract.sh index ab184e55..08f5d71e 100644 --- a/scripts/tests/test-governance-contract.sh +++ b/scripts/tests/test-governance-contract.sh @@ -10,11 +10,12 @@ cp "$ROOT/scripts/check-governance-contract.sh" "$fixture/scripts/" reset_fixture() { cp "$ROOT"/.github/workflows/*.yml "$fixture/.github/workflows/"; } reset_fixture bash "$fixture/scripts/check-governance-contract.sh" > "$fixture/log" 2>&1 -for mutation in sarif k9-pin k9-indeterminate k9-verdict scanner-advisory scanner-secrets scanner-trigger scorecard-trigger; do +for mutation in sarif k9-pin k9-require k9-indeterminate k9-verdict scanner-advisory scanner-secrets scanner-trigger scorecard-trigger; do reset_fixture case "$mutation" in sarif) sed -i "/if: github.event_name != 'pull_request'/d" "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; k9-pin) sed -i 's/ref: c6d78606f3928c22c3b0bdc6f5f5b47b96e05a5d/ref: main/' "$fixture/.github/workflows/k9-conformance-reusable.yml" ;; + k9-require) sed -i 's/require-files: true/require-files: false/g' "$fixture/.github/workflows/governance.yml" ;; k9-indeterminate) sed -i 's/fail-on-indeterminate: true/fail-on-indeterminate: false/' "$fixture/.github/workflows/governance.yml" ;; k9-verdict) sed -i 's/${K9_VERDICT:-orange}/${K9_VERDICT:-green}/' "$fixture/.github/workflows/governance.yml" ;; scanner-advisory) echo ' continue-on-error: true' >> "$fixture/.github/workflows/secret-scanner.yml" ;; diff --git a/src/server/routes/analysis.jl b/src/server/routes/analysis.jl index b8284432..8505a73d 100644 --- a/src/server/routes/analysis.jl +++ b/src/server/routes/analysis.jl @@ -38,7 +38,8 @@ end dir, err = _require_run_results(study, run; group) !isnothing(err) && return err - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body table = get(body, :table, "merged") params = _body_filter_params(body) cfg_cache = Dict{Tuple{String,String,String}, Any}() @@ -235,7 +236,8 @@ end dir, err = _require_run_results(study, run; group) !isnothing(err) && return err - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body table = string(get(body, :table, "merged")) tag = string(get(body, :tag, "rank")) value = string(get(body, :value, "")) @@ -695,7 +697,8 @@ end @post "/api/v1/studies/{study}/analysis/alpha" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body runs_spec = get(body, :runs, []) isempty(runs_spec) && return json_error(400, "no_runs", "Provide at least one run") table = get(body, :table, "merged") @@ -777,7 +780,8 @@ end "Study '$study' not found") !r_available() && return json_error(503, "r_unavailable", "R/vegan is not available - install R and the vegan package") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body runs_spec = get(body, :runs, []) length(runs_spec) < 2 && return json_error(400, "too_few_runs", "NMDS requires at least 2 runs") @@ -834,7 +838,8 @@ end "Study '$study' not found") !r_available() && return json_error(503, "r_unavailable", "R/vegan is not available") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body runs_spec = get(body, :runs, []) length(runs_spec) < 2 && return json_error(400, "too_few_runs", "PERMANOVA requires at least 2 runs") @@ -877,7 +882,8 @@ end @post "/api/v1/studies/{study}/analysis/chart" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body runs_spec = get(body, :runs, []) isempty(runs_spec) && return json_error(400, "no_runs", "Provide at least one run") table = string(get(body, :table, "merged")) @@ -1022,7 +1028,8 @@ end @post "/api/v1/studies/{study}/analysis/chart-facet" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body runs_spec = get(body, :runs, []) isempty(runs_spec) && return json_error(400, "no_runs", "Provide at least one run") @@ -1141,7 +1148,8 @@ end @post "/api/v1/studies/{study}/analysis/venn" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body runs_spec = get(body, :runs, []) isempty(runs_spec) && return json_error(400, "no_runs", "Provide at least one run") table = string(get(body, :table, "merged")) @@ -1345,5 +1353,7 @@ end ## Cross-run differential abundance (negative binomial + BH) @post "/api/v1/studies/{study}/analysis/differential" function(req, study::String) - _differential(study, JSON3.read(String(req.body))) + body = _read_json_object(req) + body isa HTTP.Response && return body + _differential(study, body) end diff --git a/src/server/routes/composition.jl b/src/server/routes/composition.jl index abb562fc..342b611c 100644 --- a/src/server/routes/composition.jl +++ b/src/server/routes/composition.jl @@ -218,7 +218,9 @@ end # write lands on disk, so a name violating the charset or a value that is not # a Dict is rejected with 400 "invalid_library". @post "/api/v1/composition/filters/{name}" function(req, name::String) - body = _to_plain(JSON3.read(String(req.body))) + parsed = _read_json_object(req; non_object_error="invalid_library") + parsed isa HTTP.Response && return parsed + body = _to_plain(parsed) result = _save_filter(name, body) result isa HTTP.Response && return result json(result) @@ -236,7 +238,9 @@ end # unassigned_colour, categories). A category naming a filter absent from the # library fails validation and is rejected with 400 "invalid_library". @post "/api/v1/composition/sets/{name}" function(req, name::String) - body = _to_plain(JSON3.read(String(req.body))) + parsed = _read_json_object(req; non_object_error="invalid_library") + parsed isa HTTP.Response && return parsed + body = _to_plain(parsed) result = _save_composition_set(name, body) result isa HTTP.Response && return result json(result) @@ -342,7 +346,8 @@ end # Save a category set, mirroring the filter-preset save apparatus. @post "/api/v1/category-sets/{name}" function(req, name::String) - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body base = string(get(body, :base, "")) colours = Dict{String,String}() let c = get(body, :colours, nothing) @@ -373,7 +378,8 @@ end err = _validate_run_request(study, run) isnothing(err) || return err - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body category_set = string(get(body, :category_set, "default")) subgroup = let s = get(body, :subgroup, nothing) isnothing(s) ? nothing : string(s) @@ -403,7 +409,8 @@ end !isnothing(err) && return err group = _req_group(req) - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body category_set = string(get(body, :category_set, "default")) table = string(get(body, :table, "merged")) @@ -429,7 +436,8 @@ end !isnothing(err) && return err group = _req_group(req) - body = JSON3.read(String(req.body)) + body = _read_json_object(req; allow_empty=true) + body isa HTTP.Response && return body category_set = string(get(body, :category_set, "default")) table = string(get(body, :table, "merged")) diff --git a/src/server/routes/config.jl b/src/server/routes/config.jl index d83b07af..3d8073d1 100644 --- a/src/server/routes/config.jl +++ b/src/server/routes/config.jl @@ -473,7 +473,8 @@ end @patch "/api/v1/config" function(req) root = dirname(ServerState.data_dir()) user_path = joinpath(root, "config", "pipeline.yml") - body = JSON3.read(req.body) + body = _read_json_object(req) + body isa HTTP.Response && return body for (k, v) in body string(k) in _ALLOWED_CONFIG_KEYS || return json_error(400, "invalid_config_key", "Config key '$(string(k))' is not a recognised pipeline option") @@ -736,7 +737,8 @@ end @patch "/api/v1/studies/{study}/config" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(req.body) + body = _read_json_object(req) + body isa HTTP.Response && return body for (k, v) in body string(k) in _ALLOWED_CONFIG_KEYS || return json_error(400, "invalid_config_key", "Config key '$(string(k))' is not a recognised pipeline option") @@ -820,7 +822,8 @@ end "Study '$study' not found") group in _group_names(study) || return json_error(404, "group_not_found", "Group '$group' not found") - body = JSON3.read(req.body) + body = _read_json_object(req) + body isa HTTP.Response && return body for (k, v) in body string(k) in _ALLOWED_CONFIG_KEYS || return json_error(400, "invalid_config_key", "Config key '$(string(k))' is not a recognised pipeline option") @@ -868,7 +871,8 @@ end isnothing(err) || return err group = let g = _req_group(req); isnothing(g) ? _run_group(study, run) : g end run_path = isnothing(group) ? run : joinpath(group, run) - body = JSON3.read(req.body) + body = _read_json_object(req) + body isa HTTP.Response && return body for (k, v) in body string(k) in _ALLOWED_CONFIG_KEYS || return json_error(400, "invalid_config_key", "Config key '$(string(k))' is not a recognised pipeline option") diff --git a/src/server/routes/publication_tables.jl b/src/server/routes/publication_tables.jl index 462ca5e6..9d1b5be9 100644 --- a/src/server/routes/publication_tables.jl +++ b/src/server/routes/publication_tables.jl @@ -442,7 +442,8 @@ _pub_filename(study, ext) = replace("$(study)_table.$ext", r"[^\w._-]" => "_") @post "/api/v1/studies/{study}/analysis/publication-tables" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body tbl = _publication_table(study, body) tbl isa NamedTuple && return tbl.error diff --git a/src/server/routes/results.jl b/src/server/routes/results.jl index c97b6627..df0761b6 100644 --- a/src/server/routes/results.jl +++ b/src/server/routes/results.jl @@ -101,7 +101,8 @@ end isnothing(dir) && return json_error(404, "no_results", "No results database for run '$run' - run the pipeline first") - body = length(req.body) > 0 ? JSON3.read(String(req.body)) : (;) + body = _read_json_object(req; allow_empty=true) + body isa HTTP.Response && return body with_results_db(dir) do con _duckdb_distinct(con, table, column, body) @@ -121,7 +122,8 @@ end isnothing(dir) && return json_error(404, "no_results", "No results database for run '$run' - run the pipeline first") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body with_results_db(dir) do con _duckdb_paginated_query(con, table, body) @@ -208,7 +210,8 @@ end isnothing(dir) && return json_error(404, "no_results", "No results database for run '$run' - run the pipeline first") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body preset_file = get(body, :preset, nothing) isnothing(preset_file) && return json_error(400, "missing_preset", "Body must include 'preset'") preset_file = string(preset_file) @@ -313,16 +316,19 @@ end Validation.is_safe_name(name) || return json_error(400, "invalid_name", "Filter name must contain only letters, numbers, dots, hyphens, and underscores") + body = _read_json_object(req) + body isa HTTP.Response && return body + filters = get(body, :filters, nothing) + isnothing(filters) && return json_error(400, "missing_filters", "Body must include 'filters'") + filters isa JSON3.Object || filters isa AbstractDict || + return json_error(400, "invalid_filters", "Field 'filters' must be a JSON object") + description = get(body, :description, "Saved from WebUI") + dir = _presets_dir() mkpath(dir) filename = endswith(name, ".yml") ? name : name * ".yml" path = joinpath(dir, filename) - body = JSON3.read(String(req.body)) - filters = get(body, :filters, nothing) - isnothing(filters) && return json_error(400, "missing_filters", "Body must include 'filters'") - description = get(body, :description, "Saved from WebUI") - filter_list = OrderedDict{String,Any}[] for (col, f) in pairs(filters) col_str = string(col) @@ -417,7 +423,8 @@ const _PIPELINE_TABLES = ("merged", "merged_otu", "merged_cdhit", "cluster_membe isnothing(dir) && return json_error(404, "no_results", "No results database for run '$run' - run the pipeline first") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body save_name = get(body, :name, nothing) isnothing(save_name) && return json_error(400, "missing_name", "Body must include 'name'") save_name = string(save_name) @@ -464,7 +471,8 @@ end isnothing(dir) && return json_error(404, "no_results", "No results database for run '$run' - run the pipeline first") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body params = _body_filter_params(body) sort_by = get(params, "sort", nothing) sort_dir = get(params, "sort_dir", "asc") diff --git a/src/server/routes/runs.jl b/src/server/routes/runs.jl index 7f4b576f..e5ecef14 100644 --- a/src/server/routes/runs.jl +++ b/src/server/routes/runs.jl @@ -416,7 +416,8 @@ end @post "/api/v1/studies/{study}/runs" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body name = get(body, :name, nothing) group = get(body, :group, nothing) isnothing(name) && return json_error(400, "missing_name", "Body must include 'name'") @@ -438,7 +439,8 @@ end @post "/api/v1/studies/{study}/runs/{run}/rename" function(req, study::String, run::String) err = _validate_run_request(study, run) isnothing(err) || return err - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body new_name = get(body, :name, nothing) isnothing(new_name) && return json_error(400, "missing_name", "Body must include 'name'") new_name = string(new_name) diff --git a/src/server/routes/studies.jl b/src/server/routes/studies.jl index 10cbac70..a3f99cae 100644 --- a/src/server/routes/studies.jl +++ b/src/server/routes/studies.jl @@ -188,7 +188,8 @@ end ## Study management @post "/api/v1/studies" function(req) - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body name = get(body, :name, nothing) isnothing(name) && return json_error(400, "missing_name", "Body must include 'name'") name = string(name) @@ -212,7 +213,8 @@ end @post "/api/v1/studies/{study}/rename" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body new_name = get(body, :name, nothing) isnothing(new_name) && return json_error(400, "missing_name", "Body must include 'name'") new_name = string(new_name) @@ -242,7 +244,8 @@ end @post "/api/v1/studies/{study}/groups" function(req, study::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body name = get(body, :name, nothing) isnothing(name) && return json_error(400, "missing_name", "Body must include 'name'") name = string(name) @@ -263,7 +266,8 @@ end "Study '$study' not found") group in _group_names(study) || return json_error(404, "group_not_found", "Group '$group' not found") - body = JSON3.read(String(req.body)) + body = _read_json_object(req) + body isa HTTP.Response && return body new_name = get(body, :name, nothing) isnothing(new_name) && return json_error(400, "missing_name", "Body must include 'name'") new_name = string(new_name) diff --git a/src/server/routes/validated_statistics.jl b/src/server/routes/validated_statistics.jl index 56e797ee..7dddea35 100644 --- a/src/server/routes/validated_statistics.jl +++ b/src/server/routes/validated_statistics.jl @@ -39,9 +39,10 @@ function _statistics_guard(f::Function) end function _statistics_body(req; reject_noninteger_tokens::Bool=false) - length(req.body) <= _STATISTICS_REQUEST_LIMIT || throw(ResourceLimitError( - "request bytes", _STATISTICS_REQUEST_LIMIT, length(req.body))) - source = String(copy(req.body)) + body_bytes = req.body isa AbstractString ? ncodeunits(req.body) : length(req.body) + body_bytes <= _STATISTICS_REQUEST_LIMIT || throw(ResourceLimitError( + "request bytes", _STATISTICS_REQUEST_LIMIT, body_bytes)) + source = req.body isa AbstractString ? String(req.body) : String(copy(req.body)) parsed = ijson_read(source) parsed isa JSON3.Object || throw(ArgumentError("request body must be a JSON object")) reject_noninteger_tokens && _reject_noninteger_json_tokens(source) diff --git a/src/server/server.jl b/src/server/server.jl index 93e0717a..9e22a3dc 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -82,6 +82,29 @@ module Server HTTP.Response(status, ["Content-Type" => "application/json"], body=bytes) end + # Parse a JSON object request body without mutating `req.body::Vector{UInt8}` + # (Julia 1.12 `String(::Vector{UInt8})` truncates its argument in place). + # Malformed JSON or a non-object JSON payload returns a 400 HTTP.Response + # instead of throwing an unhandled parse/method exception out of the route. + function _read_json_object(req::HTTP.Request; + allow_empty::Bool=false, + non_object_error::String="invalid_json") + body_bytes = req.body isa AbstractString ? ncodeunits(req.body) : length(req.body) + if body_bytes == 0 && allow_empty + return (;) + end + raw = req.body isa AbstractString ? String(req.body) : String(copy(req.body)) + parsed = try + JSON3.read(raw) + catch e + return json_error(400, "invalid_json", + "Request body is not valid JSON: $(sprint(showerror, e))") + end + parsed isa JSON3.Object || return json_error(400, non_object_error, + "Request body must be a JSON object") + parsed + end + ## Route registration # Oxygen's routing macros register with its global router as the file # loads. A precompiled module does not load its files again, so these diff --git a/test/unit/test_evidence.jl b/test/unit/test_evidence.jl index 1b4812df..841c902d 100644 --- a/test/unit/test_evidence.jl +++ b/test/unit/test_evidence.jl @@ -149,7 +149,7 @@ end @test loaded["claims"][1]["text"] == "Lactobacillus holds more than half the caecal reads" @test loaded["claims"][1]["scope"]["values"] == ["Lactobacillus"] @test loaded["schema_version"] == Evidence.SCHEMA_VERSION - @test loaded["updated"] isa AbstractString + @test occursin(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", loaded["updated"]) # A second save must be able to append a claim of a different shape; YAML # narrows homogeneous sequences and the vector must stay open. diff --git a/test/unit/test_occupancy_grade.jl b/test/unit/test_occupancy_grade.jl index 0308b442..86b68d61 100644 --- a/test/unit/test_occupancy_grade.jl +++ b/test/unit/test_occupancy_grade.jl @@ -162,7 +162,8 @@ end @test MG.disagreement(occ) === nothing # The bound the expiry sharpens: 201 KiB resident at once is now the # disagreement, where before the fix it was the prediction. - @test MG.disagreement(_occ_stage(tasks; measured = 201)) isa String + @test MG.disagreement(_occ_stage(tasks; measured = 201)) == + "measured 201 KiB above the predicted 101 KiB" end @testset "registration keeps the first child's sample boundary" begin @@ -243,8 +244,7 @@ end # allows: a sample saw more than the timed timeline can hold. rec = _occ_stage([_occ_task(100; first_sample = 1, last_sample = 2)]; measured = 250) d = MG.disagreement(rec) - @test d isa String - @test occursin("250", d) && occursin("100", d) + @test d == "measured 250 KiB above the predicted 100 KiB" # At or below the bound there is nothing to report. @test MG.disagreement(_occ_stage([_occ_task(100; first_sample = 1, last_sample = 2)]; measured = 100)) === nothing diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl index eee51fc7..d539d21f 100644 --- a/test/unit/test_server_security.jl +++ b/test/unit/test_server_security.jl @@ -195,6 +195,44 @@ end @test SV.HTTP.header(f, "X-Content-Type-Options") == "nosniff" @test SV.HTTP.header(f, "Content-Security-Policy") == "" end + + @testset "Malformed or non-object JSON bodies answer 400 rather than 500" begin + headers = ["Host" => "127.0.0.1:8080", "Content-Type" => "application/json"] + object_routes = ( + ("POST", "/api/v1/studies"), + ("POST", "/api/v1/studies/StudyS/rename"), + ("POST", "/api/v1/studies/StudyS/groups"), + ("POST", "/api/v1/studies/StudyS/runs"), + ("POST", "/api/v1/studies/StudyS/runs/run1/rename"), + ("PATCH", "/api/v1/config"), + ("PATCH", "/api/v1/studies/StudyS/config"), + ("PATCH", "/api/v1/studies/StudyS/runs/run1/config"), + ("POST", "/api/v1/category-sets/custom"), + ("POST", "/api/v1/studies/StudyS/runs/run1/composition/summary"), + ("POST", "/api/v1/studies/StudyS/analysis/alpha"), + ("POST", "/api/v1/studies/StudyS/analysis/chart"), + ("POST", "/api/v1/studies/StudyS/analysis/chart-facet"), + ("POST", "/api/v1/studies/StudyS/analysis/venn"), + ("POST", "/api/v1/studies/StudyS/analysis/differential"), + ("POST", "/api/v1/studies/StudyS/analysis/publication-tables"), + ("POST", "/api/v1/filter-presets/testpreset"), + ) + for (method, path) in object_routes, payload in ("{not-json", "[1,2]", "123") + r = _ss_request(method, path, headers, payload) + @test r.status == 400 + @test JSON3.read(String(copy(r.body))).error == "invalid_json" + end + + for path in ("/api/v1/composition/filters/f1", "/api/v1/composition/sets/s1") + bad_syntax = _ss_request("POST", path, headers, "{not-json") + @test bad_syntax.status == 400 + @test JSON3.read(String(copy(bad_syntax.body))).error == "invalid_json" + + bad_shape = _ss_request("POST", path, headers, "[1,2]") + @test bad_shape.status == 400 + @test JSON3.read(String(copy(bad_shape.body))).error == "invalid_library" + end + end finally saved_origins === nothing || (ENV["JULIA_METAMANIFOLD_ALLOWED_ORIGINS"] = saved_origins) diff --git a/test/unit/test_validated_statistics.jl b/test/unit/test_validated_statistics.jl index ea64cbcd..9a035d0b 100644 --- a/test/unit/test_validated_statistics.jl +++ b/test/unit/test_validated_statistics.jl @@ -264,3 +264,23 @@ end @test count(b -> b.derived_analysis.created, bodies) == 1 end end + +@testset "Statistics request body preserves byte buffers and counts UTF-8 bytes" begin + raw_bytes = Vector{UInt8}(codeunits("{\"counts\":[[1,2]]}")) + parsed_bytes = VALIDATED_SV._statistics_body((; body = raw_bytes); reject_noninteger_tokens = true) + @test collect(parsed_bytes.counts[1]) == [1, 2] + # Julia 1.12 `String(::Vector{UInt8})` truncates in place; `_statistics_body` must copy first. + @test length(raw_bytes) == ncodeunits("{\"counts\":[[1,2]]}") + + sub_body = SubString(" {\"label\":\"ok\"} ", 3, 16) + parsed_sub = VALIDATED_SV._statistics_body((; body = sub_body)) + @test String(parsed_sub.label) == "ok" + + # Multi-byte UTF-8 input whose codepoint count is under the limit while its + # UTF-8 byte count exceeds `_STATISTICS_REQUEST_LIMIT` must fail on bytes. + limit = VALIDATED_SV._STATISTICS_REQUEST_LIMIT + multibyte_payload = SubString("{\"v\":\"" * repeat("é", div(limit, 2) + 8) * "\"}", 1) + @test length(multibyte_payload) < limit + @test ncodeunits(multibyte_payload) > limit + @test_throws MetaManifold.NumericPolicy.ResourceLimitError VALIDATED_SV._statistics_body((; body = multibyte_payload)) +end