From 2202804b1575b318969902b29c44b6d35221b4cf Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:11:50 -0500 Subject: [PATCH] feat: add package checksum validation --- src/Package/PackageChecksum.php | 60 +++++++++++++++++ src/Package/PackageValidator.php | 15 ++++- tests/Unit/Package/PackageChecksumTest.php | 72 +++++++++++++++++++++ tests/Unit/Package/PackageValidatorTest.php | 25 ++++++- 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/Package/PackageChecksum.php create mode 100644 tests/Unit/Package/PackageChecksumTest.php diff --git a/src/Package/PackageChecksum.php b/src/Package/PackageChecksum.php new file mode 100644 index 0000000..1108d13 --- /dev/null +++ b/src/Package/PackageChecksum.php @@ -0,0 +1,60 @@ + $records Package records. + */ + public static function records( array $records ): string { + return 'sha256:' . hash( 'sha256', self::canonicalJson( $records ) ); + } + + /** + * @param array $records Package records. + * @param string $checksum Expected checksum. + */ + public static function verifyRecords( array $records, string $checksum ): bool { + return hash_equals( self::records( $records ), $checksum ); + } + + /** + * @param mixed $value Value to encode. + */ + private static function canonicalJson( $value ): string { + $normalized = self::sortKeys( $value ); + $json = wp_json_encode( $normalized ); + + if ( false === $json ) { + throw new \RuntimeException( 'Unable to encode package records for checksum.' ); + } + + return $json; + } + + /** + * @param mixed $value Value to normalize. + * + * @return mixed + */ + private static function sortKeys( $value ) { + if ( ! is_array( $value ) ) { + return $value; + } + + if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) { + ksort( $value ); + } + + foreach ( $value as $key => $child ) { + $value[ $key ] = self::sortKeys( $child ); + } + + return $value; + } +} diff --git a/src/Package/PackageValidator.php b/src/Package/PackageValidator.php index 5760b8e..06d426b 100644 --- a/src/Package/PackageValidator.php +++ b/src/Package/PackageValidator.php @@ -51,8 +51,21 @@ final class PackageValidator { $errors[] = 'checksums must be an object.'; } + $record_bucket_errors = array(); + if ( isset( $data['manifest'], $data['records'] ) && is_array( $data['manifest'] ) && is_array( $data['records'] ) ) { - $errors = array_merge( $errors, $this->validateRecordBuckets( $data['manifest'], $data['records'] ) ); + $record_bucket_errors = $this->validateRecordBuckets( $data['manifest'], $data['records'] ); + $errors = array_merge( $errors, $record_bucket_errors ); + } + + if ( + array() === $record_bucket_errors + && isset( $data['records'], $data['checksums']['records'] ) + && is_array( $data['records'] ) + && is_string( $data['checksums']['records'] ) + && ! PackageChecksum::verifyRecords( $data['records'], $data['checksums']['records'] ) + ) { + $errors[] = 'checksums.records does not match records payload.'; } return array() === $errors ? PackageValidationResult::valid() : PackageValidationResult::invalid( $errors ); diff --git a/tests/Unit/Package/PackageChecksumTest.php b/tests/Unit/Package/PackageChecksumTest.php new file mode 100644 index 0000000..867cc95 --- /dev/null +++ b/tests/Unit/Package/PackageChecksumTest.php @@ -0,0 +1,72 @@ + array( + array( + 'title' => 'Example', + 'id' => 123, + ), + ), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ); + + self::assertSame( + PackageChecksum::records( $records ), + PackageChecksum::records( $records ) + ); + self::assertStringStartsWith( 'sha256:', PackageChecksum::records( $records ) ); + } + + public function test_it_canonicalizes_associative_key_order(): void { + $records = array( + 'posts' => array( + array( + 'title' => 'Example', + 'id' => 123, + ), + ), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ); + $reordered = array( + 'media' => array(), + 'custom_post_types' => array(), + 'terms' => array(), + 'posts' => array( + array( + 'id' => 123, + 'title' => 'Example', + ), + ), + ); + + self::assertSame( PackageChecksum::records( $records ), PackageChecksum::records( $reordered ) ); + } + + public function test_it_verifies_record_checksums(): void { + $records = array( + 'posts' => array( + array( + 'id' => 123, + ), + ), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ); + $checksum = PackageChecksum::records( $records ); + + self::assertTrue( PackageChecksum::verifyRecords( $records, $checksum ) ); + self::assertFalse( PackageChecksum::verifyRecords( $records, 'sha256:not-real' ) ); + } +} diff --git a/tests/Unit/Package/PackageValidatorTest.php b/tests/Unit/Package/PackageValidatorTest.php index f3b0ee9..bc6aae3 100644 --- a/tests/Unit/Package/PackageValidatorTest.php +++ b/tests/Unit/Package/PackageValidatorTest.php @@ -3,6 +3,7 @@ namespace WPContentSync\Tests\Unit\Package; use PHPUnit\Framework\TestCase; +use WPContentSync\Package\PackageChecksum; use WPContentSync\Package\PackageValidator; class PackageValidatorTest extends TestCase { @@ -53,6 +54,16 @@ class PackageValidatorTest extends TestCase { self::assertSame( array( 'manifest.posts must match records.posts count.' ), $result->errors() ); } + public function test_it_rejects_invalid_record_checksums(): void { + $package = $this->validPackage(); + $package['checksums']['records'] = 'sha256:wrong'; + + $result = ( new PackageValidator() )->validate( $package ); + + self::assertFalse( $result->isValid() ); + self::assertSame( array( 'checksums.records does not match records payload.' ), $result->errors() ); + } + /** * @return array */ @@ -86,7 +97,19 @@ class PackageValidatorTest extends TestCase { 'custom_post_types' => array(), ), 'checksums' => array( - 'records' => 'sha256:abc123', + 'records' => PackageChecksum::records( + array( + 'posts' => array( + array( + 'id' => 123, + 'type' => 'post', + ), + ), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ) + ), ), ); }