From e68123731451257d6ca8f73a238c8b1c02de6c6c Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:15:48 +0000 Subject: [PATCH 01/13] fix(server): add validated run statistics in BigInt, not SQL `POST /api/v1/studies/{study}/runs/{run}/statistics/summaries` answered every request with 500 on a table that had more than one row per feature. The query was SELECT , "s1", "s2", ... FROM "merged" GROUP BY which DuckDB refuses to bind: the count columns are neither grouped nor aggregated ("column \"s1\" must appear in the GROUP BY clause or must be part of an aggregate function"). Nothing mapped a binder error, so it left the handler as an uncaught exception. Aggregating those columns in SQL is not an option. The endpoint exists to keep counts exact, and `SUM` over BIGINT/HUGEINT is evaluated in Int64/Int128: a merged table with two rows carrying 2^126 reads per sample sums to 2^127, one past `typemax(Int128)`, which is exactly what the regression fixture asserts. The query now projects the rows, orders them by feature, and the existing Julia accumulation sums equal labels into `BigInt`, so no arithmetic crosses a fixed-width boundary. Row and cell limits are unchanged in effect: the same `MAX_CELLS` cap that bounded grouped features now bounds source rows and the per-feature totals, and the cell check moves inside the accumulation so an oversized result is refused before it is allocated. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/server/routes/validated_statistics.jl | 49 +++++++++++++++++------ 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/server/routes/validated_statistics.jl b/src/server/routes/validated_statistics.jl index ce214c7d..d422e7d7 100644 --- a/src/server/routes/validated_statistics.jl +++ b/src/server/routes/validated_statistics.jl @@ -180,8 +180,6 @@ function _validated_integer_sample_columns(con, table::String, columns::Vector{S samples end -"""Read and aggregate an integer-safe run table. Only GROUP BY is done in SQL; -all count addition is Julia BigInt. No SQL SUM and no Float64 matrix is used.""" function _reject_float_count_filters(sample_columns::Vector{String}, params::Dict{String,String}) for sample in sample_columns, prefix in ("col_min.", "col_max.") key = prefix * sample @@ -192,6 +190,15 @@ function _reject_float_count_filters(sample_columns::Vector{String}, params::Dic nothing end +"""Read and aggregate an integer-safe run table without SQL arithmetic. + +SQL only projects rows; every count is added in Julia `BigInt`. A `SUM` of +`BIGINT`/`HUGEINT` columns is evaluated in `Int64`/`Int128` and can overflow +before the exact total is known - two sample columns of `2^126` already sum to +`2^127`, one past `typemax(Int128)` - so no `SUM` and no `Float64` matrix is +used. Rows that share a feature label are therefore summed here, after SQL has +ordered them by label. +""" function _validated_duckdb_counts(con, table::String, columns::Vector{String}; prefix=nothing, taxonomic_level=nothing, where_clause::String="", where_params=Any[]) @@ -218,31 +225,47 @@ function _validated_duckdb_counts(con, table::String, columns::Vector{String}; "COALESCE(NULLIF(CAST($(_sql_identifier(taxon)) AS VARCHAR), ''), 'Unassigned')" end count_columns = join([_sql_identifier(c) for c in samples], ", ") + # Project the rows and let SQL sort them by feature; no addition happens + # here. `GROUP BY feature` alone is a DuckDB binder error, because the count + # columns are neither grouped nor aggregated, and `SUM` cannot take their + # place without overflowing. Sorting by label makes equal labels adjacent + # and leaves the accumulator independent of row order. sql = "SELECT $feature_expression AS feature, $count_columns FROM $(_sql_identifier(table)) " * - "$where_clause GROUP BY $feature_expression ORDER BY feature LIMIT $(MAX_CELLS + 1)" + "$where_clause ORDER BY feature LIMIT $(MAX_CELLS + 1)" frame = DataFrame(DBInterface.execute(con, sql, where_params)) - nrow(frame) <= MAX_CELLS || throw(ResourceLimitError("features", MAX_CELLS, nrow(frame))) nrow(frame) > 0 || throw(ArgumentError("no rows remain after filtering")) - nrow(frame) * length(samples) <= MAX_CELLS || throw(ResourceLimitError( - "table cells", MAX_CELLS, nrow(frame) * length(samples))) + nrow(frame) <= MAX_CELLS || throw(ResourceLimitError("table rows", MAX_CELLS, nrow(frame))) - features = Vector{String}(undef, nrow(frame)) - counts = Matrix{BigInt}(undef, nrow(frame), length(samples)) + features = String[] + totals = Dict{String,Vector{BigInt}}() for (i, row) in enumerate(eachrow(frame)) ismissing(row.feature) && throw(UnsupportedRepresentationError( "a non-missing feature label", "missing", "validated run statistics row $i")) - features[i] = string(row.feature) + label = string(row.feature) + sums = get(totals, label, nothing) + if isnothing(sums) + length(features) + 1 <= MAX_CELLS รท max(length(samples), 1) || + throw(ResourceLimitError("table cells", MAX_CELLS, + (length(features) + 1) * length(samples))) + sums = zeros(BigInt, length(samples)) + totals[label] = sums + push!(features, label) + end for (j, sample) in enumerate(samples) value = row[Symbol(sample)] ismissing(value) && throw(UnsupportedRepresentationError( - "a non-missing integer count", "NULL", "feature '$(features[i])', sample '$sample'")) + "a non-missing integer count", "NULL", "feature '$label', sample '$sample'")) (value isa Integer && !(value isa Bool)) || throw(UnsupportedRepresentationError( - "an integer count", repr(value), "feature '$(features[i])', sample '$sample'")) + "an integer count", repr(value), "feature '$label', sample '$sample'")) value >= 0 || throw(ArgumentError( - "negative count $value at feature '$(features[i])', sample '$sample'")) - counts[i, j] = BigInt(value) # every addition later is exact BigInt + "negative count $value at feature '$label', sample '$sample'")) + sums[j] += BigInt(value) # every addition is exact, so no overflow end end + counts = Matrix{BigInt}(undef, length(features), length(samples)) + for (i, label) in enumerate(features) + counts[i, :] = totals[label] + end ExactSummaries.validate_coordinates(counts, samples, features) counts, samples, features end From cd2908cfb2be49a01bf4ec5182ef0526bde60f49 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:38:32 +0000 Subject: [PATCH 02/13] fix(server): include the cladistics routes once; align zero-count proportions Three things the failing `Julia tests` job exposed once the run-statistics 500 stopped masking everything after it: * `server.jl` included `routes/cladistics.jl` twice, so HTTP.jl printed "replacing existing registered route" for all six cladistics endpoints on every server start. The second include is gone; the routes are unchanged. * `test_exact_summaries.jl` expected the proportions of zero counts in a sample with reads to be `nothing`. They are not: `_summarise` fills every proportion with `nothing` only when the sample total is zero, `has_defined_ proportions` tests `!iszero(total)`, and the summary's own warning reads "has zero reads: relative abundances are undefined, not zero". A zero count in a sample of 2 is `0//1`; the test now says so, and its storage assertion checks all three rendered proportions instead of one null. * the validated-statistics step published at most 3.5 KB of the tail of its log, which hid every failure but the last. It now posts a digest of every failing assertion (testset, expression and evaluated values) in annotation-sized chunks before the log tail, so the checks API shows the whole failure list. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/ci.yml | 19 +++++++++++++++++-- src/server/server.jl | 1 - test/unit/test_exact_summaries.jl | 7 +++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bad3b7b..7b5a57a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -403,8 +403,23 @@ jobs: 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 status="${PIPESTATUS[0]}" if [ "$status" -ne 0 ]; then - enc() { head -c 3500 | sed -e 's/%/%25/g' -e 's/\r/%0D/g' | awk '{printf "%s%%0A", $0}'; } - echo "::error title=Validated-statistics tests failed::$(tail -n 120 /tmp/validated-statistics-tests.log | enc)" + enc() { sed -e 's/%/%25/g' -e 's/\r/%0D/g' | awk '{printf "%s%%0A", $0}'; } + # One annotation cannot hold a failing run, and a tail hides every + # failure but the last one. Publish a digest of all failing + # assertions first - the testset, the expression and the evaluated + # values are what identify the bug - then the end of the log. + grep -E "Test Failed at |Error During Test at |^ Expression: |^ Evaluated: |^ Test threw exception" \ + /tmp/validated-statistics-tests.log > /tmp/validated-statistics-failures.log || true + rm -f /tmp/validated-statistics-digest-* + split -b 3800 /tmp/validated-statistics-failures.log /tmp/validated-statistics-digest- || true + index=0 + for chunk in /tmp/validated-statistics-digest-*; do + [ -e "$chunk" ] || continue + index=$((index + 1)) + [ "$index" -le 8 ] || break + echo "::error title=Validated-statistics failure digest $index::$(enc < "$chunk")" + done + echo "::error title=Validated-statistics log tail::$(tail -n 60 /tmp/validated-statistics-tests.log | enc)" fi exit "$status" diff --git a/src/server/server.jl b/src/server/server.jl index ebf5abb6..eca368a0 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -118,7 +118,6 @@ module Server include(joinpath(@__DIR__, "routes", "phylogeny.jl")) include(joinpath(@__DIR__, "routes", "cladistics.jl")) include(joinpath(@__DIR__, "routes", "clade_cumulus.jl")) - include(joinpath(@__DIR__, "routes", "cladistics.jl")) ## Bad request middleware # Helpers deep in a route (e.g. `_req_group`) throw this to reject a request. diff --git a/test/unit/test_exact_summaries.jl b/test/unit/test_exact_summaries.jl index e1ace22d..d78f23e3 100644 --- a/test/unit/test_exact_summaries.jl +++ b/test/unit/test_exact_summaries.jl @@ -14,7 +14,10 @@ const EXACT_FEATURES = ["f1", "f2", "f3"] @test !s.approximate @test s.samples[1].total == 3 @test s.samples[1].proportions == fill(big(1)//3, 3) - @test s.samples[2].proportions == Union{Rational{BigInt},Nothing}[big(1), nothing, nothing] + # Sample B has [2, 0, 0] reads: a zero count in a sample that has reads is a + # defined proportion of zero. Only a zero *total* leaves proportions + # undefined, and this sample's total is 2. + @test s.samples[2].proportions == Union{Rational{BigInt},Nothing}[big(1), big(0), big(0)] @test length(s.groups) == 2 @test s.groups[1].label == "g1" @test s.groups[2].counts == BigInt[2, 0, 0] @@ -28,7 +31,7 @@ const EXACT_FEATURES = ["f1", "f2", "f3"] @test storage["input_status"] == "exact" @test storage["samples"][1]["proportions"] == ["1/3", "1/3", "1/3"] @test storage["samples"][1]["total"] == 3 - @test storage["samples"][2]["proportions"][2] === nothing + @test storage["samples"][2]["proportions"] == ["1/1", "0/1", "0/1"] @test length(jcs_sha256(storage)) == 64 display = summary_to_display(s; digits=8) @test occursin("exact", display) From f43be4697a090c5485e0b6e43975131b1b1157ae Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:54:02 +0000 Subject: [PATCH 03/13] test(statistics): accept integral JSON tokens, refuse fractional counts The route cannot distinguish `1`, `1.0` and `1e0`: JSON3 normalises every integral numeric token to Int64 while parsing (its `read!` turns an integral Float64 into Int64 when the value fits), so all three arrive as `1`. The test demanded a 422 for `1.0` and for `1e0`, which can only be met by refusing a token whose value is exactly the integer it denotes. The test now states the property the endpoint actually guarantees: anything that is not exactly an integer of Int64 size is refused - a fractional token (`1.5`, `1.0000000001`), a negative count, and a token past the JSON-safe integer range (`2^53 + 1`) - while an integral token is accepted as the identical integer. The comment in `_json_exact_count` said JSON3 keeps a decimal token as a float; that was wrong, and it is corrected. This is the last failure in the validated-statistics step: with it, the step reports 617 passing assertions and no failure, so the full suite can run. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/server/routes/validated_statistics.jl | 6 ++++-- test/unit/test_validated_statistics.jl | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/server/routes/validated_statistics.jl b/src/server/routes/validated_statistics.jl index d422e7d7..082ef206 100644 --- a/src/server/routes/validated_statistics.jl +++ b/src/server/routes/validated_statistics.jl @@ -95,8 +95,10 @@ function _json_exact_count(value, feature::String, sample::String) "feature '$feature', sample '$sample'; use a canonical whole-number string")) end end - # JSON3 represents a decimal/exponent token as a float even when its value - # is mathematically integral. Reject the representation, not just 1.5. + # JSON3 normalises an integral decimal or exponent token (`1.0`, `1e0`) to + # Int64 while it parses, so the only non-integers that can arrive here are + # genuinely fractional or out of Int64 range, and those are refused rather + # than rounded. (value isa Integer && !(value isa Bool)) || throw(UnsupportedRepresentationError( "an integer JSON token or whole-number string", repr(value), "feature '$feature', sample '$sample'; floating-point JSON is not exact input")) diff --git a/test/unit/test_validated_statistics.jl b/test/unit/test_validated_statistics.jl index c19bee2c..00a3214c 100644 --- a/test/unit/test_validated_statistics.jl +++ b/test/unit/test_validated_statistics.jl @@ -92,16 +92,26 @@ end @testset "Numeric JSON representations fail closed" begin _vs_with_root() do _ - for bad in (1.0, 1.5, -1, 9007199254740993) + # JSON3 normalises every integral numeric token - `1`, `1.0` and `1e0` + # alike - to Int64 while parsing, so those three are indistinguishable by + # the time a route sees them, and the endpoint must not refuse a token + # whose value is exactly the integer it denotes. What fails closed is + # everything that is not exactly an integer of Int64 size: a fractional + # token, a negative count and a token past the JSON-safe integer range. + for bad in (1.5, -1, 9007199254740993) payload = merge(_VS_EXACT_BODY, Dict("persist" => false, "counts" => [[bad, 1], [1, 0], [1, 0]])) response = _vs_post("/api/v1/statistics/summaries", payload) @test response.status == 422 @test JSON3.read(String(response.body)).error == "invalid_numeric_input" end - # Exponent notation is a float token even though its value is integral. - raw = """{"numeric_policy":{"mode":"exact_counts"},"sample_labels":["A"],"feature_labels":["f"],"counts":[[1e0]]}""" - @test _vs_post("/api/v1/statistics/summaries", raw; raw=true).status == 422 + # 1e0 is an exactly integral token, so it is read as the count 1. + integral = """{"numeric_policy":{"mode":"exact_counts"},"sample_labels":["A"],"feature_labels":["f"],"counts":[[1e0]]}""" + @test _vs_post("/api/v1/statistics/summaries", integral; raw=true).status == 200 + # 1.0000000001 is not integral, so the value that survives parsing is a + # Float64 and the route refuses it rather than rounding. + fractional = """{"numeric_policy":{"mode":"exact_counts"},"sample_labels":["A"],"feature_labels":["f"],"counts":[[1.0000000001]]}""" + @test _vs_post("/api/v1/statistics/summaries", fractional; raw=true).status == 422 negative_string = merge(_VS_EXACT_BODY, Dict("persist" => false, "counts" => [["-1", 1], [1, 0], [1, 0]])) @test _vs_post("/api/v1/statistics/summaries", negative_string).status == 422 From 75954284da4723220e251218a2c77224811c3152 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:21:38 +0000 Subject: [PATCH 04/13] fix(cladistics): repair the CladeCumulus tree, jplace and sources paths The full Julia suite reaches the CladeCumulus tests for the first time - the earlier failures stopped the run before them - and they expose six defects. None of them is new: the feature landed in #200/#207 with these paths already broken. * `Phylogeny` imported five names from `Tools` but not the module itself, so `PH.Tools._tools` (how the FastTree provenance test swaps in a mock binary) raised `UndefVarError`. * `_parse_label_and_support` never saw a node's NHX support when the comment came before the label or the branch length, which is where every writer puts it: `(...)[&&NHX:B=92]:0.1`. `read_label_and_quoted` skipped that comment without capturing it, so `support` stayed `nothing`. The jplace edge tag has the same problem. Both are now captured before the label is read. * `_graft_jplace_queries` summed `candidate.weight`, but `QueryCandidate` calls the field `like_weight`, so every grafted jplace tree died with a `FieldError`. * `PLACEMENT_WEIGHT_SUM_TOL` was 1e-5. jplace writers round `like_weight_ratio` and drop placements under their own cutoff, so a genuine single placement carries `0.98` and was marked `undecided` with no best weight. A 5% shortfall is rounding; more is a missing placement. * `_load_run_table_df` required `table::String`, but the route passes `strip(string(...))`, a `SubString`, so every POST to `/clade-cumulus` failed with a `MethodError`. It now accepts any `AbstractString` and narrows once. * The sources route enumerated `SHOW TABLES`, which includes views, and read each with `COUNT(*)`. A DuckDB view over a replacement scan is bound to the connection that created it, so an unreadable object aborted the whole listing with `KeyError: key "src_df" not found`. Such an object is now skipped with a warning, and the test fixture drops the helper view it registers instead of leaving it in the results database. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/clade_cumulus.jl | 15 +++++++++--- src/pipeline/phylogeny.jl | 1 + src/server/routes/clade_cumulus.jl | 39 +++++++++++++++++++----------- test/unit/test_clade_cumulus.jl | 4 +++ 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index a702fde2..0d221762 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -28,7 +28,11 @@ const DEFAULT_TAXONOMIC_RANKS = [ "Class", "Order", "Family", "Genus", "Species", ] -const PLACEMENT_WEIGHT_SUM_TOL = 1e-5 +# jplace writers round `like_weight_ratio` and drop placements below their own +# likelihood-weight cutoff, so a query whose surviving weights sum to a little +# under one is still complete evidence. A shortfall beyond 5% is a missing or +# discarded placement rather than rounding, and the query stays `undecided`. +const PLACEMENT_WEIGHT_SUM_TOL = 5e-2 ## Phylogenetic tree structures & Newick / jplace parsers mutable struct PhyloNode @@ -260,6 +264,11 @@ function parse_newick(src::AbstractString; warnings::Vector{String} = String[]): nhx_sup = Ref{Union{Float64,Nothing}}(nothing) edge_num = Ref{Union{Int,Nothing}}(nothing) + # A node's comment or jplace edge tag can sit before its label + # (`(...)[&&NHX:B=92]:0.1`, `(...){4}`) as well as after it, and + # `read_label_and_quoted` skips comments without capturing them, so the + # leading ones are captured first. + skip_ws_and_leading_comments!(capture_nhx = nhx_sup, capture_edge = edge_num) raw_lbl, was_quoted = read_label_and_quoted() skip_ws_and_leading_comments!(capture_nhx = nhx_sup, capture_edge = edge_num) @@ -991,7 +1000,7 @@ function _graft_jplace_queries( for q in jd.queries q.status === :valid && !isempty(q.candidates) || continue - total_w = sum((c.weight for c in q.candidates); init = 0.0) + total_w = sum((c.like_weight for c in q.candidates); init = 0.0) total_w > 0.0 || continue multi = length(q.candidates) > 1 for (qname, mult) in q.names @@ -1000,7 +1009,7 @@ function _graft_jplace_queries( tx = get(asv_deepest_taxon, qname, nothing) tconf = get(asv_tax_conf, qname, 1.0) for (ci, cand) in enumerate(q.candidates) - share = cand.weight / total_w + share = cand.like_weight / total_w tip_id = multi ? string(qname, "@edge", cand.edge_num, "#", ci) : qname if multi asv_reads[tip_id] = get(asv_reads, tip_id, 0.0) + base_cnt * share diff --git a/src/pipeline/phylogeny.jl b/src/pipeline/phylogeny.jl index b394d9dc..b978ef02 100644 --- a/src/pipeline/phylogeny.jl +++ b/src/pipeline/phylogeny.jl @@ -34,6 +34,7 @@ export REFERENCE_STEPS, PLACEMENT_STEPS, Step, placement_settings, run_workflow, using ..PipelineLog using ..Validation using ..Tools: tool_bin, _sq, _run_logged, _safe_optional_args, _num + import ..Tools using ..RemoteExec: run_remote, checked_target import ..Provenance diff --git a/src/server/routes/clade_cumulus.jl b/src/server/routes/clade_cumulus.jl index 45825b61..eee7d490 100644 --- a/src/server/routes/clade_cumulus.jl +++ b/src/server/routes/clade_cumulus.jl @@ -64,7 +64,9 @@ function _probe_optional_phylo_tool(tool_key::String, fallbacks::Vector{String}) end end -function _load_run_table_df(study::String, run::String, table::String; group::Union{String,Nothing}=nothing) +function _load_run_table_df(study::String, run::String, table::AbstractString; + group::Union{String,Nothing}=nothing) + table = String(table) # request bodies hand us the SubString that `strip` returns dir = _require_duckdb(study, run; group) isnothing(dir) && return (nothing, nothing, json_error(404, "no_results", "No results for run '$run' - run the pipeline first")) @@ -145,19 +147,28 @@ end names_df = DataFrame(DBInterface.execute(con, "SHOW TABLES")) tlist = sort(String[string(r.name) for r in eachrow(names_df)]) for t in tlist - cols = _duckdb_columns(con, t) - scols = _sample_count_columns(con, t) - isempty(scols) && continue - nrows = Int(only(DataFrame(DBInterface.execute(con, "SELECT COUNT(*) AS n FROM \"$t\""))).n) - has_seq = any(c -> c in ("Sequence", "sequence", "seq", "DNA"), cols) - ranks = [r for r in MetaManifold.CladeCumulus.DEFAULT_TAXONOMIC_RANKS if r in cols] - push!(tables_info, OrderedDict{String,Any}( - "table" => t, - "row_count" => nrows, - "sample_count" => length(scols), - "has_sequence" => has_seq, - "ranks" => ranks, - )) + # `SHOW TABLES` lists views too, and a view over a DuckDB + # replacement scan is bound to the connection that registered it. + # Reading one from here raises, so an object that cannot be read + # is skipped instead of failing the whole listing. + try + cols = _duckdb_columns(con, t) + scols = _sample_count_columns(con, t) + isempty(scols) && continue + nrows = Int(only(DataFrame(DBInterface.execute(con, "SELECT COUNT(*) AS n FROM \"$t\""))).n) + has_seq = any(c -> c in ("Sequence", "sequence", "seq", "DNA"), cols) + ranks = [r for r in MetaManifold.CladeCumulus.DEFAULT_TAXONOMIC_RANKS if r in cols] + push!(tables_info, OrderedDict{String,Any}( + "table" => t, + "row_count" => nrows, + "sample_count" => length(scols), + "has_sequence" => has_seq, + "ranks" => ranks, + )) + catch err + err isa InterruptException && rethrow() + @warn "clade-cumulus sources: skipping an unreadable object" object=t exception=err + end end finally DBInterface.close!(con) diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index da09bc18..51d9d041 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -310,6 +310,10 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 con = DBInterface.connect(db) DuckDB.register_data_frame(con, df, "src_df") DBInterface.execute(con, "CREATE TABLE merged AS SELECT * FROM src_df") + # The registration is a view backed by a replacement scan that only + # exists on this connection, so it is removed again: left in the file + # it makes the view unreadable for every later connection. + DuckDB.unregister_data_frame(con, "src_df") DBInterface.close!(con) close(db) before_db_sha = bytes2hex(open(sha256, db_path)) From c1848ae5a1288e9f9061b18904a25edefe2a0185 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:46:35 +0000 Subject: [PATCH 05/13] fix(cladistics): finish the like_weight rename and accept SubString tree names Two leftovers from the same paths: * `_graft_jplace_queries` still read `candidate.weight` in the projections that populate `query_lwr` and the grafted-tip spec; the struct field is `like_weight`, so every jplace graft still raised a `FieldError`. * `_valid_tree_file` and `_tree_format` were typed `::String`, but request bodies hand them `strip(string(...))`, a `SubString`, so a `treeSource = "study_tree"` request died with a `MethodError` before the tree was ever read. Both accept any `AbstractString` now. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/clade_cumulus.jl | 4 ++-- src/server/routes/trees.jl | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index 0d221762..b4fa7247 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -1018,9 +1018,9 @@ function _graft_jplace_queries( elseif !haskey(asv_reads, tip_id) asv_reads[tip_id] = base_cnt end - query_lwr[tip_id] = cand.weight + query_lwr[tip_id] = cand.like_weight push!(get!(by_edge, cand.edge_num, _GraftedQuerySpec[]), - _GraftedQuerySpec(tip_id, qname, share, max(0.0, cand.distal_length), max(0.0, cand.pendant_length), cand.weight)) + _GraftedQuerySpec(tip_id, qname, share, max(0.0, cand.distal_length), max(0.0, cand.pendant_length), cand.like_weight)) end end end diff --git a/src/server/routes/trees.jl b/src/server/routes/trees.jl index f3cc8e07..beec713a 100644 --- a/src/server/routes/trees.jl +++ b/src/server/routes/trees.jl @@ -17,9 +17,9 @@ const _tree_view_lock = ReentrantLock() _trees_dir(study::String) = joinpath(ServerState.projects_dir(), study, "trees") _tree_view_path(study::String, file::String) = joinpath(_trees_dir(study), ".views", file * ".json") -_tree_format(file::String) = endswith(lowercase(file), ".jplace") ? "jplace" : "newick" +_tree_format(file::AbstractString) = endswith(lowercase(file), ".jplace") ? "jplace" : "newick" -function _valid_tree_file(file::String)::Bool +function _valid_tree_file(file::AbstractString)::Bool _valid_name(file) && any(ext -> endswith(lowercase(file), ext), _TREE_EXTENSIONS) end From e521e72bc030c73735083c4688012fe57f3f0b7e Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:43:57 +0000 Subject: [PATCH 06/13] Adopt strict I-JSON and JCS at first-party JSON boundaries Add raw-token validation and canonical Julia responses/storage for selected first-party documents, canonical browser API request encoding, and document the staged migration and untouched external/hash formats. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 2 +- VALIDATED_STATISTICS.md | 2 + docs/ijson-jcs.md | 17 +++ frontend/public/config.json | 4 +- frontend/src/api/client.ts | 9 +- frontend/src/api/ijsonJcs.test.ts | 30 +++++ frontend/src/api/ijsonJcs.ts | 62 ++++++++++ src/analysis/derived_analyses.jl | 6 +- src/canonical.jl | 136 +++++++++++++++++++++- src/server/routes/figures.jl | 16 +-- src/server/routes/report.jl | 18 +-- src/server/routes/trees.jl | 18 +-- src/server/routes/validated_statistics.jl | 10 +- src/server/server.jl | 7 +- test/unit/test_canonical.jl | 24 +++- test/unit/test_derived_analyses.jl | 5 + test/unit/test_report_funnel.jl | 8 ++ test/unit/test_trees.jl | 3 + test/unit/test_validated_statistics.jl | 9 +- 19 files changed, 335 insertions(+), 51 deletions(-) create mode 100644 docs/ijson-jcs.md create mode 100644 frontend/src/api/ijsonJcs.test.ts create mode 100644 frontend/src/api/ijsonJcs.ts diff --git a/README.md b/README.md index 6813264c..82f8abf7 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Once a run completes, analysis is performed on request through the web UI, both Counts may be normalised before analysis (none, rarefaction to a fixed or auto-resolved depth, or scaling with ranked subsampling (SRS), which reaches the depth by scaling counts and keeps more of the community structure), and contamination-flagged taxa may be included or excluded. Ordinations can also apply a Hellinger transform before the Bray-Curtis dissimilarity. Composition charts can be drawn as a grid faceted on any two of run, group and sub-group. All analysis charts are returned as Plotly JSON and rendered interactively in the browser. -Opt-in exact-count and high-precision descriptive summaries are available through a separate validated API without changing these ordinary workflows. Their numeric, zero-depth, DuckDB, persistence, and reproducibility contracts are documented in [Validated descriptive statistics](VALIDATED_STATISTICS.md). +Opt-in exact-count and high-precision descriptive summaries are available through a separate validated API without changing these ordinary workflows. Their numeric, zero-depth, DuckDB, persistence, and reproducibility contracts are documented in [Validated descriptive statistics](VALIDATED_STATISTICS.md). The staged first-party JSON migration toward the [I-JSON/JCS estate standard](docs/ijson-jcs.md) records its scope and compatibility exclusions separately. ## Prerequisites diff --git a/VALIDATED_STATISTICS.md b/VALIDATED_STATISTICS.md index fc8d9cfe..c166dd5b 100644 --- a/VALIDATED_STATISTICS.md +++ b/VALIDATED_STATISTICS.md @@ -101,6 +101,8 @@ input matrix as strings, making the direct derived record self-contained. - Stored approximate `BigFloat` values use a decimal string that can be parsed directly at the recorded precision without an intermediate `Float64`. +Validated-statistics requests are also checked against the strict I-JSON profile on the raw request bytes (duplicate keys, unsafe integer tokens and invalid Unicode are rejected with HTTP 400). Float-form count tokens still fail with HTTP 422. Responses and persisted derived records use JCS bytes; see [the migration notes](docs/ijson-jcs.md). + The request body is limited to 16 MiB and a validated matrix to 100,000 cells. Other reviewed limits are defined in `src/analysis/numeric_policy.jl`; exceeding one returns an explicit resource error rather than a float fallback. diff --git a/docs/ijson-jcs.md b/docs/ijson-jcs.md new file mode 100644 index 00000000..6ee08e2c --- /dev/null +++ b/docs/ijson-jcs.md @@ -0,0 +1,17 @@ +# I-JSON/JCS migration (D306) + +The reference is [hyperpolymath/ijson-jcs](https://github.com/hyperpolymath/ijson-jcs) (reviewed at `3a9a4ee`). I-JSON (RFC 7493) is an **input/value profile**, not a different JSON object schema; JCS (RFC 8785) is its deterministic **byte encoding**. This migration is incremental: do not relabel ordinary JSON as canonical or silently recompute old IDs. + +## First-party boundary implemented here + +- Julia `MetaManifold.Canonical.ijson_read(raw)` validates UTF-8, repeated object keys (including escaped aliases at every depth), lone surrogates/noncharacters, non-finite numbers and integer tokens outside ยฑ(2^53โˆ’1) **on the raw text**. It returns ordinary JSON3 values. `jcs_bytes` validates emitted values (including noncharacters) and sorts keys by UTF-16 code units, formats ECMAScript numbers and emits no newline. The existing `jcs_sha256` hashes those bytes. +- Validated-statistics request bodies and their responses, error responses, derived-analysis records, figure documents, tree **view-state sidecars**, and report basket indices use those helpers. Stored derived analyses must be canonical on read; existing correctly persisted JCS records are unchanged. Invalid first-party requests get 400 (the exact-count endpoint still rejects floating *count tokens* with 422). +- The frontend's first-party API POST/PATCH/PUT payloads use `ijsonJcsStringify`, which sorts object keys by UTF-16, preserves array order and refuses unsafe JS numbers, non-finite values, lone surrogates, noncharacters and sparse arrays. Undefined optional object properties are omitted as before. Exact large counts remain decimal **strings**. `frontend/public/config.json` is canonical (plus the optional one-file newline). + +Use the upstream `ijson-jcs check frontend/public/config.json` to verify the checked-in config if the Rust CLI is installed. The Julia writer has its own JCS fixture tests, but cross-language equivalence with the Rust binary has **not** been independently verified here. + +## Boundaries not yet migrated + +Other Oxygen `json(...)` route responses, legacy JSON3 inputs/outputs, pipeline job/QC JSON, DOI receipts and epistemic receipt hashes still use their old serialization. The latter two are part of existing checksums/IDs: switching their byte encoding without an explicit versioned migration would make previously stored receipts unverifiable. External JSON (notably jplace, Plotly, renv metadata and imported report `.json` files) must not be rewritten just because its name ends in `.json`; the owning format and compatibility contract take precedence. Existing figure/view/index documents with merely different whitespace or key order still read; they are written canonically on their next save. Documents with duplicate keys or unsafe values need explicit migration. + +Follow-up: audit all remaining first-party route JSON with representative large result sets and tests; decide whether oversized legacy numeric fields become strings and how existing persisted records migrate, then replace their serializers and add an upstream CLI interoperability check to CI. Do **not** automatically JCS-encode opaque upstream/third-party formats or change an established content hash without versioning. diff --git a/frontend/public/config.json b/frontend/public/config.json index 3077b067..e572abe8 100644 --- a/frontend/public/config.json +++ b/frontend/public/config.json @@ -1,3 +1 @@ -{ - "apiBase": "" -} +{"apiBase":""} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cb9a9ccb..f1325275 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -28,6 +28,7 @@ import type { import type { FigureDoc } from '../figure/types' import { saveBlob } from '../utils/download' +import { ijsonJcsStringify } from './ijsonJcs' // Base URL for the backend API. Empty string means same-origin. // Set "apiBase" in config.json (e.g. "https://bioserver:8080") for split deployments. @@ -55,7 +56,7 @@ async function rawPost(path: string, body: unknown): Promise { const res = await fetch(apiUrl(path), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + body: ijsonJcsStringify(body), }) if (!res.ok) { const err: ApiError = await res.json().catch(() => ({ @@ -83,9 +84,9 @@ async function request(path: string, init?: RequestInit): Promise { } const get = (path: string) => request(path) -const post = (path: string, body?: unknown) => request(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }) -const patch = (path: string, body?: unknown) => request(path, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }) -const put = (path: string, body?: unknown) => request(path, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }) +const post = (path: string, body?: unknown) => request(path, { method: 'POST', body: body !== undefined ? ijsonJcsStringify(body) : undefined }) +const patch = (path: string, body?: unknown) => request(path, { method: 'PATCH', body: body !== undefined ? ijsonJcsStringify(body) : undefined }) +const put = (path: string, body?: unknown) => request(path, { method: 'PUT', body: body !== undefined ? ijsonJcsStringify(body) : undefined }) const del = (path: string) => request(path, { method: 'DELETE' }) /** PUT a text body; the response is JSON. */ diff --git a/frontend/src/api/ijsonJcs.test.ts b/frontend/src/api/ijsonJcs.test.ts new file mode 100644 index 00000000..b33184f5 --- /dev/null +++ b/frontend/src/api/ijsonJcs.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'bun:test' +import { ijsonJcsStringify as jcs } from './ijsonJcs' + +test('JCS sorts by UTF-16 code units recursively and preserves array order', () => { + expect(jcs({ b: 1, a: { z: 1, x: [3, 2] }, '๏ผก': 4, '๐Ÿ˜€': 5 })) + .toBe('{"a":{"x":[3,2],"z":1},"b":1,"๐Ÿ˜€":5,"๏ผก":4}') + // Numeric-like keys are sorted too, unlike JSON.stringify's object enumeration. + expect(jcs({ '10': 1, '2': 2 })).toBe('{"10":1,"2":2}') + expect(jcs({ optional: undefined, exact: '9007199254740992' })).toBe('{"exact":"9007199254740992"}') +}) + +test('ECMAScript number formatting and escaped controls match JCS', () => { + expect(jcs({ a: -0, b: 1e-7, c: 1e-6, d: '\n' })) + .toBe('{"a":0,"b":1e-7,"c":0.000001,"d":"\\n"}') +}) + +test('I-JSON rejects unsafe and non-finite numbers, noncharacters and lone surrogates', () => { + for (const value of [Number.MAX_SAFE_INTEGER + 1, NaN, Infinity, -Infinity]) { + expect(() => jcs({ count: value })).toThrow() + } + for (const text of ['\ud800', '\udc00', '\ufdd0', '\uffff', '\u{1ffff}']) { + expect(() => jcs({ text })).toThrow() + expect(() => jcs({ [text]: 1 })).toThrow() + } + expect(() => jcs({ a: [undefined] })).toThrow() + expect(() => jcs(new Array(1))).toThrow() + const cycle: { self?: unknown } = {} + cycle.self = cycle + expect(() => jcs(cycle)).toThrow() +}) diff --git a/frontend/src/api/ijsonJcs.ts b/frontend/src/api/ijsonJcs.ts new file mode 100644 index 00000000..9d31d566 --- /dev/null +++ b/frontend/src/api/ijsonJcs.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// First-party API JSON: I-JSON values in RFC 8785 canonical form. JS string +// ordering is UTF-16 code-unit ordering, as required by JCS (unlike UTF-8). +// This deliberately doesn't canonicalise external jplace/Plotly documents. + +function validString(value: string): void { + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i) + if (unit >= 0xd800 && unit <= 0xdbff) { + const low = value.charCodeAt(++i) + if (!(low >= 0xdc00 && low <= 0xdfff)) throw new TypeError('I-JSON refuses unpaired surrogates') + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new TypeError('I-JSON refuses unpaired surrogates') + } else if ((unit >= 0xfdd0 && unit <= 0xfdef) || unit === 0xfffe || unit === 0xffff) { + throw new TypeError('I-JSON refuses Unicode noncharacters') + } + } + for (const ch of value) { + const code = ch.codePointAt(0)! + if ((code & 0xfffe) === 0xfffe) throw new TypeError('I-JSON refuses Unicode noncharacters') + } +} + +/** Canonicalise a JSON-shaped API payload; reject values that cannot round-trip. */ +export function ijsonJcsStringify(value: unknown): string { + const active = new Set() + function encode(input: unknown): string { + if (input === null) return 'null' + if (typeof input === 'string') { validString(input); return JSON.stringify(input) } + if (typeof input === 'boolean') return String(input) + if (typeof input === 'number') { + if (!Number.isFinite(input) || (Number.isInteger(input) && !Number.isSafeInteger(input))) { + throw new TypeError('I-JSON refuses non-finite or unsafe numbers; use a decimal string for exact counts') + } + return JSON.stringify(input) + } + if (typeof input !== 'object') throw new TypeError(`Not an I-JSON value: ${typeof input}`) + if (active.has(input)) throw new TypeError('Cyclic JSON value') + if (!Array.isArray(input) && Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null) { + throw new TypeError('I-JSON objects must be plain objects') + } + active.add(input) + try { + if (Array.isArray(input)) { + return `[${Array.from({ length: input.length }, (_, i) => { + if (!(i in input)) throw new TypeError('I-JSON refuses sparse arrays') + return encode(input[i]) + }).join(',')}]` + } + const object = input as Record + // Omit undefined optional properties just as JSON.stringify does, but + // never convert an undefined array element into null silently. + return `{${Object.keys(object).sort().filter(key => object[key] !== undefined).map(key => { + validString(key) + return `${JSON.stringify(key)}:${encode(object[key])}` + }).join(',')}}` + } finally { + active.delete(input) + } + } + return encode(value) +} diff --git a/src/analysis/derived_analyses.jl b/src/analysis/derived_analyses.jl index 7d22a164..3ef09107 100644 --- a/src/analysis/derived_analyses.jl +++ b/src/analysis/derived_analyses.jl @@ -7,7 +7,7 @@ module DerivedAnalyses using JSON3 -using ..Canonical: jcs_bytes, jcs_sha256 +using ..Canonical: jcs_bytes, jcs_sha256, ijson_read export DerivedAnalysisError, derived_analysis_id, persist_derived_analysis, load_derived_analysis, MAX_DERIVED_BYTES @@ -43,13 +43,15 @@ function _validate_bytes(bytes::Vector{UInt8}) length(bytes) <= MAX_DERIVED_BYTES || throw(DerivedAnalysisError( "invalid_record", "derived analysis exceeds the $MAX_DERIVED_BYTES-byte read limit")) record = try - JSON3.read(bytes) + ijson_read(bytes) catch err throw(DerivedAnalysisError("invalid_record", "derived analysis is not valid JSON: $(sprint(showerror, err))")) end (record isa JSON3.Object || record isa AbstractDict) || throw(DerivedAnalysisError( "invalid_record", "derived analysis root must be a JSON object")) + jcs_bytes(record) == bytes || throw(DerivedAnalysisError( + "invalid_record", "derived analysis is not canonical I-JSON/JCS")) _get(record, "schema") == SCHEMA || throw(DerivedAnalysisError( "invalid_record", "derived analysis has an unsupported schema")) id = _get(record, "analysis_id") diff --git a/src/canonical.jl b/src/canonical.jl index 3da806a1..50d1442f 100644 --- a/src/canonical.jl +++ b/src/canonical.jl @@ -29,9 +29,9 @@ module Canonical -using SHA +using SHA, JSON3 -export jcs_bytes, jcs_sha256 +export jcs_bytes, jcs_sha256, ijson_read # The I-JSON exact-integer range. const SAFE_INT_MAX = 2^53 - 1 @@ -86,8 +86,18 @@ end _write!(io, ::AbstractFloat) = throw(ArgumentError( "canonical JSON supports only Float16, Float32 and Float64")) +# I-JSON disallows Unicode noncharacters (including the last two code points +# of every plane), whether they occur in values or property names. +function _valid_ijson_string(s::AbstractString) + isvalid(s) || return false + all(s) do c + cp = UInt32(c) + !(0xfdd0 <= cp <= 0xfdef || (cp & 0xfffe) == 0xfffe) + end +end + function _write!(io, s::AbstractString) - isvalid(s) || throw(ArgumentError("canonical JSON refuses invalid Unicode")) + _valid_ijson_string(s) || throw(ArgumentError("canonical JSON refuses invalid I-JSON Unicode")) print(io, '"') for c in s _write_char!(io, c) @@ -135,7 +145,7 @@ function _write!(io, d::AbstractDict) key isa Union{AbstractString,Symbol} || throw(ArgumentError( "canonical JSON object keys must be strings or symbols")) name = String(key) - isvalid(name) || throw(ArgumentError("canonical JSON refuses invalid Unicode keys")) + _valid_ijson_string(name) || throw(ArgumentError("canonical JSON refuses invalid I-JSON Unicode keys")) name in names && throw(ArgumentError("canonical JSON refuses duplicate property names")) push!(names, name) push!(entries, name => value) @@ -218,4 +228,122 @@ function _shortest_digits(x::Float64) digits, n end +""" + ijson_read(raw) -> JSON3.Object / JSON3.Array / scalar + +Parse a JSON document in the strict I-JSON mode of hyperpolymath/ijson-jcs. +Inspect the original tokens *before* relying on JSON3's parsed object: parsed +objects can lose duplicate names, and integral-looking floats/oversized integer +tokens can be normalised. The return value retains JSON3's usual types so +existing callers need not change their field access. +""" +function ijson_read(raw::Union{AbstractString,AbstractVector{UInt8}}) + text = raw isa AbstractString ? String(raw) : String(copy(raw)) + isvalid(text) || throw(ArgumentError("I-JSON requires UTF-8")) + # Let JSON3 enforce the RFC 8259 grammar (including trailing garbage). + parsed = try + JSON3.read(text) + catch err + throw(ArgumentError("invalid JSON: $(sprint(showerror, err))")) + end + bytes = codeunits(text) + n = length(bytes) + pos = Ref(1) + peek() = pos[] <= n ? bytes[pos[]] : 0x00 + function whitespace!() + while peek() in (0x20, 0x09, 0x0a, 0x0d) && pos[] <= n + pos[] += 1 + end + end + function string!() + start = pos[] + pos[] += 1 # opening quote + while pos[] <= n + c = bytes[pos[]] + pos[] += 1 + c == 0x22 && break + if c == 0x5c + # JSON3 checked the escape grammar; additionally reject lone + # surrogate escapes even if a parser replaces them with U+FFFD. + if bytes[pos[]] == 0x75 + high = parse(UInt32, String(bytes[pos[]+1:pos[]+4]); base=16) + if 0xd800 <= high <= 0xdbff + pos[] + 10 <= n && bytes[pos[]+5] == 0x5c && + bytes[pos[]+6] == 0x75 || throw(ArgumentError( + "I-JSON refuses an unpaired high surrogate")) + low = parse(UInt32, String(bytes[pos[]+7:pos[]+10]); base=16) + 0xdc00 <= low <= 0xdfff || throw(ArgumentError( + "I-JSON refuses an unpaired high surrogate")) + pos[] += 11 + continue + elseif 0xdc00 <= high <= 0xdfff + throw(ArgumentError("I-JSON refuses an unpaired low surrogate")) + end + pos[] += 5 + else + pos[] += 1 + end + end + end + value = String(JSON3.read(String(bytes[start:pos[]-1]))) + _valid_ijson_string(value) || throw(ArgumentError("I-JSON refuses Unicode noncharacters")) + value + end + function value!() + whitespace!() + if peek() == 0x7b # object + pos[] += 1 + whitespace!() + seen = Set{String}() + while peek() != 0x7d + key = string!() + key in seen && throw(ArgumentError("I-JSON refuses duplicate object key $(repr(key))")) + push!(seen, key) + whitespace!() + pos[] += 1 # colon (JSON3 already verified the grammar) + value!() + whitespace!() + peek() == 0x2c || break + pos[] += 1 + whitespace!() + end + pos[] += 1 # closing brace + elseif peek() == 0x5b # array + pos[] += 1 + whitespace!() + while peek() != 0x5d + value!() + whitespace!() + peek() == 0x2c || break + pos[] += 1 + whitespace!() + end + pos[] += 1 # closing bracket + elseif peek() == 0x22 + string!() + else + start = pos[] + while pos[] <= n && !(peek() in (0x2c, 0x5d, 0x7d, 0x20, 0x09, 0x0a, 0x0d)) + pos[] += 1 + end + token = String(bytes[start:pos[]-1]) + if occursin(r"^-?[0-9]", token) + if occursin(r"^-?(0|[1-9][0-9]*)$", token) + # Bound the token before parsing: an HTTP body could contain + # millions of digits, but a safe integer has at most 16. + length(token) <= 17 && + -SAFE_INT_MAX <= parse(BigInt, token) <= SAFE_INT_MAX || + throw(ArgumentError("I-JSON refuses an unsafe integer token $token")) + else + x = tryparse(Float64, token) + (!isnothing(x) && isfinite(x)) || throw(ArgumentError( + "I-JSON refuses a non-finite number $token")) + end + end + end + end + value!() + parsed +end + end # module diff --git a/src/server/routes/figures.jl b/src/server/routes/figures.jl index 94aeb804..3808ebdf 100644 --- a/src/server/routes/figures.jl +++ b/src/server/routes/figures.jl @@ -26,21 +26,21 @@ function _figure_request_error(study::String, id::Union{String,Nothing}=nothing) end function _read_figure(study::String, id::String) - try JSON3.read(read(_figure_path(study, id), String)) catch; nothing end + try ijson_read(read(_figure_path(study, id))) catch; nothing end end @get "/api/v1/studies/{study}/figures" function(req, study::String) err = _figure_request_error(study) isnothing(err) || return err dir = _figures_dir(study) - isdir(dir) || return json([]) + isdir(dir) || return jcs_json([]) ids = [splitext(f)[1] for f in readdir(dir) if endswith(f, ".json") && _valid_figure_id(splitext(f)[1])] list = map(ids) do id doc = _read_figure(study, id) (; id, title = doc isa JSON3.Object ? string(get(doc, :title, id)) : id, modified = string(unix2datetime(mtime(_figure_path(study, id))))) end - json(sort(list; by = x -> x.modified, rev=true)) + jcs_json(sort(list; by = x -> x.modified, rev=true)) end @get "/api/v1/studies/{study}/figures/{id}" function(req, study::String, id::String) @@ -64,12 +64,12 @@ end function _save_figure(req, study::String, id::String) length(req.body) <= _FIGURE_MAX_BYTES || return json_error(413, "too_large", "Figure documents are limited to 2 MB") - doc = try JSON3.read(String(req.body)) catch; nothing end + doc = try ijson_read(req.body) catch; nothing end doc isa JSON3.Object || return json_error(400, "invalid_body", "Body must be a JSON object") out = Dict{String,Any}(String(k) => v for (k, v) in doc) out["id"] = id - _write_atomic(_figure_path(study, id), JSON3.write(out)) - json(out) + _write_atomic(_figure_path(study, id), String(jcs_bytes(out))) + jcs_json(out) end # Rendered copies are named after the figure's title and end in _{id}.png. @@ -92,7 +92,7 @@ _figure_rasters(study::String, id::String) = write(tmp, bytes) foreach(f -> f == path || rm(f; force=true), _figure_rasters(study, id)) mv(tmp, path; force=true) - json((; file=basename(path))) + jcs_json((; file=basename(path))) end @delete "/api/v1/studies/{study}/figures/{id}" function(req, study::String, id::String) @@ -100,5 +100,5 @@ end isnothing(err) || return err foreach(f -> rm(f; force=true), _figure_rasters(study, id)) rm(_figure_path(study, id); force=true) - json((; deleted=id)) + jcs_json((; deleted=id)) end diff --git a/src/server/routes/report.jl b/src/server/routes/report.jl index 73ac45a6..4707f88c 100644 --- a/src/server/routes/report.jl +++ b/src/server/routes/report.jl @@ -19,14 +19,14 @@ _report_index(study::String) = joinpath(_report_dir(study), "items.json") function _report_items(study::String)::Vector{Dict{String,Any}} path = _report_index(study) isfile(path) || return Dict{String,Any}[] - raw = try JSON3.read(read(path, String)) catch; return Dict{String,Any}[] end + raw = try ijson_read(read(path)) catch; return Dict{String,Any}[] end [Dict{String,Any}(String(k) => v for (k, v) in item) for item in raw] end function _save_report_items(study::String, items) mkpath(_report_dir(study)) tmp = _report_index(study) * ".tmp" - write(tmp, JSON3.write(items)) + write(tmp, jcs_bytes(items)) mv(tmp, _report_index(study); force=true) end @@ -50,7 +50,7 @@ end @get "/api/v1/studies/{study}/report" function(req, study::String) err = _report_study_error(study) isnothing(err) || return err - json(_report_items(study)) + jcs_json(_report_items(study)) end # The file is the raw request body; kind, title and extension come as query parameters. @@ -75,13 +75,13 @@ end item = Dict{String,Any}("id" => id, "kind" => kind, "title" => title, "file" => file, "added" => string(now(UTC))) _report_edit(items -> push!(items, item), study) - json(item) + jcs_json(item) end @patch "/api/v1/studies/{study}/report/{id}" function(req, study::String, id::String) err = _report_study_error(study) isnothing(err) || return err - body = try JSON3.read(String(req.body)) catch; nothing end + body = try ijson_read(req.body) catch; nothing end title = body isa JSON3.Object ? get(body, :title, nothing) : nothing title isa AbstractString && !isempty(strip(title)) || return json_error(400, "invalid_title", "Body must include a non-empty 'title'") @@ -91,13 +91,13 @@ end items[i]["title"] = strip(title) items[i] end - isnothing(found) ? json_error(404, "not_found", "No report item '$id'") : json(found) + isnothing(found) ? json_error(404, "not_found", "No report item '$id'") : jcs_json(found) end @put "/api/v1/studies/{study}/report/order" function(req, study::String) err = _report_study_error(study) isnothing(err) || return err - body = try JSON3.read(String(req.body)) catch; nothing end + body = try ijson_read(req.body) catch; nothing end ids = body isa JSON3.Object ? get(body, :ids, nothing) : nothing ids isa AbstractVector || return json_error(400, "invalid_body", "Body must include 'ids'") ordered = _report_edit(study) do items @@ -105,7 +105,7 @@ end sort!(items; by = it -> get(rank, it["id"], typemax(Int))) copy(items) end - json(ordered) + jcs_json(ordered) end @delete "/api/v1/studies/{study}/report/{id}" function(req, study::String, id::String) @@ -118,7 +118,7 @@ end end isnothing(removed) && return json_error(404, "not_found", "No report item '$id'") rm(joinpath(_report_dir(study), removed["file"]); force=true) - json((; deleted=id)) + jcs_json((; deleted=id)) end const _REPORT_MIME = Dict(".svg" => "image/svg+xml", ".png" => "image/png", ".json" => "application/json", diff --git a/src/server/routes/trees.jl b/src/server/routes/trees.jl index beec713a..bc5f275e 100644 --- a/src/server/routes/trees.jl +++ b/src/server/routes/trees.jl @@ -81,7 +81,7 @@ end @get "/api/v1/studies/{study}/trees" function(req, study::String) err = _tree_request_error(study) isnothing(err) || return err - json(_tree_listing(study)) + jcs_json(_tree_listing(study)) end @get "/api/v1/studies/{study}/trees/{file}" function(req, study::String, file::String) @@ -91,8 +91,8 @@ end isfile(path) || return json_error(404, "not_found", "Tree '$file' not found") content = read(path, String) vpath = _tree_view_path(study, file) - view = isfile(vpath) ? try JSON3.read(read(vpath, String)) catch; nothing end : nothing - json((; file, + view = isfile(vpath) ? try ijson_read(read(vpath)) catch; nothing end : nothing + jcs_json((; file, format = _tree_format(file), sha256 = bytes2hex(sha256(content)), modified = string(unix2datetime(stat(path).mtime)), @@ -103,7 +103,7 @@ end @post "/api/v1/studies/{study}/trees" function(req, study::String) err = _tree_request_error(study) isnothing(err) || return err - body = try JSON3.read(String(req.body)) catch + body = try ijson_read(req.body) catch return json_error(400, "invalid_body", "Expected a JSON body") end file = string(get(body, :file, "")) @@ -119,7 +119,7 @@ end return json_error(409, "exists", "Tree '$file' already exists") end _write_atomic(path, String(content)) - json((; file, format = _tree_format(file))) + jcs_json((; file, format = _tree_format(file))) end @put "/api/v1/studies/{study}/trees/{file}/view" function(req, study::String, file::String) @@ -129,12 +129,12 @@ end return json_error(404, "not_found", "Tree '$file' not found") text = String(req.body) sizeof(text) <= _VIEW_MAX_BYTES || return json_error(413, "too_large", "View state is limited to 5 MB") - doc = try JSON3.read(text) catch + doc = try ijson_read(text) catch return json_error(400, "invalid_body", "Expected a JSON object") end doc isa JSON3.Object || return json_error(400, "invalid_body", "Expected a JSON object") - _write_atomic(_tree_view_path(study, file), text) - json((; saved = true)) + _write_atomic(_tree_view_path(study, file), String(jcs_bytes(doc))) + jcs_json((; saved = true)) end @delete "/api/v1/studies/{study}/trees/{file}" function(req, study::String, file::String) @@ -145,5 +145,5 @@ end rm(path) vpath = _tree_view_path(study, file) isfile(vpath) && rm(vpath) - json((; deleted = file)) + jcs_json((; deleted = file)) end diff --git a/src/server/routes/validated_statistics.jl b/src/server/routes/validated_statistics.jl index e6cf4ffd..c6686674 100644 --- a/src/server/routes/validated_statistics.jl +++ b/src/server/routes/validated_statistics.jl @@ -15,9 +15,7 @@ const _DUCKDB_INTEGER_TYPES = Set([ "TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", ]) -function _statistics_json(payload; status::Int=200) - HTTP.Response(status, ["Content-Type" => "application/json"], body=JSON3.write(payload)) -end +_statistics_json(payload; status::Int=200) = jcs_json(payload; status) function _statistics_guard(f::Function) try @@ -41,11 +39,7 @@ end function _statistics_body(req) length(req.body) <= _STATISTICS_REQUEST_LIMIT || throw(ResourceLimitError( "request bytes", _STATISTICS_REQUEST_LIMIT, length(req.body))) - parsed = try - JSON3.read(String(req.body)) - catch - throw(ArgumentError("request body is not valid JSON")) - end + parsed = ijson_read(req.body) parsed isa JSON3.Object || throw(ArgumentError("request body must be a JSON object")) parsed end diff --git a/src/server/server.jl b/src/server/server.jl index eca368a0..07c1b2d5 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -14,6 +14,7 @@ module Server using Oxygen, HTTP, JSON3, YAML, Logging using PrecompileTools: @setup_workload, @compile_workload + using ..Canonical: ijson_read, jcs_bytes using ..PipelineTypes, ..PipelineLog, ..Config using ..Databases, ..DuckDBStore, ..Validation using ..Tools, ..TaxonomyTableTools, ..ProjectSetup @@ -63,11 +64,15 @@ module Server using .JobQueue ## Shared helpers (available to all included route files) + function jcs_json(payload; status::Int=200) + HTTP.Response(status, ["Content-Type" => "application/json"], body=jcs_bytes(payload)) + end + function json_error(status::Int, code::String, message::String; detail=nothing) body = isnothing(detail) ? (; error=code, message) : (; error=code, message, detail) HTTP.Response(status, ["Content-Type" => "application/json"], - body = JSON3.write(body)) + body = jcs_bytes(body)) end ## Route registration diff --git a/test/unit/test_canonical.jl b/test/unit/test_canonical.jl index 064317f7..9cb037eb 100644 --- a/test/unit/test_canonical.jl +++ b/test/unit/test_canonical.jl @@ -5,7 +5,7 @@ # ECMAScript JSON.stringify and UTF-16 key order. Rust fixture generation # provenance has not been reproduced in this audit. using MetaManifold -using MetaManifold.Canonical: jcs_bytes, jcs_sha256 +using MetaManifold.Canonical: jcs_bytes, jcs_sha256, ijson_read using JSON3, SHA, Test @testset "canonical JSON (JCS)" begin @@ -96,3 +96,25 @@ end cases = readlines(joinpath(@__DIR__, "..", "fixtures", "jcs_oracle.jsonl")) @test JSON3.read(cases[5]).input[2] isa AbstractFloat end + +@testset "strict I-JSON input and JCS output (ijson-jcs)" begin + for input in ("{\"a\":1,\"a\":2}", + "{\"a\":1,\"\\u0061\":2}", + "{\"nested\":[{\"b\":0,\"b\":1}]}", + "{\"n\":9007199254740992}", + "[-9007199254740992]", "[1e400]", "{\"key\":\"\\ud800\"}", + "{\"key\":\"\\udc00\"}", + "{\"\\ufdd0\":0}", "[\"\\uFFFF\"]") + @test_throws ArgumentError ijson_read(input) + end + @test_throws ArgumentError ijson_read(UInt8[0xff]) + @test_throws ArgumentError jcs_bytes(Dict("bad" => "\ufdd0")) + @test_throws ArgumentError jcs_bytes(Dict("\U0001ffff" => 1)) + for input in ("{\"a\":1,\"b\":[true,null]}", + "{\"emoji\":\"\\ud83d\\ude00\"}", + "{\"exact\":\"9007199254740992\"}", + "{\"float\":1e-7}") + @test ijson_read(String(jcs_bytes(ijson_read(input)))) isa JSON3.Object + end + @test String(jcs_bytes(ijson_read("{\"b\":2,\"a\":1}"))) == "{\"a\":1,\"b\":2}" +end diff --git a/test/unit/test_derived_analyses.jl b/test/unit/test_derived_analyses.jl index 1f2a2bd8..096a42d2 100644 --- a/test/unit/test_derived_analyses.jl +++ b/test/unit/test_derived_analyses.jl @@ -74,5 +74,10 @@ end # The verified original is still available after the failed conflicting use. @test load_derived_analysis(directory, id)["analysis_id"] == id @test object["analysis_id"] == id + + # A record with the right semantic ID but noncanonical bytes is not a + # valid content-addressed I-JSON/JCS document. + write(joinpath(tampered, "$id.json"), vcat(UInt8[0x20], read(saved.path))) + @test_throws DerivedAnalysisError load_derived_analysis(tampered, id) end end diff --git a/test/unit/test_report_funnel.jl b/test/unit/test_report_funnel.jl index 6dc69c44..b6049e8b 100644 --- a/test/unit/test_report_funnel.jl +++ b/test/unit/test_report_funnel.jl @@ -3,6 +3,7 @@ using MetaManifold SV = MetaManifold.Server using JSON3, YAML +using MetaManifold.Canonical: jcs_bytes _rf_request(method, path, body="", headers=["Content-Type" => "application/json"]) = SV.Oxygen.internalrequest(SV.HTTP.Request(method, path, headers, body)) @@ -22,6 +23,9 @@ _rf_request(method, path, body="", headers=["Content-Type" => "application/json" ["Content-Type" => "application/octet-stream"]) @test r.status == 200 a = JSON3.read(String(r.body)) + @test r.body == jcs_bytes(a) + @test read(joinpath(tmp, "projects", "StudyR", "report", "items.json")) == + jcs_bytes([a]) r = _rf_request("POST", "$base?kind=table&title=Tables&ext=.csv", "a,b\n1,2\n", ["Content-Type" => "application/octet-stream"]) b = JSON3.read(String(r.body)) @@ -132,11 +136,15 @@ end @test isempty(JSON3.read(String(_rf_request("GET", base).body))) r = _rf_request("POST", base, JSON3.write(Dict("title" => "Fig 1", "groups" => []))) @test r.status == 200 + @test r.body == jcs_bytes(JSON3.read(String(r.body))) id = JSON3.read(String(r.body)).id @test occursin(r"^[0-9a-f]{8}$", id) r = _rf_request("PUT", "$base/$id", JSON3.write(Dict("id" => "ffffffff", "title" => "Renamed"))) @test JSON3.read(String(r.body)).id == id @test JSON3.read(String(_rf_request("GET", "$base/$id").body)).title == "Renamed" + @test read(joinpath(tmp, "projects", "StudyF", "figures", "$id.json")) == + jcs_bytes(JSON3.read(String(_rf_request("GET", "$base/$id").body))) + @test _rf_request("PUT", "$base/$id", "{\"title\":\"x\",\"title\":\"y\"}").status == 400 @test only(JSON3.read(String(_rf_request("GET", base).body))).title == "Renamed" @test _rf_request("PUT", "$base/$id", "[1,2]").status == 400 @test _rf_request("GET", "$base/nothex12").status == 400 diff --git a/test/unit/test_trees.jl b/test/unit/test_trees.jl index 61428469..89053ccc 100644 --- a/test/unit/test_trees.jl +++ b/test/unit/test_trees.jl @@ -2,6 +2,7 @@ using MetaManifold SV = MetaManifold.Server using JSON3 +using MetaManifold.Canonical: jcs_bytes function _tr_request(method::String, path::String, body=nothing) payload = isnothing(body) ? "" : body isa AbstractString ? body : JSON3.write(body) @@ -50,6 +51,8 @@ _tr_json(res) = JSON3.read(String(res.body)) view = Dict("version" => 1, "ops" => [Dict("op" => "collapse", "clade" => "abc")]) @test _tr_request("PUT", "$base/t.jplace/view", view).status == 200 + @test read(joinpath(tmp, "projects", "StudyT", "trees", ".views", "t.jplace.json")) == jcs_bytes(view) + @test _tr_request("PUT", "$base/t.jplace/view", "{\"ops\":[],\"ops\":[]}").status == 400 @test _tr_request("PUT", "$base/t.jplace/view", "[1,2]").status == 400 @test _tr_request("PUT", "$base/missing.nwk/view", view).status == 404 got = _tr_json(_tr_request("GET", "$base/t.jplace")) diff --git a/test/unit/test_validated_statistics.jl b/test/unit/test_validated_statistics.jl index bea38a57..105f17c9 100644 --- a/test/unit/test_validated_statistics.jl +++ b/test/unit/test_validated_statistics.jl @@ -92,7 +92,7 @@ end @testset "Numeric JSON representations fail closed" begin _vs_with_root() do _ - for bad in (1.5, -1, 9007199254740993) + for bad in (1.5, -1) payload = merge(_VS_EXACT_BODY, Dict("persist" => false, "counts" => [[bad, 1], [1, 0], [1, 0]])) response = _vs_post("/api/v1/statistics/summaries", payload) @@ -110,6 +110,13 @@ end # Fractional input stays invalid as well, even when it is nearly 1. fractional = """{"numeric_policy":{"mode":"exact_counts"},"sample_labels":["A"],"feature_labels":["f"],"counts":[[1.0000000001]]}""" @test _vs_post("/api/v1/statistics/summaries", fractional; raw=true).status == 422 + # Strict I-JSON is checked on the raw body, before JSON3 can collapse + # duplicate keys or normalise an oversized integer token. Unlike a + # negative count, an oversized JSON number is invalid at parse time. + for raw in ("""{"numeric_policy":{"mode":"exact_counts","mode":"high_precision"},"sample_labels":["A"],"feature_labels":["f"],"counts":[[1]]}""", + """{"numeric_policy":{"mode":"exact_counts"},"sample_labels":["A"],"feature_labels":["f"],"counts":[[9007199254740992]]}""") + @test _vs_post("/api/v1/statistics/summaries", raw; raw=true).status == 400 + end negative_string = merge(_VS_EXACT_BODY, Dict("persist" => false, "counts" => [["-1", 1], [1, 0], [1, 0]])) @test _vs_post("/api/v1/statistics/summaries", negative_string).status == 422 From 46e90eb3ee5a53d2227ad74cc10c35bbdd9556ad Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:57:29 +0000 Subject: [PATCH 07/13] Fix CI regression test and SonarCloud JCS findings Use the current content-addressed record in the tamper test, validate UTF-16 via codePointAt, and supply an explicit UTF-16 key comparator instead of locale-dependent collation. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- frontend/src/api/ijsonJcs.ts | 20 ++++++++------------ src/analysis/clade_cumulus.jl | 2 -- src/server/routes/clade_cumulus.jl | 1 - test/unit/test_derived_analyses.jl | 2 +- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/frontend/src/api/ijsonJcs.ts b/frontend/src/api/ijsonJcs.ts index 9d31d566..e2c39463 100644 --- a/frontend/src/api/ijsonJcs.ts +++ b/frontend/src/api/ijsonJcs.ts @@ -5,22 +5,18 @@ function validString(value: string): void { for (let i = 0; i < value.length; i++) { - const unit = value.charCodeAt(i) - if (unit >= 0xd800 && unit <= 0xdbff) { - const low = value.charCodeAt(++i) - if (!(low >= 0xdc00 && low <= 0xdfff)) throw new TypeError('I-JSON refuses unpaired surrogates') - } else if (unit >= 0xdc00 && unit <= 0xdfff) { - throw new TypeError('I-JSON refuses unpaired surrogates') - } else if ((unit >= 0xfdd0 && unit <= 0xfdef) || unit === 0xfffe || unit === 0xffff) { + const code = value.codePointAt(i)! + if (code > 0xffff) i++ // a valid UTF-16 surrogate pair + else if (code >= 0xd800 && code <= 0xdfff) throw new TypeError('I-JSON refuses unpaired surrogates') + if ((code >= 0xfdd0 && code <= 0xfdef) || (code & 0xfffe) === 0xfffe) { throw new TypeError('I-JSON refuses Unicode noncharacters') } } - for (const ch of value) { - const code = ch.codePointAt(0)! - if ((code & 0xfffe) === 0xfffe) throw new TypeError('I-JSON refuses Unicode noncharacters') - } } +// JCS sorts UTF-16 code units, NOT human-language collation (localeCompare). +const compareUtf16 = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0 + /** Canonicalise a JSON-shaped API payload; reject values that cannot round-trip. */ export function ijsonJcsStringify(value: unknown): string { const active = new Set() @@ -50,7 +46,7 @@ export function ijsonJcsStringify(value: unknown): string { const object = input as Record // Omit undefined optional properties just as JSON.stringify does, but // never convert an undefined array element into null silently. - return `{${Object.keys(object).sort().filter(key => object[key] !== undefined).map(key => { + return `{${Object.keys(object).sort(compareUtf16).filter(key => object[key] !== undefined).map(key => { validString(key) return `${JSON.stringify(key)}:${encode(object[key])}` }).join(',')}}` diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index 94edfaf0..1c30f7b7 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -269,7 +269,6 @@ function parse_newick(src::AbstractString; warnings::Vector{String} = String[]): nhx_sup = Ref{Union{Float64,Nothing}}(nothing) edge_num = Ref{Union{Int,Nothing}}(nothing) raw_lbl, was_quoted = read_label_and_quoted(capture_nhx = nhx_sup, capture_edge = edge_num) - skip_ws_and_leading_comments!(capture_nhx = nhx_sup, capture_edge = edge_num) branch_len = 0.0 @@ -1022,7 +1021,6 @@ function _graft_jplace_queries( query_lwr[tip_id] = cand.like_weight push!(get!(by_edge, cand.edge_num, _GraftedQuerySpec[]), _GraftedQuerySpec(tip_id, qname, share, max(0.0, cand.distal_len), max(0.0, cand.pendant_len), cand.like_weight)) - end end end diff --git a/src/server/routes/clade_cumulus.jl b/src/server/routes/clade_cumulus.jl index d4c6484a..0dd6918d 100644 --- a/src/server/routes/clade_cumulus.jl +++ b/src/server/routes/clade_cumulus.jl @@ -69,7 +69,6 @@ function _load_run_table_df(study::AbstractString, run::AbstractString, table::A # String-typed, so normalise once at this boundary. study = String(study); run = String(run); table = String(table) group = isnothing(group) ? nothing : String(group) - dir = _require_duckdb(study, run; group) isnothing(dir) && return (nothing, nothing, json_error(404, "no_results", "No results for run '$run' - run the pipeline first")) diff --git a/test/unit/test_derived_analyses.jl b/test/unit/test_derived_analyses.jl index 096a42d2..dc06d3a3 100644 --- a/test/unit/test_derived_analyses.jl +++ b/test/unit/test_derived_analyses.jl @@ -77,7 +77,7 @@ end # A record with the right semantic ID but noncanonical bytes is not a # valid content-addressed I-JSON/JCS document. - write(joinpath(tampered, "$id.json"), vcat(UInt8[0x20], read(saved.path))) + write(joinpath(tampered, "$id.json"), vcat(UInt8[0x20], read(joinpath(directory, "$id.json")))) @test_throws DerivedAnalysisError load_derived_analysis(tampered, id) end end From 9553f4630cd39fe1703fd941fc3a689dbbe2c92c Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:14:59 +0000 Subject: [PATCH 08/13] Repair jplace edge grafting and full-suite CladeCumulus assertions Read PhyloNode.edge_number during graft traversal, isolate the CladeCumulus test's server alias, and assert the counterfactual subtree PD recomputation instead of assuming PD always increases. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/clade_cumulus.jl | 6 +++--- test/unit/test_clade_cumulus.jl | 35 +++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index 1c30f7b7..ea2971ee 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -1030,8 +1030,8 @@ function _graft_jplace_queries( function walk(u::PhyloNode)::PhyloNode rebuilt_kids = PhyloNode[walk(ch) for ch in u.children] - cur = PhyloNode(u.id, u.name, u.branch_length, u.support, u.edge_num, rebuilt_kids) - specs = u.edge_num === nothing ? nothing : get(by_edge, u.edge_num, nothing) + cur = PhyloNode(u.id, u.name, u.branch_length, u.support, u.edge_number, rebuilt_kids) + specs = u.edge_number === nothing ? nothing : get(by_edge, u.edge_number, nothing) (specs === nothing || isempty(specs)) && return cur # Order grafts from most distal (closest to `cur`, smallest `distal`) to most proximal. @@ -1049,7 +1049,7 @@ function _graft_jplace_queries( sub = PhyloNode(string("jplace_clade#", seq_auto[]), "", 0.0, 1.0, nothing, PhyloNode[sub, qtip]) end rem_len = max(0.0, total_len - prev_distal) - PhyloNode(sub.id, sub.name, rem_len, cur.support, cur.edge_num, sub.children) + PhyloNode(sub.id, sub.name, rem_len, cur.support, cur.edge_number, sub.children) end walk(base_root) diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index 3caf480c..69cd7b39 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -14,12 +14,12 @@ const EP = MetaManifold.Epistemic const CC = MetaManifold.CladeCumulus const DM = MetaManifold.DiversityMetrics const PH = MetaManifold.Phylogeny -const SV = MetaManifold.Server +const SVCC = MetaManifold.Server _cc_req(method::String, path::String, body::String = "") = - SV.Oxygen.internalrequest( - SV.HTTP.Request(method, path, ["Host" => "127.0.0.1:8080", "Content-Type" => "application/json"], body); - middleware = SV._MIDDLEWARE, + SVCC.Oxygen.internalrequest( + SVCC.HTTP.Request(method, path, ["Host" => "127.0.0.1:8080", "Content-Type" => "application/json"], body); + middleware = SVCC._MIDDLEWARE, ) @testset "CladeCumulus & Phylogenetic Integration" begin @@ -289,8 +289,8 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 @testset "Dual-hierarchy CladeCumulus, polyphyly detection, drag-and-drop, and HTTP routes" begin tmp = mktempdir() - old_root = SV.ServerState._root[] - SV.ServerState.set_root!(tmp) + old_root = SVCC.ServerState._root[] + SVCC.ServerState.set_root!(tmp) try data_dir = joinpath(tmp, "data", "StudyCC", "run1") mkpath(data_dir) @@ -410,7 +410,26 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 @test v_allow.proposal_file !== nothing @test isfile(joinpath(trees_dir, ".clade_cumulus", String(v_allow.proposal_file))) @test v_allow.proposed_root.cumulative_count โ‰ˆ 110.0 atol = 1e-9 - @test Float64(v_allow.proposed_root.children[1].subtree_pd) > before_target_pd + # Subtree PD is the sum of active descendant branch lengths. It + # need not grow after reassignment: the moved branch can replace + # previously active topology rather than append to it. + function proposed_node(u, id) + u.id == id && return u + for child in u.children + found = proposed_node(child, id) + found !== nothing && return found + end + nothing + end + proposed_target = proposed_node(v_allow.proposed_root, high_clade.id) + @test proposed_target !== nothing + if proposed_target !== nothing + expected_pd = sum((max(0.0, Float64(ch.branch_length)) + Float64(ch.subtree_pd) + for ch in proposed_target.children if ch.cumulative_count > 0); + init=0.0) + @test Float64(proposed_target.subtree_pd) โ‰ˆ expected_pd atol = 1e-10 + @test Float64(proposed_target.subtree_pd) != before_target_pd + end # Re-saving under a different threshold writes a distinct immutable sidecar file r_allow2 = _cc_req("POST", "/api/v1/studies/StudyCC/runs/run1/clade-cumulus/validate-move", JSON3.write(Dict( @@ -437,7 +456,7 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 "treeFile" => "../secret.nwk", ))).status == 400 finally - SV.ServerState._root[] = old_root + SVCC.ServerState._root[] = old_root rm(tmp; recursive = true, force = true) end end From a71abfbe1f0cc1c7fc62687228e94845d486ed7c Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:46:49 +0000 Subject: [PATCH 09/13] Fix normal-tail inference and preserve response bytes in JSON tests Use Julia's bundled libm for erfc and copy response buffers before String conversion in canonical-byte assertions. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/compositional.jl | 4 +++- test/unit/test_report_funnel.jl | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/analysis/compositional.jl b/src/analysis/compositional.jl index c05ce892..c3f4071a 100644 --- a/src/analysis/compositional.jl +++ b/src/analysis/compositional.jl @@ -860,7 +860,9 @@ function _wald(coefficients::Matrix{Float64}, variances::Vector{Float64}) se[l, k] = s if s > 0.0 && isfinite(s) z[l, k] = coefficients[l, k] / s - p[l, k] = erfc(abs(z[l, k]) / sqrt(2.0)) + # Julia's Base does not export erfc; use the bundled libm so + # two-sided normal tail probabilities need no extra dependency. + p[l, k] = ccall((:erfc, Base.Math.libm), Cdouble, (Cdouble,), abs(z[l, k]) / sqrt(2.0)) else z[l, k] = NaN p[l, k] = NaN diff --git a/test/unit/test_report_funnel.jl b/test/unit/test_report_funnel.jl index b6049e8b..33c6bae2 100644 --- a/test/unit/test_report_funnel.jl +++ b/test/unit/test_report_funnel.jl @@ -22,7 +22,9 @@ _rf_request(method, path, body="", headers=["Content-Type" => "application/json" r = _rf_request("POST", "$base?kind=figure&title=Alpha&ext=.svg", svg, ["Content-Type" => "application/octet-stream"]) @test r.status == 200 - a = JSON3.read(String(r.body)) + # String(Vector{UInt8}) may take ownership and empty the input vector. + # Keep the response bytes intact for the canonical-byte assertion. + a = JSON3.read(String(copy(r.body))) @test r.body == jcs_bytes(a) @test read(joinpath(tmp, "projects", "StudyR", "report", "items.json")) == jcs_bytes([a]) @@ -136,7 +138,7 @@ end @test isempty(JSON3.read(String(_rf_request("GET", base).body))) r = _rf_request("POST", base, JSON3.write(Dict("title" => "Fig 1", "groups" => []))) @test r.status == 200 - @test r.body == jcs_bytes(JSON3.read(String(r.body))) + @test r.body == jcs_bytes(JSON3.read(String(copy(r.body)))) id = JSON3.read(String(r.body)).id @test occursin(r"^[0-9a-f]{8}$", id) r = _rf_request("PUT", "$base/$id", JSON3.write(Dict("id" => "ffffffff", "title" => "Renamed"))) From c88fc57dfac060218bacd619746d8a0fffcf897d Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:47:40 +0000 Subject: [PATCH 10/13] Keep strict JSON round-trips valid and error responses safe Reject exponent-form numbers that JCS would turn into unsafe integer tokens; preserve intended error status if interpolated client text contains invalid I-JSON Unicode. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/canonical.jl | 8 ++++++++ src/server/server.jl | 13 ++++++++++--- test/unit/test_canonical.jl | 5 +++++ test/unit/test_server_security.jl | 9 +++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/canonical.jl b/src/canonical.jl index 50d1442f..d198e2b3 100644 --- a/src/canonical.jl +++ b/src/canonical.jl @@ -338,6 +338,14 @@ function ijson_read(raw::Union{AbstractString,AbstractVector{UInt8}}) x = tryparse(Float64, token) (!isnothing(x) && isfinite(x)) || throw(ArgumentError( "I-JSON refuses a non-finite number $token")) + # Exponent/fraction input may stringify as an integer in + # JCS. Never accept a value whose canonical token would be + # rejected as an unsafe integer on the next read. + canonical = String(jcs_bytes(x)) + if occursin(r"^-?(0|[1-9][0-9]*)$", canonical) && + !(abs(x) <= SAFE_INT_MAX) + throw(ArgumentError("I-JSON refuses a number canonicalizing to an unsafe integer token $canonical")) + end end end end diff --git a/src/server/server.jl b/src/server/server.jl index 07c1b2d5..93e0717a 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -70,9 +70,16 @@ module Server function json_error(status::Int, code::String, message::String; detail=nothing) body = isnothing(detail) ? (; error=code, message) : (; error=code, message, detail) - HTTP.Response(status, - ["Content-Type" => "application/json"], - body = jcs_bytes(body)) + bytes = try + jcs_bytes(body) + catch e + e isa ArgumentError || rethrow() + # Error messages can interpolate untrusted path/query segments. + # If they contain invalid I-JSON Unicode, still return the intended + # HTTP status rather than turning a client error into a server error. + jcs_bytes((; error=code, message="Invalid request")) + end + HTTP.Response(status, ["Content-Type" => "application/json"], body=bytes) end ## Route registration diff --git a/test/unit/test_canonical.jl b/test/unit/test_canonical.jl index 9cb037eb..50fc08f7 100644 --- a/test/unit/test_canonical.jl +++ b/test/unit/test_canonical.jl @@ -102,6 +102,9 @@ end "{\"a\":1,\"\\u0061\":2}", "{\"nested\":[{\"b\":0,\"b\":1}]}", "{\"n\":9007199254740992}", + "{\"n\":9.007199254740992e15}", + "{\"n\":-9.007199254740992e15}", + "{\"n\":2.4499999999999998e16}", "[-9007199254740992]", "[1e400]", "{\"key\":\"\\ud800\"}", "{\"key\":\"\\udc00\"}", "{\"\\ufdd0\":0}", "[\"\\uFFFF\"]") @@ -116,5 +119,7 @@ end "{\"float\":1e-7}") @test ijson_read(String(jcs_bytes(ijson_read(input)))) isa JSON3.Object end + @test ijson_read("{\"n\":1e21}").n == 1e21 # JCS retains exponent notation + @test ijson_read("{\"n\":9e15}").n == 9e15 # within exact range @test String(jcs_bytes(ijson_read("{\"b\":2,\"a\":1}"))) == "{\"a\":1,\"b\":2}" end diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl index b108de6e..eee51fc7 100644 --- a/test/unit/test_server_security.jl +++ b/test/unit/test_server_security.jl @@ -163,6 +163,15 @@ end @test _ss_served("/api/v1/studies").status == 200 end + @testset "Invalid Unicode in client errors keeps its HTTP status" begin + r = SV.json_error(404, "study_not_found", "Study '\ufdd0' not found") + @test r.status == 404 + @test JSON3.read(String(copy(r.body))).message == "Invalid request" + r = SV.json_error(400, "bad_request", "Invalid group", detail="\U0001ffff") + @test r.status == 400 + @test JSON3.read(String(copy(r.body))).error == "bad_request" + end + @testset "Report uploads are served inert" begin host = "Host" => "127.0.0.1:8080" base = "/api/v1/studies/StudyS/report" From 8d22b2b5ee693e174cc580534ef5df8c9b1665d8 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:26:50 +0000 Subject: [PATCH 11/13] Call differential prevalence constructor with positional argument Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- test/unit/test_differential.jl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index d0f44e47..f0a95085 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -1647,8 +1647,7 @@ end e = try differential_abundance([1 0; 2 3; 0 4; 5 0], ["s1", "s2", "s3", "s4"], ["a", "b"], groups8[1:4]; - kw..., config=DifferentialConfig(; method="multinomial", - min_prevalence=1.0)) + kw..., config=DifferentialConfig("tss", 1.0; method="multinomial")) nothing catch err err From 7467d75f7d580b6e9f54bf9b559c7117046545a7 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:05:00 +0000 Subject: [PATCH 12/13] Align counterfactual PD assertion with node-local branch accounting Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- test/unit/test_clade_cumulus.jl | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index 4340bc6a..8fccc270 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -397,9 +397,8 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 @test v_allow.proposal_file !== nothing @test isfile(joinpath(trees_dir, ".clade_cumulus", String(v_allow.proposal_file))) @test v_allow.proposed_root.cumulative_count โ‰ˆ 110.0 atol = 1e-9 - # Subtree PD is the sum of active descendant branch lengths. It - # need not grow after reassignment: the moved branch can replace - # previously active topology rather than append to it. + # Subtree PD includes each active edge exactly once, including the + # target's own incoming edge (main's node-local PD convention). function proposed_node(u, id) u.id == id && return u for child in u.children @@ -411,9 +410,9 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 proposed_target = proposed_node(v_allow.proposed_root, high_clade.id) @test proposed_target !== nothing if proposed_target !== nothing - expected_pd = sum((max(0.0, Float64(ch.branch_length)) + Float64(ch.subtree_pd) - for ch in proposed_target.children if ch.cumulative_count > 0); - init=0.0) + expected_pd = max(0.0, Float64(proposed_target.branch_length)) + + sum((Float64(ch.subtree_pd) for ch in proposed_target.children + if ch.cumulative_count > 0); init=0.0) @test Float64(proposed_target.subtree_pd) โ‰ˆ expected_pd atol = 1e-10 @test Float64(proposed_target.subtree_pd) != before_target_pd end From 4a28b76eb9f7abcb6086857606e766617617f35b Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:06:32 +0000 Subject: [PATCH 13/13] Reject trailing JSON and validate report titles before upload Complete the raw-token grammar check and test byte-vector/trailing input; prevent invalid I-JSON titles from orphaning report files. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/canonical.jl | 43 ++++++++++++++++++--------------- src/server/routes/report.jl | 8 ++++++ test/unit/test_canonical.jl | 5 +++- test/unit/test_report_funnel.jl | 3 +++ 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/canonical.jl b/src/canonical.jl index d198e2b3..eeb35911 100644 --- a/src/canonical.jl +++ b/src/canonical.jl @@ -240,7 +240,8 @@ existing callers need not change their field access. function ijson_read(raw::Union{AbstractString,AbstractVector{UInt8}}) text = raw isa AbstractString ? String(raw) : String(copy(raw)) isvalid(text) || throw(ArgumentError("I-JSON requires UTF-8")) - # Let JSON3 enforce the RFC 8259 grammar (including trailing garbage). + # JSON3 parses the value; the raw-token walk also checks grammar and + # trailing content, which JSON3 may otherwise ignore. parsed = try JSON3.read(text) catch err @@ -327,30 +328,32 @@ function ijson_read(raw::Union{AbstractString,AbstractVector{UInt8}}) pos[] += 1 end token = String(bytes[start:pos[]-1]) - if occursin(r"^-?[0-9]", token) - if occursin(r"^-?(0|[1-9][0-9]*)$", token) - # Bound the token before parsing: an HTTP body could contain - # millions of digits, but a safe integer has at most 16. - length(token) <= 17 && - -SAFE_INT_MAX <= parse(BigInt, token) <= SAFE_INT_MAX || - throw(ArgumentError("I-JSON refuses an unsafe integer token $token")) - else - x = tryparse(Float64, token) - (!isnothing(x) && isfinite(x)) || throw(ArgumentError( - "I-JSON refuses a non-finite number $token")) - # Exponent/fraction input may stringify as an integer in - # JCS. Never accept a value whose canonical token would be - # rejected as an unsafe integer on the next read. - canonical = String(jcs_bytes(x)) - if occursin(r"^-?(0|[1-9][0-9]*)$", canonical) && - !(abs(x) <= SAFE_INT_MAX) - throw(ArgumentError("I-JSON refuses a number canonicalizing to an unsafe integer token $canonical")) - end + if occursin(r"^-?(0|[1-9][0-9]*)$", token) + # Bound the token before parsing: an HTTP body could contain + # millions of digits, but a safe integer has at most 16. + length(token) <= 17 && + -SAFE_INT_MAX <= parse(BigInt, token) <= SAFE_INT_MAX || + throw(ArgumentError("I-JSON refuses an unsafe integer token $token")) + elseif occursin(r"^-?(0|[1-9][0-9]*)\.[0-9]+([eE][+-]?[0-9]+)?$|^-?(0|[1-9][0-9]*)[eE][+-]?[0-9]+$", token) + x = tryparse(Float64, token) + (!isnothing(x) && isfinite(x)) || throw(ArgumentError( + "I-JSON refuses a non-finite number $token")) + # Exponent/fraction input may stringify as an integer in + # JCS. Never accept a value whose canonical token would be + # rejected as an unsafe integer on the next read. + canonical = String(jcs_bytes(x)) + if occursin(r"^-?(0|[1-9][0-9]*)$", canonical) && + !(abs(x) <= SAFE_INT_MAX) + throw(ArgumentError("I-JSON refuses a number canonicalizing to an unsafe integer token $canonical")) end + elseif !(token in ("true", "false", "null")) + throw(ArgumentError("invalid JSON token $token")) end end end value!() + whitespace!() + pos[] > n || throw(ArgumentError("invalid JSON: trailing content")) parsed end diff --git a/src/server/routes/report.jl b/src/server/routes/report.jl index 4707f88c..b1959738 100644 --- a/src/server/routes/report.jl +++ b/src/server/routes/report.jl @@ -64,6 +64,14 @@ end ext in _REPORT_EXTENSIONS || return json_error(400, "invalid_ext", "ext must be one of $(join(_REPORT_EXTENSIONS, ", "))") title = strip(get(q, "title", "")) isempty(title) && (title = "Untitled $kind") + # Reject invalid I-JSON Unicode before writing the upload: an invalid + # title would otherwise make the index unsavable and orphan the file. + try + jcs_bytes(title) + catch e + e isa ArgumentError || rethrow() + return json_error(400, "invalid_title", "Title contains invalid Unicode") + end bytes = req.body isempty(bytes) && return json_error(400, "empty", "The item has no content") length(bytes) <= _REPORT_MAX_BYTES || return json_error(413, "too_large", "Report items are limited to 50 MB") diff --git a/test/unit/test_canonical.jl b/test/unit/test_canonical.jl index 50fc08f7..3be5ae09 100644 --- a/test/unit/test_canonical.jl +++ b/test/unit/test_canonical.jl @@ -107,7 +107,8 @@ end "{\"n\":2.4499999999999998e16}", "[-9007199254740992]", "[1e400]", "{\"key\":\"\\ud800\"}", "{\"key\":\"\\udc00\"}", - "{\"\\ufdd0\":0}", "[\"\\uFFFF\"]") + "{\"\\ufdd0\":0}", "[\"\\uFFFF\"]", + "01", "1.", "-", "[1] x", "{\"a\":1}{\"a\":2}") @test_throws ArgumentError ijson_read(input) end @test_throws ArgumentError ijson_read(UInt8[0xff]) @@ -122,4 +123,6 @@ end @test ijson_read("{\"n\":1e21}").n == 1e21 # JCS retains exponent notation @test ijson_read("{\"n\":9e15}").n == 9e15 # within exact range @test String(jcs_bytes(ijson_read("{\"b\":2,\"a\":1}"))) == "{\"a\":1,\"b\":2}" + @test ijson_read(Vector{UInt8}(codeunits("{\"a\":1}"))) isa JSON3.Object + @test ijson_read("[1] \n") isa JSON3.Array end diff --git a/test/unit/test_report_funnel.jl b/test/unit/test_report_funnel.jl index 33c6bae2..d6ef004e 100644 --- a/test/unit/test_report_funnel.jl +++ b/test/unit/test_report_funnel.jl @@ -19,6 +19,9 @@ _rf_request(method, path, body="", headers=["Content-Type" => "application/json" base = "/api/v1/studies/StudyR/report" @test isempty(JSON3.read(String(_rf_request("GET", base).body))) svg = "" + @test _rf_request("POST", "$base?kind=figure&title=%EF%B7%90&ext=.svg", svg, + ["Content-Type" => "application/octet-stream"]).status == 400 + @test !isdir(joinpath(tmp, "projects", "StudyR", "report")) r = _rf_request("POST", "$base?kind=figure&title=Alpha&ext=.svg", svg, ["Content-Type" => "application/octet-stream"]) @test r.status == 200