From fc05ad8c9330c976b22249242bd36e0a4ea84f8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 15:12:25 +0000 Subject: [PATCH] CI: add cross-port byte-exact interop checks Two new safeguards for the canonical 966-byte PCF-SIG vector that every port ships under testdata/canonical.bin: (1) Rust workspace test reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs runs as part of `cargo test --workspace`. It compiles the reference vector via include_bytes! and asserts that the copies shipped by the .NET, PHP and TypeScript ports are byte-identical. Catches the easy regression where someone regenerates the reference vector but forgets to propagate it to one or more ports. Fast (one file read each) and requires no new toolchains. (2) New workflow .github/workflows/cross-port-interop.yml Installs Rust + Node 22 + PHP 8.3 + .NET 8 in a single Ubuntu job, generates the canonical vector from each language's writer to a fresh path, and asserts: - every output's sha256 matches the spec-pinned b158e2f5...1307 expected value - every pair of outputs is byte-for-byte equal Catches writer-side drift that the per-port suites would miss if a port's own testdata happened to match its (also drifted) writer. Triggered on changes that touch any pcf-sig directory or the workflow itself. Both checks are independent and cheap relative to the assurance they provide: any future minor-version bump that regenerates the canonical vector now has CI as a single source of truth for byte-exact agreement across all four implementations. https://claude.ai/code/session_01ST4PcjqvobURus32WuyEi5 --- .github/workflows/cross-port-interop.yml | 171 ++++++++++++++++++ .../PCF-SIG-v1.0/tests/cross_port_testdata.rs | 89 +++++++++ 2 files changed, 260 insertions(+) create mode 100644 .github/workflows/cross-port-interop.yml create mode 100644 reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs diff --git a/.github/workflows/cross-port-interop.yml b/.github/workflows/cross-port-interop.yml new file mode 100644 index 0000000..5d7aa97 --- /dev/null +++ b/.github/workflows/cross-port-interop.yml @@ -0,0 +1,171 @@ +name: CI / Cross-Port Interop + +# Runs every PCF-SIG writer (Rust reference + .NET + PHP + TypeScript) and +# asserts all four produce the byte-identical canonical 966-byte signed +# container. The reference also checks the shipped testdata/canonical.bin in +# each port directory (covered by the Rust workspace test +# `cross_port_testdata`), but this job goes one step further: it regenerates +# each writer's output from scratch and compares the bytes, catching writer- +# side drift that would silently pass if a port happened to keep its own +# committed testdata in sync with itself but produced different bytes at run +# time. + +on: + push: + branches: [master] + paths: + - 'reference/PCF-SIG-v1.0/**' + - 'implementations/**/pcf-sig/**' + - 'implementations/ts/package.json' + - 'implementations/ts/package-lock.json' + - 'implementations/dotnet/Directory.Build.props' + - '.github/workflows/cross-port-interop.yml' + pull_request: + branches: [master] + paths: + - 'reference/PCF-SIG-v1.0/**' + - 'implementations/**/pcf-sig/**' + - 'implementations/ts/package.json' + - 'implementations/ts/package-lock.json' + - 'implementations/dotnet/Directory.Build.props' + - '.github/workflows/cross-port-interop.yml' + +# Expected SHA-256 of the canonical 966-byte vector. Pinned here so the job +# fails loudly if every writer drifts together (e.g. a regenerated reference +# that propagated to all four ports but is no longer the spec test vector). +env: + EXPECTED_SHA256: b158e2f5b160d72cea3226af2041f8d18aa75b3db6cb85faeca5df7879871307 + +jobs: + cross-port-byte-exact: + name: all writers produce identical bytes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: implementations/ts/package-lock.json + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: hash, mbstring, sodium + coverage: none + tools: composer:v2 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + # ---- Rust reference writer -------------------------------------------- + - name: Generate Rust reference vector + run: | + cargo run -p pcf-sig --example gen_testvector -- /tmp/rust.bin + + # ---- TypeScript writer ------------------------------------------------ + - name: Install npm deps and build pcf + working-directory: implementations/ts + run: | + npm ci + npm run build -w @kduma-oss/pcf + - name: Generate TS vector + working-directory: implementations/ts + run: | + npm run gen-testvector -w @kduma-oss/pcf-sig -- /tmp/ts.bin + + # ---- PHP writer ------------------------------------------------------- + - name: Install composer deps + working-directory: implementations/php/pcf-sig + run: composer install --prefer-dist --no-progress --no-interaction + - name: Generate PHP vector + working-directory: implementations/php/pcf-sig + run: php examples/gen_testvector.php /tmp/php.bin + + # ---- .NET writer ------------------------------------------------------ + # The PCF-SIG .NET tests already include a CanonicalVectorTests suite + # that asserts the writer matches the shipped testdata; here we build a + # tiny CLI on the fly that writes its output to a path so the bytes can + # be compared with the other three. + - name: Generate .NET vector + run: | + mkdir -p /tmp/dotnet-gen + cat > /tmp/dotnet-gen/GenTestVector.csproj <<'EOF' + + + Exe + net8.0 + disable + + + + + + EOF + cat > /tmp/dotnet-gen/Program.cs <<'EOF' + using System; + using System.IO; + using System.Text; + using Pcf; + using Pcf.Sig; + var seed = new byte[32]; + for (int i = 0; i < 32; i++) seed[i] = (byte)i; + var signer = SigningMaterial.Ed25519FromSeed(seed); + var ms = new MemoryStream(); + var c = Container.CreateWith(ms, 8, HashAlgo.Sha256); + var alphaUid = new byte[16]; for (int i = 0; i < 16; i++) alphaUid[i] = 0x11; + var sigUid = new byte[16]; for (int i = 0; i < 16; i++) sigUid[i] = 0x33; + var keyUid = new byte[16]; for (int i = 0; i < 16; i++) keyUid[i] = 0x22; + c.AddPartition(0x10, alphaUid, "alpha", Encoding.UTF8.GetBytes("Hello, PCF-SIG!"), 0, HashAlgo.Sha256); + SignPartitions.Run(c, signer, new[] { alphaUid }, sigUid, keyUid, 0, "pcfsig", "pcfkey"); + File.WriteAllBytes(args[0], c.CompactedImage()); + EOF + dotnet run --project /tmp/dotnet-gen/GenTestVector.csproj -c Release -- /tmp/dotnet.bin + + # ---- Compare ---------------------------------------------------------- + - name: All four writers agree on byte-exact output + run: | + set -euo pipefail + ls -l /tmp/rust.bin /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin + declare -A digests + for f in /tmp/rust.bin /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin; do + d=$(sha256sum "$f" | awk '{print $1}') + echo "$f -> $d" + digests[$f]=$d + done + # Every digest must equal EXPECTED_SHA256 + fail=0 + for f in /tmp/rust.bin /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin; do + if [ "${digests[$f]}" != "$EXPECTED_SHA256" ]; then + echo "::error::$f sha256 = ${digests[$f]} (expected $EXPECTED_SHA256)" + fail=1 + fi + done + # And byte-for-byte equal to each other (paranoia — sha256 collisions + # below 2^128 work are not credible, but cmp is free). + for f in /tmp/ts.bin /tmp/php.bin /tmp/dotnet.bin; do + if ! cmp -s /tmp/rust.bin "$f"; then + echo "::error::$f differs from /tmp/rust.bin" + fail=1 + fi + done + if [ "$fail" != "0" ]; then + echo "Cross-port writer interop FAILED" + exit 1 + fi + echo "All four writers produced sha256 = $EXPECTED_SHA256" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cross-port-vectors + path: | + /tmp/rust.bin + /tmp/ts.bin + /tmp/php.bin + /tmp/dotnet.bin diff --git a/reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs b/reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs new file mode 100644 index 0000000..dac5d7c --- /dev/null +++ b/reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs @@ -0,0 +1,89 @@ +//! Cross-port test-vector parity check. +//! +//! Every PCF-SIG language port ships its own copy of the canonical 966-byte +//! signed-container vector under `implementations//pcf-sig/testdata/ +//! canonical.bin`. Each port's own test suite asserts that its writer +//! produces this byte sequence; this Rust workspace test additionally +//! asserts that the four shipped *files* are byte-identical, so that any +//! future regeneration of the reference vector cannot leave one port out of +//! sync. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// The reference vector compiled into the test binary. +const REFERENCE: &[u8] = include_bytes!("../testdata/canonical.bin"); + +/// Locate the repository root from this crate's `CARGO_MANIFEST_DIR`. +/// reference/PCF-SIG-v1.0 → repository root is two levels up. +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("PCF-SIG-v1.0 crate has a parent (reference/)") + .parent() + .expect("reference/ has a parent (repo root)") + .to_path_buf() +} + +fn read_port_vector(rel: &str) -> Vec { + let path = repo_root().join(rel); + fs::read(&path).unwrap_or_else(|e| { + panic!( + "failed to read {}: {e}\n\ + every PCF-SIG language port MUST ship a copy of the canonical \ + test vector identical to reference/PCF-SIG-v1.0/testdata/canonical.bin", + path.display(), + ) + }) +} + +fn assert_byte_identical(label: &str, port: &[u8]) { + assert_eq!( + port.len(), + REFERENCE.len(), + "{label} ships canonical.bin of length {} bytes; reference is {} bytes", + port.len(), + REFERENCE.len(), + ); + if port != REFERENCE { + // Find the first differing byte to give a precise diagnostic without + // dumping ~1 KiB of binary into the panic message. + let first_diff = port + .iter() + .zip(REFERENCE.iter()) + .position(|(a, b)| a != b) + .unwrap_or(REFERENCE.len()); + panic!( + "{label} canonical.bin diverges from reference at offset {first_diff}: \ + port byte = 0x{:02x}, reference byte = 0x{:02x}", + port.get(first_diff).copied().unwrap_or(0), + REFERENCE.get(first_diff).copied().unwrap_or(0), + ); + } +} + +#[test] +fn typescript_port_testdata_matches_reference() { + let port = read_port_vector("implementations/ts/pcf-sig/testdata/canonical.bin"); + assert_byte_identical("TypeScript port", &port); +} + +#[test] +fn php_port_testdata_matches_reference() { + let port = read_port_vector("implementations/php/pcf-sig/testdata/canonical.bin"); + assert_byte_identical("PHP port", &port); +} + +#[test] +fn dotnet_port_testdata_matches_reference() { + let port = read_port_vector("implementations/dotnet/pcf-sig/testdata/canonical.bin"); + assert_byte_identical(".NET port", &port); +} + +/// Sanity: the reference itself is the canonical 966-byte vector we expect. +/// Catches a regenerated reference that drifted from the spec test-vector +/// section. +#[test] +fn reference_has_canonical_length() { + assert_eq!(REFERENCE.len(), 966); +}