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); +}