From b5d60472cc39c2ae711d08d8b3b685a75830663f Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Mon, 2 Nov 2026 08:55:46 +0000 Subject: [PATCH 1/3] test: pin the wire format with golden vectors; keep inputs out of release.yml scripts (V2-1751) Every serialization test in this crate round-trips in-process, so a reordered field, an inserted variant or a serializer bump passes CI and only breaks when ant-node and ant-client from different versions talk. tests/golden_wire.rs checks the bytes captured from 5.0.1 (tests/golden/wire-v5.txt) for every ChunkMessage body and response arm, all ProtocolError variants, the rmp-encoded quote, candidate and commitment payloads, the commitment pin inputs, single-node and merkle payment proofs, and a real ML-DSA-65 pointer record. Each vector must encode to the captured bytes, decode to the expected value field by field (catches swapped same-typed fields), and re-encode identically. Deliberate re-capture: delete the vector and run once with ANT_PROTOCOL_WRITE_GOLDEN=1 (append-only). release.yml: the dispatch version input reaches the script through env, the format check runs before anything is written to $GITHUB_OUTPUT, and $GITHUB_OUTPUT is quoted. Closes V2-1751 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 39 +- tests/golden/wire-v5.txt | 63 +++ tests/golden_wire.rs | 835 ++++++++++++++++++++++++++++++++++ 3 files changed, 920 insertions(+), 17 deletions(-) create mode 100644 tests/golden/wire-v5.txt create mode 100644 tests/golden_wire.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b0d569..c562315 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,46 +34,51 @@ jobs: with: fetch-depth: 0 - - name: Extract version + - name: Extract and validate version id: version + # The dispatch input reaches the script through env, never through + # expression interpolation into the script text. + env: + INPUT_VERSION: ${{ github.event.inputs.version }} run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - VERSION="${{ github.event.inputs.version }}" + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + VERSION="$INPUT_VERSION" else VERSION="${GITHUB_REF#refs/tags/}" fi - echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Validate before anything is written to $GITHUB_OUTPUT, so a + # malformed input (e.g. one containing a newline) can't add or + # override step outputs. + VERSION_REGEX="^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$" + if ! [[ "$VERSION" =~ $VERSION_REGEX ]]; then + echo "::error::Invalid version format: $VERSION (expected SemVer-shaped vX.Y.Z[-prerelease][+build])" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" VERSION_NUMBER="${VERSION#v}" - echo "version_number=$VERSION_NUMBER" >> $GITHUB_OUTPUT + echo "version_number=$VERSION_NUMBER" >> "$GITHUB_OUTPUT" # Any SemVer pre-release identifier (anything after a `-`) counts # as a pre-release. Previously this only matched `alpha|beta|rc` # and silently treated `v2.0.0-pre`, `-dev`, `-snapshot`, numeric # pre-releases, etc. as stable. if [[ "$VERSION_NUMBER" == *-* ]]; then - echo "is_prerelease=true" >> $GITHUB_OUTPUT + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" else - echo "is_prerelease=false" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" fi echo "Version: $VERSION (number: $VERSION_NUMBER)" - - name: Validate version format - run: | - VERSION="${{ steps.version.outputs.version }}" - VERSION_REGEX="^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$" - if ! [[ "$VERSION" =~ $VERSION_REGEX ]]; then - echo "::error::Invalid version format: $VERSION (expected SemVer-shaped vX.Y.Z[-prerelease][+build])" - exit 1 - fi - - name: Check Cargo.toml version + env: + EXPECTED: ${{ steps.version.outputs.version_number }} run: | # Use cargo metadata rather than grep so quoting style / stray # `version` keys in sub-tables can't trick the check. CARGO_VERSION=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys;print(json.load(sys.stdin)["packages"][0]["version"])') - EXPECTED="${{ steps.version.outputs.version_number }}" if [[ "$CARGO_VERSION" != "$EXPECTED" ]]; then echo "::error::Cargo.toml version mismatch" echo "::error::Expected $EXPECTED but found $CARGO_VERSION" diff --git a/tests/golden/wire-v5.txt b/tests/golden/wire-v5.txt new file mode 100644 index 0000000..e340a85 --- /dev/null +++ b/tests/golden/wire-v5.txt @@ -0,0 +1,63 @@ +# Golden wire-format vectors for ant-protocol, read by tests/golden_wire.rs. +# +# Captured from ant-protocol 5.0.1 (src identical to the v5.0.1 tag) with +# postcard 1.1.3, rmp-serde 1.3.1 and evmlib 0.10.0. +# +# One `name hex` pair per line. These bytes are what deployed ant-node and +# ant-client peers exchange: if a test fails, the wire format changed. Read the +# module docs in tests/golden_wire.rs before editing anything here. + +chunk.get_request 888e98a8c0e080810102a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 +chunk.get_response.error 888e98a8c0e08081010302080477697265 +chunk.get_response.not_found 888e98a8c0e08081010301a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 +chunk.get_response.success 888e98a8c0e08081010300a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a00c676f6c64656e206368756e6b +chunk.merkle_candidate_quote_request 888e98a8c0e080810106a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a000ffffff01f0fcc7d306 +chunk.merkle_candidate_quote_request_v2 888e98a8c0e080810109a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a000ffffff01f0fcc7d30601 +chunk.merkle_candidate_quote_response.error 888e98a8c0e08081010701080477697265 +chunk.merkle_candidate_quote_response.success 888e98a8c0e080810107008b019792ccc105c420000000000000000000000000000000000000000000000000000000000007a125c41405060708090a0b0c0d0e0f101112131415161718ce6a71fe71935c0509cc85dc0020ccd5ccd6ccd7ccd8ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf2ccf3ccf4015695dc0020303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4fcd1000dc0020404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f93ccb1ccb2ccb3945b5c5d5e +chunk.pointer_get_request 888e98a8c0e08081010ca0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 +chunk.pointer_get_response.error 888e98a8c0e08081010d02080477697265 +chunk.pointer_get_response.not_found 888e98a8c0e08081010d01a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 +chunk.pointer_get_response.success 888e98a8c0e08081010d00177374616e642d696e20706f696e746572207265636f7264 +chunk.pointer_put_request 888e98a8c0e08081010a177374616e642d696e20706f696e746572207265636f726400 +chunk.pointer_put_request.paid 888e98a8c0e08081010a177374616e642d696e20706f696e746572207265636f7264010501deadbeef +chunk.pointer_put_response.error 888e98a8c0e08081010b04080477697265 +chunk.pointer_put_response.payment_required 888e98a8c0e08081010b0309706179206669727374 +chunk.pointer_put_response.stale 888e98a8c0e08081010b02a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1 +chunk.pointer_put_response.success 888e98a8c0e08081010b00a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1 +chunk.pointer_put_response.unchanged 888e98a8c0e08081010b01a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1 +chunk.put_request 888e98a8c0e080810100a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a00c676f6c64656e206368756e6b00 +chunk.put_request.paid 898e98a8c0e080810100a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a00c676f6c64656e206368756e6b010501deadbeef +chunk.put_response.already_exists 888e98a8c0e08081010101a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 +chunk.put_response.error 888e98a8c0e08081010103080477697265 +chunk.put_response.payment_required 888e98a8c0e0808101010209706179206669727374 +chunk.put_response.success 888e98a8c0e08081010100a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 +chunk.quote_request 888e98a8c0e080810104a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0ffffff0100 +chunk.quote_request_v2 888e98a8c0e080810108a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0ffffff010001 +chunk.quote_response.error 888e98a8c0e08081010501080477697265 +chunk.quote_response.success 888e98a8c0e08081010500b70198dc0020030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212292ce6a71fc1bce075bcd15c420000000000000000000000000000000000000000000000000000000003b9aca0ac414565758595a5b5c5d5e5f6061626364656667686993ccb00301945a030203cd1003dc0020ccc3ccc4ccc5ccc6ccc7ccc8ccc9cccacccbcccccccdcccecccfccd0ccd1ccd2ccd3ccd4ccd5ccd6ccd7ccd8ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce200015695dc0020303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4fcd1000dc0020404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f93ccb1ccb2ccb3945b5c5d5e +chunk.quote_response.success.baseline 888e98a8c0e080810105007398dc00200405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2021222392ce6a71fc1cce075bcd15c420000000000000000000000000000000000000000000000000000000003b9aca0bc4145152535455565758595a5b5c5d5e5f606162636493ccb00401945a04020300c00100 +merkle_candidate_node.baseline 9792ccc108c420000000000000000000000000000000000000000000000000000000000007a128c41408090a0b0c0d0e0f101112131415161718191a1bce6a71fe71935c080900c0 +merkle_candidate_node.committed 9792ccc109c420000000000000000000000000000000000000000000000000000000000007a129c414090a0b0c0d0e0f101112131415161718191a1b1cce6a71fe71935c0909cc89dc0020ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf2ccf3ccf4ccf5ccf6ccf7ccf8 +payment_proof.merkle 0294dc00206162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7fcc809692dc0020606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7fdc002029cced417fccbd6923cc8dcca8ccd6313d4845cccdccb309ccc3cc98323e147874cc8cccc3466a1141ccf7cc8b0104dc00206162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7fcc80dc0020ccf6cc99784b16cc94ccb507cc8c156155254dcc9e2728ccd0cc9cccc2706474062bcc9064ccdc4635614edc00207172737475767778797a7b7c7d7e7fcc80cc81cc82cc83cc84cc85cc86cc87cc88cc89cc8acc8bcc8ccc8dcc8ecc8fcc9092929691dc002029cced417fccbd6923cc8dcca8ccd6313d4845cccdccb309ccc3cc98323e147874cc8cccc3466a1141ccf7cc8b0002dc0020ccecccf1ccaf18ccdc6558ccc8ccabcc87cca616ccb9ccdd044dccb0ccdacceacca64b02cc9a6900ccb3786f5f6fccd1ccffdc0020ccf6cc99784b16cc94ccb507cc8c156155254dcc9e2728ccd0cc9cccc2706474062bcc9064ccdc4635614ec0ce6a71fe71dc00109792ccc100c420000000000000000000000000000000000000000000000000000000000007a120c414000102030405060708090a0b0c0d0e0f10111213ce6a71fe71935c000900c09792ccc101c420000000000000000000000000000000000000000000000000000000000007a121c4140102030405060708090a0b0c0d0e0f1011121314ce6a71fe71935c0109cc81dc0020ccd1ccd2ccd3ccd4ccd5ccd6ccd7ccd8ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf09792ccc102c420000000000000000000000000000000000000000000000000000000000007a122c41402030405060708090a0b0c0d0e0f101112131415ce6a71fe71935c020900c09792ccc103c420000000000000000000000000000000000000000000000000000000000007a123c414030405060708090a0b0c0d0e0f10111213141516ce6a71fe71935c0309cc83dc0020ccd3ccd4ccd5ccd6ccd7ccd8ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf29792ccc104c420000000000000000000000000000000000000000000000000000000000007a124c4140405060708090a0b0c0d0e0f1011121314151617ce6a71fe71935c040900c09792ccc105c420000000000000000000000000000000000000000000000000000000000007a125c41405060708090a0b0c0d0e0f101112131415161718ce6a71fe71935c0509cc85dc0020ccd5ccd6ccd7ccd8ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf2ccf3ccf49792ccc106c420000000000000000000000000000000000000000000000000000000000007a126c414060708090a0b0c0d0e0f10111213141516171819ce6a71fe71935c060900c09792ccc107c420000000000000000000000000000000000000000000000000000000000007a127c4140708090a0b0c0d0e0f101112131415161718191ace6a71fe71935c0709cc87dc0020ccd7ccd8ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf2ccf3ccf4ccf5ccf69792ccc108c420000000000000000000000000000000000000000000000000000000000007a128c41408090a0b0c0d0e0f101112131415161718191a1bce6a71fe71935c080900c09792ccc109c420000000000000000000000000000000000000000000000000000000000007a129c414090a0b0c0d0e0f101112131415161718191a1b1cce6a71fe71935c0909cc89dc0020ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf2ccf3ccf4ccf5ccf6ccf7ccf89792ccc10ac420000000000000000000000000000000000000000000000000000000000007a12ac4140a0b0c0d0e0f101112131415161718191a1b1c1dce6a71fe71935c0a0900c09792ccc10bc420000000000000000000000000000000000000000000000000000000000007a12bc4140b0c0d0e0f101112131415161718191a1b1c1d1ece6a71fe71935c0b09cc8bdc0020ccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf2ccf3ccf4ccf5ccf6ccf7ccf8ccf9ccfa9792ccc10cc420000000000000000000000000000000000000000000000000000000000007a12cc4140c0d0e0f101112131415161718191a1b1c1d1e1fce6a71fe71935c0c0900c09792ccc10dc420000000000000000000000000000000000000000000000000000000000007a12dc4140d0e0f101112131415161718191a1b1c1d1e1f20ce6a71fe71935c0d09cc8ddc0020ccddccdeccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf2ccf3ccf4ccf5ccf6ccf7ccf8ccf9ccfaccfbccfc9792ccc10ec420000000000000000000000000000000000000000000000000000000000007a12ec4140e0f101112131415161718191a1b1c1d1e1f2021ce6a71fe71935c0e0900c09792ccc10fc420000000000000000000000000000000000000000000000000000000000007a12fc4140f101112131415161718191a1b1c1d1e1f202122ce6a71fe71935c0f09cc8fdc0020ccdfcce0cce1cce2cce3cce4cce5cce6cce7cce8cce9cceaccebccecccedcceeccefccf0ccf1ccf2ccf3ccf4ccf5ccf6ccf7ccf8ccf9ccfaccfbccfcccfdccfe91dc0056cc95ccdc0020303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4fcccd1000ccdc0020404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fcc93ccccccb1ccccccb2ccccccb3cc945b5c5d5e +payment_proof.single_node 0193919292dc0020cc90cc91cc92cc93cc94cc95cc96cc97cc98cc99cc9acc9bcc9ccc9dcc9ecc9fcca0cca1cca2cca3cca4cca5cca6cca7cca8cca9ccaaccabccacccadccaeccaf98dc00200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2092ce6a71fc19ce075bcd15c420000000000000000000000000000000000000000000000000000000003b9aca08c4145455565758595a5b5c5d5e5f606162636465666793ccb00101945a01020300c092dc0020cc91cc92cc93cc94cc95cc96cc97cc98cc99cc9acc9bcc9ccc9dcc9ecc9fcca0cca1cca2cca3cca4cca5cca6cca7cca8cca9ccaaccabccacccadccaeccafccb098dc002002030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202192ce6a71fc1ace075bcd15c420000000000000000000000000000000000000000000000000000000003b9aca09c4145758595a5b5c5d5e5f606162636465666768696a93ccb00201945a020203cd1002dc0020ccc2ccc3ccc4ccc5ccc6ccc7ccc8ccc9cccacccbcccccccdcccecccfccd0ccd1ccd2ccd3ccd4ccd5ccd6ccd7ccd8ccd9ccdaccdbccdcccddccdeccdfcce0cce191c420e0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff91dc0056cc95ccdc0020303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4fcccd1000ccdc0020404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5fcc93ccccccb1ccccccb2ccccccb3cc945b5c5d5e +payment_quote.baseline 98dc0020060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20212223242592ce6a71fc1ece075bcd15c420000000000000000000000000000000000000000000000000000000003b9aca0dc414535455565758595a5b5c5d5e5f6061626364656693ccb00601945a06020300c0 +payment_quote.committed 98dc00200708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2021222324252692ce6a71fc1fce075bcd15c420000000000000000000000000000000000000000000000000000000003b9aca0ec41452535455565758595a5b5c5d5e5f60616263646593ccb00701945a070203cd1007dc0020ccc7ccc8ccc9cccacccbcccccccdcccecccfccd0ccd1ccd2ccd3ccd4ccd5ccd6ccd7ccd8ccd9ccdaccdbccdcccddccdeccdfcce0cce1cce2cce3cce4cce5cce6 +pointer.address 672090853b5b96f3c265104d891c1fdf65a295d00bc0898ab1e6c756ee62409c +pointer.record 01ecfb111611963d2cd4b2dbb29f77d78244a6d99e582aa3f88f406a2ac618ea548db45dea73a4730bac4ac96366264de6fd56fc96232e1f911eb7616ede6693a4ddebea03bb143ba70a5330e041e6a6faf25e5c2017e6872e202f67af6b50f1ddc66492399461541a825914682841fe7a4cf01b8e0a0b91f5edae9bf3c931956dbbf8c727f47ce7b70c1c5956c03051384e7e1b3cba00020b9c03193a1dbe8ce577ac66e3d2ede2e5c17b954a0344d1138fe5d383ad74a1df1ccfee331ec2f2652a2551d64b8f30261ebf59db4db017fcf866b986134ed4c6e0d58785f92d99472cd9bd3e45afa3847d6a2b4de327c23c9550ecaf95996a28b8678ee0ac30ef35c3c8f7caaa6ad346f30ef017daa673cf8aa4d7a8e83a197a852da1fb9fb48bc4e63538b06439935882c9b7dc0909e6d5a69cb7b01ad26185db832205f475df5ae569a9a79c6700482b7d693150519c4b9127df8c5da2b912e0017204414de03546b603892ed2f7045f732695fceee0fb7061514c6c9a46edc848defd716ac2b18b36984b079ee14e9c6eb9ee5bcd1a9d4e53999646cc5c0ff59c39afda640a7d41407c3e5a8435ec488917d5c6818dc94da27dcf988a2da15816eeb7a3b027758a367cac43cb6b7e6f8380c7f5698aa3148e4637cf7ae8db24662f273664a57610dcf9f26250854b2e4ad4952fca2635f360d68047d27c756bf912e083da44bc252fd2846ef58a4ce2ea9d734116a08e2c4e8f2ddd93278dd992b7941afb41bbdb7336976c47d3168ab5ebb54fcd25a44c93ceb2fb399585c2154018ad5dd5448205c6723159e357c2e92fdbed42fa1a9532f181793aba323a915cc9af58243031f2ae9b72cbc0dd94d6f8f2acf74c8bacf82c9fde2d214791e09f36360c84810f195339aaa26e35ca875987882806b7d7c2444f66a8a6a1bd88313b9b9307f99dc81bf7449e36768a639bab297f70ef7507aa411a2e6a53ab326715e6d2dae123b1a3d11caf30a7fd0461b7964089af6b46cc9842fd8d8df4a52753cf1573f6e03d197b1e82ebeb3f560c3e660a326394ade0d5d15bf16b61021c9a9f00be8f16a0c3e499b9ecd7f0bbbaba9f2687cb8a7b86070f5fb418759af861587bd9805fd6b16278834fe6191268db3f98757ff42ae37d2fdb579c68b71d272d6958ec4933d4c979fcce6e6b942a5fecfc9da902c9f3440b36d02798cb0d2ca56994a67c48228f1b2e63e88423d365c0586a5dee10421d34c2558d472b7d6486d9c716e12aa2110dbcc3e5f7b3a202797a61ddf9fb8b7a22b9a3ffeb0ae9c361fbedc6aa0df9f8c94361a3c589d11ddeefe992fd8784d81adb83969c5f45d63b4f0a1887e7cce30c04f0266ff304d654227785114d5a5636bec389987d7efee0e4ee68f0d6e2216b4bfdec93ae5cdd0bb2c939b6cc980f553ec40edac2674a65c7549f52b4bbc5ec9640c36250ad49c767535d9aa116aadf6e87c4c4855fcbcada653a1e4c5f4290472c628895b03a1f7dc12a0969bcdb92141284d7a0e0e91ed49a16524e279a93806f00aa96c3407f1488e8d0b370aa71547d5941d1695501f6479a19bafc98b3407ca2cec122815317e3d1abe11c1a61bb16be134a254aae6010262ea8e1829fc1c530851d8cd7894b6b66fc08cc460adcc9900f1c4fcf911191b71ef851c9546de506e1903cada44b379fb9bd5fa06788b5e860c91bfb6e174e70f4fc33d5bbeb09ee71ae2845c317ab8bd3a0e91c2b8adf9fde9ac98e02ee413635243ce01f9cb75050ed13694e609d5a47166f07f1f4d8e29e90906165d528d9730e99f6fbda8417a5883152d35ed3057c74a1563be655d3d947eb20c211fc20fd18497f12e7791d637d4bc32a90ae8447bed8fb9bed38588407667d7e601fd2748a54f075f4b5420e26d895cae1e0fa11821a6350c36c8c7648799a921d3365b51d91afa30d1ad680f0de42e0ed1a46a2acbf1aa3b3e5c83761adadccc31fb7e68481f96669fe5dbf63d65e404197715752603330e26eefdf59361a2f0a2b0f87d777f4a02e88c8befc92115f7ca562b25ffeebb30705f9bb121e5f7039af18944bdd1c56e3333d11815b78c522ba238799046010d52f63931abf641c9ac3fb5ea15b8fd05612d8564b39887bfe56635068f79ce227fb19acf48800e0a0b4b18abb38cc9f5391c4941b07a483e78193d1f140f5eec68eeb9dcafd71782515c97afa84bdc75e192da8207435c7e97b441767602b3a09c0a76c2e6b44e81efcabcbc5a2624f4d9fa6f088f09baa3b46f4f0048e782c6a89c5eedd9ce86e75f7e66f6ea8f010bf817b78ff17ab778925800a17a6a5046170eb8e59eaf1cdeda0b27fdd8280d12c5bb14f95382881bb610db4e3d841732018ddc320d328413fbe7385d8a0cc593f12ef68bd80dbed70e806a727760a6a237c5fe5300ca1ac89055b6cfcf1d4dd61780d518f6ac149e64882f25d4e9379021b1ac0d790622235d85d8212ca712f1e6c9bfb70d319a41aea9a5a78c496c58813509554a7fc459d1096ae4648eff0ceef9fd4a50fafeaed8efa8cd314a9bc00269138c2865751a20c8e6c7a5542d2acd033953df4e66c70a8359e7c7e1ff7da6cea2ab873cde84f7ec8f64d617bb9cb0ec9bad97b528a7a20216a36b0654a17d17bb58c4cd4a3bd6105342a45ea2fedcfa6b6ffc62f987f6780fad3ccc532603529eefcc14b89962877c9a8e7fc33720adb120ae77f90e4b38a7f4901d52c79feb5f35e6fa54c408fee000000000000000700f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff000102030405060708090a0b0c0d0e0f6369fee96a9a45dfd7cf6f2c1bc4e9e1f331c677e1ff6bdf167d338cea826709faf75bfdfab318343ea5f7f32773e130b8e0f0c5a00312270bc02dffe2844b3d5473bd37c72c9c6c7e7a09935addfc9f27a3b94d8d44241b1e0d5312f352f3a81eb39d9339af90d6c2c9cd15fed01c6421dcb1cc78791505a53e42ffed58ca5d1694acab6716fa7d0619306382ddc421ccb80e074f2fc3edd00d1c1cab5cfb41dc5f8d5455b656b0649edb6ba2607a06b98bbf73a8eadb3b68ca4702f1dd20cbe86aab55883b0e170bd792f1be285bf7e58ca7587deb617391597b7c4d6027db044478fbd39ad3675080221386357433a504e39fb5755b4cbad49a0d5c49f982ad7c123bcb7d57eea08b46a25387f81bd7099e6389fb9966f234c7d37dad87039008a64af964d6269db3325d7ce2c14863761d99acde6f3836d46262ad4873a31d10fb3033411ec39b45d9044cd0e9340a90e594b2a480d99f919ad102c8a1af0821feb4ec61ba4df97858cdb153a12cfa1cc1768a7edb80abda4ed00ccc0641e654a64ec3b6311d7c0736b9b7a7a06ecfbbe101e27ec2fb2a36c75de3930be457a7cec831bc27bc8e1b8fd08baa64e336be448df92783e8fa41583578bfec0ef67c15d2b90b75616b77b736e69eb534ad249b754f0d076b89bdff896e4d01df05bf7631cdf1cb4d4f39cfbe8da5fd906f99f1fba2d9288f958c72ecfe351c031fe1ec0246b58c5902e777639e2dadeb96928826474833e961882cb73a0ad253d4e399cadacacede9f9e5a5d2fcc412df96eebfcab9d9a40bdbee6d0bf5d3b38efde072a43ac2d247baccb818cd0d088faa86aeb15fdb1dd888795ace53ad5adcc1c24a919e8849134049e1b6fc66d6bf753871c9d665a1c9922e698d124886fafe1a94760b0cf1ac56f3b484e425a3627aff352a85e5512a02c48adb7228714081ca63c216a4eaf2fa63bc12a90e1bd1fc0204a4fb20df1990ffc51cf42686ce81380db383c15c89e475e2a14b4a4f9d83bb60dbc42d07e5a8fdc0f773b6791e23e8bad9bee504932331ed7ea602d7ba8993220059154abdb0364954e05d81f5a96caded14aaccf1881c44e3fd1e24146436244e4bf7bc8a35f40c9d0dd13c207dca84393c26ee2cd570fde4e38e948e9db3217e55bfecb3bba5ee23841528b5321dc37b6187590928ba5f487d89ae3b829d54c383b35e62dfae5c5dcbce164aecb3f217665a5291995ae3c57fbe9cf09a53dee108b7373648f037692a228786e65e1c3477e695275478c0dfa4b032d1574051ddb7ef17dec5f04c0d53e15206d8bfcc22fa7151b07e57d0663006e94a7ac765aa8b3d53fa2651df761addd2b3a616e366c46d77065a2f90e152a655b57d51ead113283a36ea6d5775214c551afee7d83355e65ff41013bb3ac954719e9bf328717711b69f8f1ecaee2542564e79e2d90e6f31e226a6405b6e1e0a57bf77dd349da34d5bc24012b5410f4ae3bdb64445278c28cf8df3d1bd9ae2d109db60d1902d5cd4f9841fcbc5baa1e0e71374bc4064f60340388aa34b694d2db52927166452a470340f66d738e42fc73ee424b588254fb718c93586cc365004b9745118e1fe119007b4e359263853997a07a166239f08d88304b8a0676c91c3fc6518ab4e6c03dd3094d9aad04adec76360a0a9c9a162d2c7e4081b8abf6e70fd7b4bf42ccf9d9ae8b2251a4b93f381efd701940545af62ae7ef542831e26d157611b05aa8581dd6ff8543c1f407702f5428e4e7bdbe36ccb5d046e35b8dd8a83efdce3b0ff45fe0756b535156436f5c97ed177483ccc48597c76587753ea599b84a9910066e867e2197066b0babb0a2fcda9931a15edff773e5417665cfb25a4b8ddfcb77bef1f8ed453e13bcfda6797f58b1eb3b764f56aabfa79458516b2e59697c3ef6b01beacd67e35f2a9e026b7dbaa14e3cff816ed9e79d7677d8f66e4c3e81abe9103353d050a6f4b2c4accd2cd4a5918a15a17774432f0d4261a78734921cb1ffcc036ee94bc0713302a0bda19c4417e746ef77b69be280fea252265e29e33e7672bdd28b8f6d8d6a43e0d5418286f4515b6073dbd191fdcdd052aeee6d6e56434ede1d992fdc6da805c8b261f4fc2096b9a40d075b5bce5847e11b327aeea5bdf13b40cc27e530b9d4707b97fb72e062060cb69873612be88799dcbf250c692fd7eade223239aacbbf934a577d9e6ab9cf7b12c4028d5f718bcf3399b5c5157cbc07f9c713d39665fe06f744a952af368634c37e13d0f71bbc7ceca2c456aad0817d7db69490e9ff654abc66a2253d1f90fad137e2a3774cf983e54e68fe6c02bef54b89b5399c8cbd901de54c182e53396b2a9f1bd8572b9247254cee8d4ec7cd525cdf3a8b9400eaee26e8f49803cfc716bb78711bd8b85a5084e0199147acc10311fd8d2c1197029af66a4128ad951b04c5b0397703f5eb3c0ca62c6c34b9fbd46bb45afd0c91d55b38c5d2be437f22bfb2fe6c46bcd1c6bf1d196d804c33361a134470a541c50779841687566bc9bce7a69f0464e88e88f61827545a917b2f7112d44fd7f0790480aa086df90a52cfee1220c08ccabffcc19ead87a9b35073035486d6f34697792a5663a3dc169e020658f392a067df53ac8da0e5beaf587e70e7341760334768c4e8c7ff1d8bf03a7fa043f3c19d617a0bcb09f44277efe9ad54d3138d4a2b80dbf58f6b11c5d921bbb428a437aca689ec74dce9850f4ebb4d1f2d94c14f47f4f6c3ae3865143cf829acc3811e6d8ec3fdc7cddaee6437f273999a716cdf81d6e8a5e379ecdf3efe25853230fa683f65babe70be808fcba58ddf7ff4313b5bd0e95aa613930dbe3b392b04234ce219e3646da28285d822ee5f173ef9dddf29a7b8c41f16b40764993d2a15e051d318c3714af9ceca8a2aa04bdb1e653bd2783ab8082ca56833b14bd83df1039481272f62a2b1e830434c00fa487c7c834fa85530f4c199821f7cd419ef342ee9d1fc04e3098c04c6d3862852bb0304cb29d85353ac6d813365d13542d56e75a54d05c4bee132ef3ccb4c30da0e127e9e19de0fbc962047b8bac5fa572405dbe1d59689010d486a0d811f7ac077b74c978e5d627f6b513f0816400969ed806b827e96a8316243dc60b735ebb70300b3f2343ba9101daa4fe525189316b41d313ae48d149c16efaa11176466954eecde5c245fc1661e7b2387ad0db49a62c1f3a305ab7b6b7d468a6581bc44c469b8cfa29a0b8b61efe1ab2339694f11bbbc49fe10118f04a190c3a9932463a6e4f5a58308c4fcb962ba63b300b83a234741bdf9dd820f01a9b875b61c7dea1a42cfb9d38241b0b23fc2465931b72ec930176509c270f77f00959999a552d7a0f82135629359a9cf51a68707993c815d88f3f7348fc2193b2041358cfe5dc6c8b7c515ed8ed9695f361093bfa4ca5ee1caa0b222e4cd4d8c0007a1fc89aea58bad0d2a293e56bdbbe36eb7d7e76fdc551d0dbe590a2cfb2c3bf09fe36e50ddf5d4527a050d7f2dbd5e8359d08a68682262542759fe5e53b07ac274243684b93e5b7b0eb76ace3edb7388fb50ef0fbac0b97248ca2516bca6d0e09d2797ee4b054ee2d0272017eae94bc85274eb7ffee13115a8688df667bca6a50b54802599c61469386a00d905969c7166ad98f6ea9d1a27ea802b60927013011a34da9be8b8be24c3e4f60d5b49381b923bf0c435554871c127c37698adbd4c468d1e043db77f3735e5e651ed8ef185b342ba999d45d50db953d73b5e09b6ca0d9c538de19220bbb253a9296251078c19c9b49db6b473044dad6bfe75deab7681c83be00cbe5cee9242feb09e501d1062dd217a28dae3909821540cdf0511f5d97a9c497b564e35b75668f9a31d92a6744b7f3b4671af92518fbb57ff9e24df0951cf6dd10b7afa4b09472e77f6e580049aece2674192716fee4e3c22999555f9a72585b707030c0621bab35d9b6dee0370eacb3b25739ac9f60cb9bed7e0a2aa8e69d087e278ff9356600db1de061d142320693fd781736a449dcbeb5837902585942e6f8c2bd96d3e1c0fa12f027644a719e6810ada65096dcc48d166ca179bc7f41fd515e55547ece3b26df3a189b95026a971da8f77e29395d9f14e62cb31323e2ce3aa4dcf063020554e14979e5a1dddb124f067b9c5474a29f98d537130833c381d7a8780266808977ec277a8c8f6d7d1135ada7268fd7acc9ab0d137e364d635eacc2942a20a86d88741911f2492ac26fd97edb4f3da7bdc56f84b132e29824c8a3a7429e947dee8dff059040dacb81130527c4863f1919acc682dd2f75535b9b23757dd5f0c18a4c8db0a6f1c790cf175c1d7a2919b6e40c0ebf419fbcb4156301e0d55606355c184e0ca455c0ec8c71d06670a3fa1b44300ddf4a00fdab824d19c80b4c323ab1a10d1a10c2edbbf71161e10be4a4a4f28c2eae0c6e29961b4dbbb822fda3120a4e568da47577f1907352cd7765d1682394a0a1d7fbdbc26c9cb1d3f3041fd7024c93f1e133f9eebdfdfa6e59532e4040986c9e4e0938b29211e9e7b79431ac53d3011da5c2ee96655f96924e3f4bfbf3ae6965881529dcf183d6db7e54c8eced9f8535fe3041430384477a8c706104f547598b8c1e8f200000000000000000000000000000000000000000003080d101822 +pointer.state_id 80e10af5b6e88b3483277f16e438a1801c6a2515b6c503bc39054ac9b80cbc7e +protocol_error.address_mismatch 040102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f208182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0 +protocol_error.chunk_too_large 038180800280808002 +protocol_error.client_update_required 090304 +protocol_error.deserialization_failed 01026465 +protocol_error.internal 08046f6f7073 +protocol_error.message_too_large 028180c0028080c002 +protocol_error.payment_failed 0606756e70616964 +protocol_error.quote_failed 070462757379 +protocol_error.serialization_failed 0003736572 +protocol_error.storage_failed 05046469736b +protocol_error.storer_update_required 0a0605 +storage_commitment.pin 7b42bd26e597f4164805e12e21d6eadae38cbf656e8c1531ad5875f2806210a9 +storage_commitment.postcard 303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f8020404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f03b1b2b3045b5c5d5e +storage_commitment.rmp 95dc0020303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4fcd1000dc0020404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f93ccb1ccb2ccb3945b5c5d5e +storage_commitment.signed_payload 303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f00100000404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f03000000b1b2b3 diff --git a/tests/golden_wire.rs b/tests/golden_wire.rs new file mode 100644 index 0000000..54343a9 --- /dev/null +++ b/tests/golden_wire.rs @@ -0,0 +1,835 @@ +//! Golden wire-format vectors. +//! +//! Every other serialization test in this crate round-trips in-process: encode +//! a value, decode it, compare. That proves nothing about compatibility. A +//! reordered field, an inserted enum variant or a serializer bump changes the +//! bytes on both sides of the round trip at once, so it passes, and only +//! breaks when an `ant-node` and an `ant-client` built from different versions +//! talk to each other. +//! +//! These tests pin the bytes instead. `tests/golden/wire-v5.txt` holds one +//! vector per wire type, captured from `ant-protocol` 5.0.1. Each test builds +//! the same value from fixed inputs and checks that: +//! +//! 1. encoding it gives exactly the captured bytes; +//! 2. decoding the captured bytes gives exactly that value, field by field; +//! 3. re-encoding the decoded value gives the captured bytes again. +//! +//! Check 2 is the one that catches a swap of two same-typed fields, which the +//! other two cannot see: postcard and rmp both encode by position, so a swapped +//! decode re-encodes to the same bytes. Every fixture therefore uses a +//! distinct value per field. +//! +//! **A failure here means the wire format changed**, which is a breaking change +//! for every deployed peer. Don't make it pass by re-capturing. If the change +//! is a deliberate, coordinated cutover, delete the affected lines from the +//! vector file and run the tests once with `ANT_PROTOCOL_WRITE_GOLDEN=1`, which +//! appends missing vectors and never overwrites existing ones, and say so in +//! the changelog. +//! +//! Signature and public-key fields inside quotes, candidates and commitments +//! are short stand-in bytes. The wire carries them as opaque byte strings, so +//! their validity is not a wire property, and real ones would make each +//! vector about 10 KB. The pointer vector is the exception: decoding a pointer +//! verifies its signature, so it carries a real ML-DSA-65 key and signature. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::BTreeMap; +use std::fmt::Debug; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, UNIX_EPOCH}; + +use ant_protocol::evm::{ + Address, Amount, EncodedPeerId, MerklePaymentCandidateNode, MerklePaymentCandidatePool, + MerklePaymentProof, MerkleTree, PaymentQuote, ProofOfPayment, TxHash, +}; +use ant_protocol::payment::commitment::{ + commitment_hash, commitment_signed_payload, StorageCommitment, +}; +use ant_protocol::pqc::api::ml_dsa_65; +use ant_protocol::{ + deserialize_merkle_proof, deserialize_proof, serialize_merkle_proof, + serialize_single_node_proof, state_id_for_body, ChunkGetRequest, ChunkGetResponse, + ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, + ChunkQuoteRequestV2, ChunkQuoteResponse, MerkleCandidateQuoteRequest, + MerkleCandidateQuoteRequestV2, MerkleCandidateQuoteResponse, PaymentProof, Pointer, + PointerGetRequest, PointerGetResponse, PointerPutRequest, PointerPutResponse, PointerTarget, + PointerTargetKind, ProtocolError, CURRENT_SETTLEMENT_VERSION, DATA_TYPE_CHUNK, + POINTER_BODY_LEN, POINTER_WIRE_LEN, PROOF_TAG_MERKLE, PROOF_TAG_SINGLE_NODE, +}; +use bytes::Bytes; +use serde::Serialize; +use xor_name::XorName; + +// ============================================================================= +// Vector file +// ============================================================================= + +/// The captured vectors, relative to the crate root. One `name hex` pair per +/// line; `#` starts a comment. +const VECTOR_FILE: &str = "tests/golden/wire-v5.txt"; + +/// Set to append vectors that are missing from [`VECTOR_FILE`]. +const WRITE_ENV: &str = "ANT_PROTOCOL_WRITE_GOLDEN"; + +/// Serializes appends when the tests run in parallel. +static WRITE_LOCK: Mutex<()> = Mutex::new(()); + +fn vector_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(VECTOR_FILE) +} + +fn load_vectors() -> BTreeMap> { + let text = std::fs::read_to_string(vector_path()).unwrap_or_default(); + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(|line| { + let (name, hex_bytes) = line + .split_once(char::is_whitespace) + .unwrap_or_else(|| panic!("`{line}` is not a `name hex` line")); + let bytes = hex::decode(hex_bytes.trim()) + .unwrap_or_else(|e| panic!("vector `{name}` is not valid hex: {e}")); + (name.to_string(), bytes) + }) + .collect() +} + +/// The captured bytes for `name`. +/// +/// `fresh` is what this build encodes. It's only used when the vector is +/// missing: printed in the failure, or appended under [`WRITE_ENV`]. +fn golden(name: &str, fresh: &[u8]) -> Vec { + if let Some(bytes) = load_vectors().remove(name) { + return bytes; + } + if std::env::var_os(WRITE_ENV).is_some() { + let _guard = WRITE_LOCK.lock().unwrap(); + if let Some(bytes) = load_vectors().remove(name) { + return bytes; + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(vector_path()) + .unwrap(); + writeln!(file, "{name} {}", hex::encode(fresh)).unwrap(); + return fresh.to_vec(); + } + panic!( + "no vector `{name}` in {VECTOR_FILE}; this build encodes it as\n{}\n\ + (set {WRITE_ENV}=1 to append missing vectors)", + hex::encode(fresh) + ); +} + +fn assert_bytes(name: &str, what: &str, actual: &[u8], golden: &[u8]) { + assert!( + actual == golden, + "{name}: {what} no longer matches the captured wire bytes. The wire format \ + changed; see the module docs before touching the vector.\n captured: {}\n \ + now: {}", + hex::encode(golden), + hex::encode(actual), + ); +} + +/// The three checks from the module docs, for one value. +/// +/// `same` compares a decoded value with the expected one. Use `PartialEq` +/// where the type has it. Otherwise compare `Debug` output, but only for types +/// whose `Debug` prints every field (`PaymentQuote`'s doesn't). +fn check( + name: &str, + expected: &T, + encode: impl Fn(&T) -> Vec, + decode: impl Fn(&[u8]) -> T, + same: impl Fn(&T, &T) -> bool, +) { + let fresh = encode(expected); + let golden = golden(name, &fresh); + assert_bytes(name, "encoding", &fresh, &golden); + let decoded = decode(&golden); + assert!( + same(&decoded, expected), + "{name}: the captured bytes decode to a different value\n decoded: \ + {decoded:?}\n expected: {expected:?}", + ); + assert_bytes(name, "re-encoding", &encode(&decoded), &golden); +} + +/// For derived values with no decoder of their own (hashes, signed payloads). +fn check_derived(name: &str, fresh: &[u8]) { + let golden = golden(name, fresh); + assert_bytes(name, "value", fresh, &golden); +} + +// ============================================================================= +// Encoders, one per wire format +// ============================================================================= + +/// `ChunkMessage`: postcard, through the crate's own codec. +fn check_message(name: &str, request_id: u64, body: ChunkMessageBody) { + let expected = ChunkMessage { request_id, body }; + check( + name, + &expected, + |m| m.encode().unwrap(), + |b| ChunkMessage::decode(b).unwrap(), + |a, b| format!("{a:?}") == format!("{b:?}"), + ); +} + +/// `MessagePack`, as the node encodes quotes, candidates and commitment +/// sidecars (`rmp_serde::to_vec`). +fn rmp(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +// ============================================================================= +// Fixtures +// ============================================================================= + +/// 32 distinct bytes starting at `seed`. +fn bytes32(seed: u8) -> [u8; 32] { + let mut out = [0u8; 32]; + for (byte, offset) in out.iter_mut().zip(0u8..) { + *byte = seed.wrapping_add(offset); + } + out +} + +/// 20 distinct bytes starting at `seed`. +fn bytes20(seed: u8) -> [u8; 20] { + let mut out = [0u8; 20]; + for (byte, offset) in out.iter_mut().zip(0u8..) { + *byte = seed.wrapping_add(offset); + } + out +} + +const REQUEST_ID: u64 = 0x0102_0304_0506_0708; +const ADDRESS: [u8; 32] = [0xA0; 32]; +const STATE_ID: [u8; 32] = [0xA1; 32]; +const DATA_SIZE: u64 = 4_194_303; +const MERKLE_TIMESTAMP: u64 = 1_785_855_600; + +fn payment_proof_bytes() -> Vec { + vec![PROOF_TAG_SINGLE_NODE, 0xDE, 0xAD, 0xBE, 0xEF] +} + +fn quote(seed: u8, committed: bool) -> PaymentQuote { + PaymentQuote { + content: XorName(bytes32(seed)), + timestamp: UNIX_EPOCH + Duration::new(1_785_855_000 + u64::from(seed), 123_456_789), + price: Amount::from(1_000_000_007_u64 + u64::from(seed)), + rewards_address: Address::new(bytes20(seed ^ 0x55)), + pub_key: vec![0xB0, seed, 0x01], + signature: vec![0x5A, seed, 0x02, 0x03], + committed_key_count: if committed { + 4_096 + u32::from(seed) + } else { + 0 + }, + commitment_pin: committed.then(|| bytes32(seed ^ 0xC0)), + } +} + +fn commitment() -> StorageCommitment { + StorageCommitment { + root: bytes32(0x30), + key_count: 4_096, + sender_peer_id: bytes32(0x40), + sender_public_key: vec![0xB1, 0xB2, 0xB3], + signature: vec![0x5B, 0x5C, 0x5D, 0x5E], + } +} + +/// Even candidates are baseline quotes, odd ones pin a commitment, so the +/// pool covers both shapes. +fn candidate(i: u8) -> MerklePaymentCandidateNode { + let committed = i % 2 == 1; + MerklePaymentCandidateNode { + pub_key: vec![0xC1, i], + price: Amount::from(500_000_u64 + u64::from(i)), + reward_address: Address::new(bytes20(i)), + merkle_payment_timestamp: MERKLE_TIMESTAMP + 1, + signature: vec![0x5C, i, 0x09], + committed_key_count: if committed { 128 + u32::from(i) } else { 0 }, + commitment_pin: committed.then(|| bytes32(0xD0 ^ i)), + } +} + +/// A merkle tree built from fixed leaves and salts. +/// +/// `MerkleTree::from_xornames` salts and pads with random bytes, so this goes +/// through the tree's persisted form instead, which is `{ leaves, salts }`. +/// If evmlib changes that form, this fixture fails to decode; that's a fixture +/// problem, not a wire change. +fn merkle_tree() -> MerkleTree { + #[derive(Serialize)] + struct TreeSnapshot { + leaves: Vec<[u8; 32]>, + salts: Vec<[u8; 32]>, + } + let snapshot = TreeSnapshot { + leaves: (0x60..0x64).map(bytes32).collect(), + salts: (0x70..0x74).map(bytes32).collect(), + }; + rmp_serde::from_slice(&rmp(&snapshot)).unwrap() +} + +fn merkle_proof() -> MerklePaymentProof { + let tree = merkle_tree(); + let address = XorName(bytes32(0x61)); + let data_proof = tree.generate_address_proof(1, address).unwrap(); + let midpoint_proof = tree + .reward_candidates(MERKLE_TIMESTAMP + 1) + .unwrap() + .into_iter() + .next() + .unwrap(); + let winner_pool = MerklePaymentCandidatePool { + midpoint_proof, + candidate_nodes: std::array::from_fn(|i| candidate(u8::try_from(i).unwrap())), + }; + let mut proof = MerklePaymentProof::new(address, data_proof, winner_pool); + proof.commitment_sidecars = vec![rmp(&commitment())]; + proof +} + +fn single_node_proof() -> PaymentProof { + PaymentProof { + proof_of_payment: ProofOfPayment { + peer_quotes: vec![ + (EncodedPeerId::new(bytes32(0x90)), quote(1, false)), + (EncodedPeerId::new(bytes32(0x91)), quote(2, true)), + ], + }, + tx_hashes: vec![TxHash::new(bytes32(0xE0))], + commitment_sidecars: vec![rmp(&commitment())], + } +} + +/// Every `ProtocolError` variant, with distinct field values. +fn protocol_errors() -> Vec<(&'static str, ProtocolError)> { + vec![ + ( + "serialization_failed", + ProtocolError::SerializationFailed("ser".into()), + ), + ( + "deserialization_failed", + ProtocolError::DeserializationFailed("de".into()), + ), + ( + "message_too_large", + ProtocolError::MessageTooLarge { + size: 5_242_881, + max_size: 5_242_880, + }, + ), + ( + "chunk_too_large", + ProtocolError::ChunkTooLarge { + size: 4_194_305, + max_size: 4_194_304, + }, + ), + ( + "address_mismatch", + ProtocolError::AddressMismatch { + expected: bytes32(0x01), + actual: bytes32(0x81), + }, + ), + ( + "storage_failed", + ProtocolError::StorageFailed("disk".into()), + ), + ( + "payment_failed", + ProtocolError::PaymentFailed("unpaid".into()), + ), + ("quote_failed", ProtocolError::QuoteFailed("busy".into())), + ("internal", ProtocolError::Internal("oops".into())), + ( + "client_update_required", + ProtocolError::ClientUpdateRequired { + client_settlement_version: 3, + min_settlement_version: 4, + }, + ), + ( + "storer_update_required", + ProtocolError::StorerUpdateRequired { + client_settlement_version: 6, + node_settlement_version: 5, + }, + ), + ] +} + +fn protocol_error() -> ProtocolError { + ProtocolError::Internal("wire".into()) +} + +// ============================================================================= +// ChunkMessage: chunks +// ============================================================================= + +#[test] +fn chunk_put_request() { + check_message( + "chunk.put_request", + REQUEST_ID, + ChunkMessageBody::PutRequest(ChunkPutRequest::new( + ADDRESS, + Bytes::from_static(b"golden chunk"), + )), + ); + check_message( + "chunk.put_request.paid", + REQUEST_ID + 1, + ChunkMessageBody::PutRequest(ChunkPutRequest::with_payment( + ADDRESS, + Bytes::from_static(b"golden chunk"), + payment_proof_bytes(), + )), + ); +} + +#[test] +fn chunk_put_response() { + let cases = [ + ("success", ChunkPutResponse::Success { address: ADDRESS }), + ( + "already_exists", + ChunkPutResponse::AlreadyExists { address: ADDRESS }, + ), + ( + "payment_required", + ChunkPutResponse::PaymentRequired { + message: "pay first".into(), + }, + ), + ("error", ChunkPutResponse::Error(protocol_error())), + ]; + for (variant, response) in cases { + check_message( + &format!("chunk.put_response.{variant}"), + REQUEST_ID, + ChunkMessageBody::PutResponse(response), + ); + } +} + +#[test] +fn chunk_get_request() { + check_message( + "chunk.get_request", + REQUEST_ID, + ChunkMessageBody::GetRequest(ChunkGetRequest::new(ADDRESS)), + ); +} + +#[test] +fn chunk_get_response() { + let cases = [ + ( + "success", + ChunkGetResponse::Success { + address: ADDRESS, + content: b"golden chunk".to_vec(), + }, + ), + ("not_found", ChunkGetResponse::NotFound { address: ADDRESS }), + ("error", ChunkGetResponse::Error(protocol_error())), + ]; + for (variant, response) in cases { + check_message( + &format!("chunk.get_response.{variant}"), + REQUEST_ID, + ChunkMessageBody::GetResponse(response), + ); + } +} + +// ============================================================================= +// ChunkMessage: quotes +// ============================================================================= + +#[test] +fn chunk_quote_request() { + check_message( + "chunk.quote_request", + REQUEST_ID, + ChunkMessageBody::QuoteRequest(ChunkQuoteRequest::new(ADDRESS, DATA_SIZE)), + ); +} + +#[test] +fn chunk_quote_request_v2() { + let request = ChunkQuoteRequestV2::new(ADDRESS, DATA_SIZE); + assert_eq!(request.settlement_version, CURRENT_SETTLEMENT_VERSION); + check_message( + "chunk.quote_request_v2", + REQUEST_ID, + ChunkMessageBody::QuoteRequestV2(request), + ); +} + +#[test] +fn chunk_quote_response() { + let cases = [ + ( + "success", + ChunkQuoteResponse::Success { + quote: rmp("e(3, true)), + already_stored: false, + commitment: Some(rmp(&commitment())), + }, + ), + ( + "success.baseline", + ChunkQuoteResponse::Success { + quote: rmp("e(4, false)), + already_stored: true, + commitment: None, + }, + ), + ("error", ChunkQuoteResponse::Error(protocol_error())), + ]; + for (variant, response) in cases { + check_message( + &format!("chunk.quote_response.{variant}"), + REQUEST_ID, + ChunkMessageBody::QuoteResponse(response), + ); + } +} + +#[test] +fn merkle_candidate_quote_request() { + check_message( + "chunk.merkle_candidate_quote_request", + REQUEST_ID, + ChunkMessageBody::MerkleCandidateQuoteRequest(MerkleCandidateQuoteRequest { + address: ADDRESS, + data_type: DATA_TYPE_CHUNK, + data_size: DATA_SIZE, + merkle_payment_timestamp: MERKLE_TIMESTAMP, + }), + ); +} + +#[test] +fn merkle_candidate_quote_request_v2() { + check_message( + "chunk.merkle_candidate_quote_request_v2", + REQUEST_ID, + ChunkMessageBody::MerkleCandidateQuoteRequestV2(MerkleCandidateQuoteRequestV2::new( + ADDRESS, + DATA_SIZE, + MERKLE_TIMESTAMP, + )), + ); +} + +#[test] +fn merkle_candidate_quote_response() { + let cases = [ + ( + "success", + MerkleCandidateQuoteResponse::Success { + candidate_node: rmp(&candidate(5)), + commitment: Some(rmp(&commitment())), + }, + ), + ( + "error", + MerkleCandidateQuoteResponse::Error(protocol_error()), + ), + ]; + for (variant, response) in cases { + check_message( + &format!("chunk.merkle_candidate_quote_response.{variant}"), + REQUEST_ID, + ChunkMessageBody::MerkleCandidateQuoteResponse(response), + ); + } +} + +// ============================================================================= +// ChunkMessage: pointers +// ============================================================================= + +#[test] +fn pointer_put_request() { + let record = Bytes::from_static(b"stand-in pointer record"); + check_message( + "chunk.pointer_put_request", + REQUEST_ID, + ChunkMessageBody::PointerPutRequest(PointerPutRequest::new(record.clone())), + ); + check_message( + "chunk.pointer_put_request.paid", + REQUEST_ID, + ChunkMessageBody::PointerPutRequest(PointerPutRequest::with_payment( + record, + payment_proof_bytes(), + )), + ); +} + +#[test] +fn pointer_put_response() { + let cases = [ + ( + "success", + PointerPutResponse::Success { + address: ADDRESS, + state_id: STATE_ID, + }, + ), + ( + "unchanged", + PointerPutResponse::Unchanged { + address: ADDRESS, + state_id: STATE_ID, + }, + ), + ( + "stale", + PointerPutResponse::Stale { + address: ADDRESS, + state_id: STATE_ID, + }, + ), + ( + "payment_required", + PointerPutResponse::PaymentRequired { + message: "pay first".into(), + }, + ), + ("error", PointerPutResponse::Error(protocol_error())), + ]; + for (variant, response) in cases { + check_message( + &format!("chunk.pointer_put_response.{variant}"), + REQUEST_ID, + ChunkMessageBody::PointerPutResponse(response), + ); + } +} + +#[test] +fn pointer_get_request() { + check_message( + "chunk.pointer_get_request", + REQUEST_ID, + ChunkMessageBody::PointerGetRequest(PointerGetRequest::new(ADDRESS)), + ); +} + +#[test] +fn pointer_get_response() { + let cases = [ + ( + "success", + PointerGetResponse::Success { + record: Bytes::from_static(b"stand-in pointer record"), + }, + ), + ( + "not_found", + PointerGetResponse::NotFound { address: ADDRESS }, + ), + ("error", PointerGetResponse::Error(protocol_error())), + ]; + for (variant, response) in cases { + check_message( + &format!("chunk.pointer_get_response.{variant}"), + REQUEST_ID, + ChunkMessageBody::PointerGetResponse(response), + ); + } +} + +// ============================================================================= +// ProtocolError +// ============================================================================= + +/// Every variant, encoded on its own. It travels inside the responses above, +/// where postcard writes exactly these bytes. +#[test] +fn protocol_error_variants() { + for (variant, error) in protocol_errors() { + check( + &format!("protocol_error.{variant}"), + &error, + |e| postcard::to_stdvec(e).unwrap(), + |b| postcard::from_bytes(b).unwrap(), + PartialEq::eq, + ); + } +} + +// ============================================================================= +// Payloads inside the messages (MessagePack) +// ============================================================================= + +/// `ChunkQuoteResponse::Success::quote`. +#[test] +fn payment_quote() { + for (variant, value) in [("baseline", quote(6, false)), ("committed", quote(7, true))] { + check( + &format!("payment_quote.{variant}"), + &value, + rmp, + |b| rmp_serde::from_slice(b).unwrap(), + PartialEq::eq, + ); + } +} + +/// `MerkleCandidateQuoteResponse::Success::candidate_node`. +#[test] +fn merkle_candidate_node() { + for (variant, value) in [("baseline", candidate(8)), ("committed", candidate(9))] { + check( + &format!("merkle_candidate_node.{variant}"), + &value, + rmp, + |b| rmp_serde::from_slice(b).unwrap(), + PartialEq::eq, + ); + } +} + +/// The commitment as it travels (quote-response `commitment`, proof +/// `commitment_sidecars`), and the pin a quote names it by. +/// +/// The pin hashes the **postcard** encoding, not the `MessagePack` one on +/// the wire, so both are pinned: a postcard layout change would silently +/// change every pin, and every quote would stop resolving to its commitment. +#[test] +fn storage_commitment() { + let value = commitment(); + check( + "storage_commitment.rmp", + &value, + rmp, + |b| rmp_serde::from_slice(b).unwrap(), + PartialEq::eq, + ); + check( + "storage_commitment.postcard", + &value, + |c| postcard::to_stdvec(c).unwrap(), + |b| postcard::from_bytes(b).unwrap(), + PartialEq::eq, + ); + check_derived("storage_commitment.pin", &commitment_hash(&value).unwrap()); + check_derived( + "storage_commitment.signed_payload", + &commitment_signed_payload( + &value.root, + value.key_count, + &value.sender_peer_id, + &value.sender_public_key, + ), + ); +} + +/// `ChunkPutRequest::payment_proof` for a single-node payment: tag byte, then +/// the `MessagePack` `PaymentProof`. +#[test] +fn single_node_payment_proof() { + let expected = single_node_proof(); + check( + "payment_proof.single_node", + &expected, + |p| serialize_single_node_proof(p).unwrap(), + |b| { + assert_eq!(b.first(), Some(&PROOF_TAG_SINGLE_NODE)); + ant_protocol::payment::proof::deserialize_single_node_proof(b).unwrap() + }, + |a, b| { + a.proof_of_payment == b.proof_of_payment + && a.tx_hashes == b.tx_hashes + && a.commitment_sidecars == b.commitment_sidecars + }, + ); + + // The older entry point drops the sidecars but must read the same bytes. + let golden = golden("payment_proof.single_node", &[]); + let (proof_of_payment, tx_hashes) = deserialize_proof(&golden).unwrap(); + assert_eq!(proof_of_payment, expected.proof_of_payment); + assert_eq!(tx_hashes, expected.tx_hashes); +} + +/// `ChunkPutRequest::payment_proof` for a merkle payment: tag byte, then the +/// `MessagePack` `MerklePaymentProof`. +#[test] +fn merkle_payment_proof() { + check( + "payment_proof.merkle", + &merkle_proof(), + |p| serialize_merkle_proof(p).unwrap(), + |b| { + assert_eq!(b.first(), Some(&PROOF_TAG_MERKLE)); + deserialize_merkle_proof(b).unwrap() + }, + PartialEq::eq, + ); +} + +// ============================================================================= +// Pointer record (hand-encoded, see `pointer.rs`) +// ============================================================================= + +/// The pointer record carried by `PointerPutRequest` and `PointerGetResponse`, +/// plus the two identities derived from it. +/// +/// ML-DSA-65 signing is randomized, so the signature can't be rebuilt from the +/// fixture. The record is checked by decoding it, which verifies the captured +/// signature against the seeded owner key, and the signed body is rebuilt and +/// compared byte for byte. +#[test] +fn pointer_record() { + let (owner, secret) = ml_dsa_65().generate_keypair_from_seed(&[0x42; 32]); + let target = PointerTarget::new(PointerTargetKind::Chunk, bytes32(0xF0)); + let fresh = Pointer::sign(&secret, &owner, 7, target).unwrap(); + + let golden = golden("pointer.record", &fresh.to_bytes()); + assert_eq!(golden.len(), POINTER_WIRE_LEN); + assert_bytes( + "pointer.record", + "signed body", + &fresh.to_bytes()[..POINTER_BODY_LEN], + &golden[..POINTER_BODY_LEN], + ); + + let decoded = Pointer::from_bytes(&golden).unwrap(); + assert_eq!(decoded.owner().to_bytes(), owner.to_bytes()); + assert_eq!(decoded.counter(), 7); + assert_eq!(decoded.target(), target); + assert_bytes( + "pointer.record", + "re-encoding", + &decoded.to_bytes(), + &golden, + ); + + check_derived("pointer.address", &decoded.address()); + check_derived("pointer.state_id", &decoded.state_id()); + assert_eq!( + decoded.state_id(), + state_id_for_body(&golden[..POINTER_BODY_LEN]) + ); +} From 092acd59441cf26cedfb7762634ae5033de68969 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Mon, 2 Nov 2026 10:19:43 +0000 Subject: [PATCH 2/3] test: read and append golden vectors under one lock in write mode (V2-1751) Hermes round 1 (REQUEST_CHANGES on b5d60472): with ANT_PROTOCOL_WRITE_GOLDEN=1 and vectors missing, golden() read the vector file before taking WRITE_LOCK, so a parallel test could read a line another test was still appending ("... is not a `name hex` line"; reproduced 4/50 runs with all vectors removed and 21 threads). golden() now delegates to golden_in(path, name, fresh, write), which in write mode holds WRITE_LOCK across the read and the append (no nested locking; a poisoned lock is recovered, since the file is still intact). Each line is appended with a single write_all. parallel_regeneration_is_consistent: 32 threads regenerate 150 vectors into a temporary file (the real vector file is never touched), then check each vector was written exactly once and reads back intact. A race test can only be probabilistic. On dev2 the round-1 code failed 5/20 runs with the reported "`r` is not a `name hex` line", and the fixed code 0/50 (about 2 s per run). Also checked: regenerating every vector from scratch (30 runs, 21 threads) reproduces the captured bytes exactly, except pointer.record, whose ML-DSA signature is randomized and is captured once by design. Co-Authored-By: Claude Opus 5.5 --- tests/golden_wire.rs | 91 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/tests/golden_wire.rs b/tests/golden_wire.rs index 54343a9..9018604 100644 --- a/tests/golden_wire.rs +++ b/tests/golden_wire.rs @@ -39,8 +39,8 @@ use std::collections::BTreeMap; use std::fmt::Debug; use std::fs::OpenOptions; use std::io::Write; -use std::path::PathBuf; -use std::sync::Mutex; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, PoisonError}; use std::time::{Duration, UNIX_EPOCH}; use ant_protocol::evm::{ @@ -76,15 +76,15 @@ const VECTOR_FILE: &str = "tests/golden/wire-v5.txt"; /// Set to append vectors that are missing from [`VECTOR_FILE`]. const WRITE_ENV: &str = "ANT_PROTOCOL_WRITE_GOLDEN"; -/// Serializes appends when the tests run in parallel. +/// Held for every read and append in write mode, when the tests run in parallel. static WRITE_LOCK: Mutex<()> = Mutex::new(()); fn vector_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(VECTOR_FILE) } -fn load_vectors() -> BTreeMap> { - let text = std::fs::read_to_string(vector_path()).unwrap_or_default(); +fn load_vectors(path: &Path) -> BTreeMap> { + let text = std::fs::read_to_string(path).unwrap_or_default(); text.lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) @@ -104,20 +104,35 @@ fn load_vectors() -> BTreeMap> { /// `fresh` is what this build encodes. It's only used when the vector is /// missing: printed in the failure, or appended under [`WRITE_ENV`]. fn golden(name: &str, fresh: &[u8]) -> Vec { - if let Some(bytes) = load_vectors().remove(name) { + golden_in( + &vector_path(), + name, + fresh, + std::env::var_os(WRITE_ENV).is_some(), + ) +} + +/// [`golden`] against the vector file at `path`; with `write`, a missing vector +/// is appended. +/// +/// In write mode the read and the append both happen under [`WRITE_LOCK`]: +/// another test may be appending a line, and an unlocked read could see it +/// half-written. +fn golden_in(path: &Path, name: &str, fresh: &[u8], write: bool) -> Vec { + // A test that panicked while holding the lock left the file intact, so a + // poisoned lock is still safe to use. + let _guard = write.then(|| WRITE_LOCK.lock().unwrap_or_else(PoisonError::into_inner)); + if let Some(bytes) = load_vectors(path).remove(name) { return bytes; } - if std::env::var_os(WRITE_ENV).is_some() { - let _guard = WRITE_LOCK.lock().unwrap(); - if let Some(bytes) = load_vectors().remove(name) { - return bytes; - } + if write { let mut file = OpenOptions::new() .create(true) .append(true) - .open(vector_path()) + .open(path) + .unwrap(); + file.write_all(format!("{name} {}\n", hex::encode(fresh)).as_bytes()) .unwrap(); - writeln!(file, "{name} {}", hex::encode(fresh)).unwrap(); return fresh.to_vec(); } panic!( @@ -833,3 +848,53 @@ fn pointer_record() { state_id_for_body(&golden[..POINTER_BODY_LEN]) ); } + +// ============================================================================= +// Regeneration mode +// ============================================================================= + +/// `ANT_PROTOCOL_WRITE_GOLDEN=1` runs every test in parallel, each reading the +/// vector file and appending what it's missing. No reader may see another +/// test's line half-written, and each vector must be written exactly once. +/// Runs against a temporary file, never the real one. +#[test] +fn parallel_regeneration_is_consistent() { + // Many short appends and fast reads give a racing read plenty of chances + // to land between the writes of one line. A race test can only be + // probabilistic: at these settings the round-1 code (first read outside + // the lock, multi-write append) failed about 1 run in 4 on Linux, with the + // reported "`r` is not a `name hex` line"; the fixed code never did, and + // takes about 2 s. + const THREADS: usize = 32; + const VECTORS: usize = 150; + const REPEAT: usize = 1; + let path = std::env::temp_dir().join(format!( + "ant-protocol-golden-regen-{}.txt", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let vector = |i: usize| i.to_le_bytes().repeat(REPEAT); + let names: Vec = (0..VECTORS) + .map(|i| format!("regen.vector_{i:04}")) + .collect(); + + std::thread::scope(|scope| { + for start in 0..THREADS { + let (path, names) = (&path, &names); + scope.spawn(move || { + // Each thread walks all vectors from a different starting point. + for i in (0..VECTORS).map(|k| (k + start * 3) % VECTORS) { + assert_eq!(golden_in(path, &names[i], &vector(i), true), vector(i)); + } + }); + } + }); + + let lines = std::fs::read_to_string(&path).unwrap(); + assert_eq!(lines.lines().count(), VECTORS, "each vector written once"); + let vectors = load_vectors(&path); + for (i, name) in names.iter().enumerate() { + assert_eq!(vectors[name], vector(i), "{name}"); + } + std::fs::remove_file(&path).unwrap(); +} From 125334e89b4968eabe5119c6177ed9ca66a50ce1 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Mon, 2 Nov 2026 11:46:50 +0000 Subject: [PATCH 3/3] test: round-2 follow-ups: temp-file cleanup, single-process note, no echoed input (V2-1751) Hermes approved 092acd5 with three non-blocking notes: - WRITE_LOCK serializes appends between threads of one test process, not across processes. The module docs now say to regenerate with a single `cargo test` invocation. - parallel_regeneration_is_consistent removes its temporary file in a Drop guard, so a panicking run doesn't leave it behind. - release.yml no longer echoes a rejected dispatch version into its ::error:: line; text after a newline in it could have been read as another workflow command. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 4 +++- tests/golden_wire.rs | 25 +++++++++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c562315..ed27ca3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,9 @@ jobs: # override step outputs. VERSION_REGEX="^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$" if ! [[ "$VERSION" =~ $VERSION_REGEX ]]; then - echo "::error::Invalid version format: $VERSION (expected SemVer-shaped vX.Y.Z[-prerelease][+build])" + # Don't echo the rejected value: it's dispatch input, and text after a + # newline in it could be read as another workflow command. + echo "::error::Invalid version format (expected SemVer-shaped vX.Y.Z[-prerelease][+build])" exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" diff --git a/tests/golden_wire.rs b/tests/golden_wire.rs index 9018604..0299131 100644 --- a/tests/golden_wire.rs +++ b/tests/golden_wire.rs @@ -25,7 +25,8 @@ //! is a deliberate, coordinated cutover, delete the affected lines from the //! vector file and run the tests once with `ANT_PROTOCOL_WRITE_GOLDEN=1`, which //! appends missing vectors and never overwrites existing ones, and say so in -//! the changelog. +//! the changelog. Run it as a single `cargo test` invocation: appends are +//! serialized between the tests' threads in one process, not across processes. //! //! Signature and public-key fields inside quotes, candidates and commitments //! are short stand-in bytes. The wire carries them as opaque byte strings, so @@ -868,11 +869,20 @@ fn parallel_regeneration_is_consistent() { const THREADS: usize = 32; const VECTORS: usize = 150; const REPEAT: usize = 1; - let path = std::env::temp_dir().join(format!( + /// Removes the temporary vector file however the test ends. + struct TempFile(PathBuf); + impl Drop for TempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + let file = TempFile(std::env::temp_dir().join(format!( "ant-protocol-golden-regen-{}.txt", std::process::id() - )); - let _ = std::fs::remove_file(&path); + ))); + let path = &file.0; + let _ = std::fs::remove_file(path); let vector = |i: usize| i.to_le_bytes().repeat(REPEAT); let names: Vec = (0..VECTORS) .map(|i| format!("regen.vector_{i:04}")) @@ -880,7 +890,7 @@ fn parallel_regeneration_is_consistent() { std::thread::scope(|scope| { for start in 0..THREADS { - let (path, names) = (&path, &names); + let names = &names; scope.spawn(move || { // Each thread walks all vectors from a different starting point. for i in (0..VECTORS).map(|k| (k + start * 3) % VECTORS) { @@ -890,11 +900,10 @@ fn parallel_regeneration_is_consistent() { } }); - let lines = std::fs::read_to_string(&path).unwrap(); + let lines = std::fs::read_to_string(path).unwrap(); assert_eq!(lines.lines().count(), VECTORS, "each vector written once"); - let vectors = load_vectors(&path); + let vectors = load_vectors(path); for (i, name) in names.iter().enumerate() { assert_eq!(vectors[name], vector(i), "{name}"); } - std::fs::remove_file(&path).unwrap(); }