diff --git a/.gitbot-fleet/report-backs/README.md b/.gitbot-fleet/report-backs/README.md new file mode 100644 index 00000000..32b48064 --- /dev/null +++ b/.gitbot-fleet/report-backs/README.md @@ -0,0 +1,40 @@ +# gitbot-fleet report-backs + +One JSON file per enrolled fleet member. The renderer uses the `reporter` +field as the row identity when present, falling back to the filename basename +without `.json` (for example, `rhodibot.json` becomes `rhodibot`). This identity +should match the key in `.gitbot-fleet/enrolment.json` (`rhodibot`, `seambot`, ...). + +The Hypatia verdict grid reads this directory +(`.github/workflows/hypatia-scan-reusable.yml` passes it to +`scripts/verdict-grid.sh --report-backs`), so a report-back written here is the +only way a member's row stops being orange. The envelope is the one in +`docs/VERDICT-GRID-SPEC.adoc` of +[hyperpolymath/standards](https://github.com/hyperpolymath/standards): + +```json +{ "reporter": "rhodibot", + "title": "rhodibot (RSR compliance)", + "status": "ran", + "reason": null, + "ref": "", + "findings": [ + { "rule_id": "RSR-001", "severity": "error", + "file": "README.adoc", "line": 1, "message": "..." } + ] } +``` + +`status` is one of `ran`, `not_run`, `error` or `not_applicable`; `reason` is +required unless the member ran, and `not_applicable` renders grey. `severity` +uses the fleet's own words (`error`, `warning`, `info`, `suggestion`) or the +scanner words (`critical`, `high`, `medium`, `low`); both normalise. + +The directory exists because this repository's deed declares it +(`MetaManifold-WebUI_chora.deed`, `:report-backs ".gitbot-fleet/report-backs/"`) +and a declared path that does not exist is a dangling reference. It is empty +on purpose: no member has reported back yet, and the grid says so - an +enrolled member with no report-back renders **orange** ("enrolled, but no +report-back was received"), never green. Do not add a placeholder envelope to +turn a row green; only a real run may. + +Only files matching `*.json` are read, so this README is inert. diff --git a/bench/advanced_zero_handling/benchmark.jl b/bench/advanced_zero_handling/benchmark.jl index e757c452..a385aa17 100644 --- a/bench/advanced_zero_handling/benchmark.jl +++ b/bench/advanced_zero_handling/benchmark.jl @@ -92,10 +92,14 @@ function main() isnothing(output) ? println(rendered) : write(output, rendered * "\n") if !isnothing(baseline_path) + # A baseline is an input like any other: a truncated or hand-edited file + # must name itself in the error rather than escape as a JSON3 parse + # failure from inside the benchmark loop. baseline = try JSON3.read(read(baseline_path, String)) - catch e - error("Failed to read benchmark baseline JSON from '$baseline_path': $(sprint(showerror, e))") + catch err + err isa InterruptException && rethrow() + error("could not read benchmark baseline $(baseline_path): $(sprint(showerror, err))") end regressions = String[] for old in baseline.results diff --git a/src/server/routes/clade_cumulus.jl b/src/server/routes/clade_cumulus.jl index c65a5a91..f41b9d51 100644 --- a/src/server/routes/clade_cumulus.jl +++ b/src/server/routes/clade_cumulus.jl @@ -224,7 +224,7 @@ function _clade_cumulus_post_impl(req::HTTP.Request, study::String, run::String) !isnothing(err) && return err body = try - isempty(req.body) ? Dict{Symbol,Any}() : JSON3.read(String(req.body)) + isempty(req.body) ? Dict{Symbol,Any}() : _request_json(req) catch return json_error(400, "invalid_json", "Request body must be valid JSON") end @@ -454,7 +454,7 @@ end !isnothing(err) && return err body = try - isempty(req.body) ? Dict{Symbol,Any}() : JSON3.read(String(req.body)) + isempty(req.body) ? Dict{Symbol,Any}() : _request_json(req) catch return json_error(400, "invalid_json", "Request body must be valid JSON") end diff --git a/src/server/server.jl b/src/server/server.jl index 9e22a3dc..2757cbd1 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -160,6 +160,28 @@ module Server message::String end + # Parse a request body as JSON, and turn malformed input into a 400 instead + # of a 500 carrying a JSON3 stack trace. A client that sends a truncated + # body behind a Save button has made a request error, not found a server + # bug. `String(::Vector{UInt8})` can consume the buffer, so copy it: routes + # that parse and then hash or log `req.body` still see the original bytes. + function _request_json(req::HTTP.Request) + try + JSON3.read(String(copy(req.body))) + catch e + e isa InterruptException && rethrow() + throw(BadRequest("Request body is not valid JSON")) + end + end + + # Oxygen's serializer catches whatever a handler throws and answers 500 + # before the middleware chain sees it, so `_bad_request_middleware` cannot + # rescue a `BadRequest` raised inside a route. Its error mapper is the hook + # that does run inside the serializer: the same dispatch that answers + # Oxygen's own `ValidationError` with a 400. Extending it keeps our JSON + # error shape instead of Oxygen's plain `{"message": ...}`. + Oxygen.Core.Util.handle_error(err::BadRequest) = json_error(400, "bad_request", err.message) + # Oxygen URL-decodes path parameters, so an encoded '/' would reach a handler # as a real separator. No study, run, table, column or file name needs one. function _unsafe_path_segment(target::AbstractString) diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl index d539d21f..d8ba0ab6 100644 --- a/test/unit/test_server_security.jl +++ b/test/unit/test_server_security.jl @@ -172,6 +172,30 @@ end @test JSON3.read(String(copy(r.body))).error == "bad_request" end + @testset "Malformed JSON bodies are client errors" begin + host = "Host" => "127.0.0.1:8080" + json = "Content-Type" => "application/json" + # Object-body routes parse through `Server._read_json_object`, so + # a truncated body returns 400 with an invalid_json error directly. + for (method, path) in ( + ("POST", "/api/v1/studies"), + ("POST", "/api/v1/studies/StudyS/runs"), + ("PATCH", "/api/v1/config"), + ("POST", "/api/v1/studies/StudyS/analysis/alpha"), + ) + r = _ss_request(method, path, [host, json], "{\"name\": ") + @test r.status == 400 + @test JSON3.read(String(copy(r.body))).error == "invalid_json" + end + # A well-formed body still parses, and a byte-backed body survives: + # `String(::Vector{UInt8})` can consume the vector, so the helper + # copies it before handing it to JSON3. + bytes = Vector{UInt8}(codeunits("{\"name\":\"FromBytes\"}")) + req = SV.HTTP.Request("POST", "/api/v1/studies", [json], bytes) + @test SV._request_json(req).name == "FromBytes" + @test String(copy(req.body)) == "{\"name\":\"FromBytes\"}" + end + @testset "Report uploads are served inert" begin host = "Host" => "127.0.0.1:8080" base = "/api/v1/studies/StudyS/report"