Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions .github/workflows/cross-port-interop.yml
Original file line number Diff line number Diff line change
@@ -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'
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(GITHUB_WORKSPACE)/implementations/dotnet/pcf-sig/src/Pcf.Sig/Pcf.Sig.csproj" />
</ItemGroup>
</Project>
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
89 changes: 89 additions & 0 deletions reference/PCF-SIG-v1.0/tests/cross_port_testdata.rs
Original file line number Diff line number Diff line change
@@ -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/<lang>/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<u8> {
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);
}
Loading