diff --git a/src/Url/MetadataUrlTransformer.php b/src/Url/MetadataUrlTransformer.php index f160338..89ea04a 100644 --- a/src/Url/MetadataUrlTransformer.php +++ b/src/Url/MetadataUrlTransformer.php @@ -25,12 +25,19 @@ final class MetadataUrlTransformer { return $value; } - if ( $this->isSerialized( $value ) ) { - // phpcs:ignore -- Required to transform serialized metadata while preserving valid string lengths. - $unserialized = unserialize( $value, array( 'allowed_classes' => false ) ); + if ( $this->looksSerialized( trim( $value ) ) && trim( $value ) !== $value ) { + return $value; + } + + if ( $this->looksSerialized( $value ) ) { + $unserialized = $this->unserializeValue( $value ); + + if ( ! $unserialized['valid'] || is_object( $unserialized['value'] ) ) { + return $value; + } // phpcs:ignore -- Required to reserialize metadata with updated string lengths. - return serialize( $this->transformValue( $unserialized, $mappings ) ); + return serialize( $this->transformValue( $unserialized['value'], $mappings ) ); } if ( $this->isJsonObjectOrArray( $value ) ) { @@ -43,6 +50,8 @@ final class MetadataUrlTransformer { return $encoded; } } + + return $value; } return $this->url_transformer->transformString( $value, $mappings ); @@ -65,23 +74,43 @@ final class MetadataUrlTransformer { return $transformed; } - private function isSerialized( string $value ): bool { - $trimmed = trim( $value ); + private function looksSerialized( string $value ): bool { + return 'N;' === $value || 'b:0;' === $value || 1 === preg_match( '/^(?:a|O|s|i|d|b):/', $value ); + } - if ( 'N;' === $trimmed ) { - return true; + /** + * @return array{valid: bool, value: mixed} + */ + private function unserializeValue( string $value ): array { + if ( 'N;' === $value ) { + return array( + 'valid' => true, + 'value' => null, + ); } - if ( 'b:0;' === $trimmed ) { - return true; + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Metadata may be stored in PHP serialized format. + $unserialized = @unserialize( $value, array( 'allowed_classes' => false ) ); + + if ( false === $unserialized && 'b:0;' !== $value ) { + return array( + 'valid' => false, + 'value' => null, + ); } - if ( ! preg_match( '/^(?:a|O|s|i|d|b):/', $trimmed ) ) { - return false; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Strict validation avoids transforming malformed serialized-looking strings. + if ( serialize( $unserialized ) !== $value ) { + return array( + 'valid' => false, + 'value' => null, + ); } - // phpcs:ignore -- Validation must parse PHP serialized metadata. - return false !== @unserialize( $trimmed, array( 'allowed_classes' => false ) ); + return array( + 'valid' => true, + 'value' => $unserialized, + ); } private function isJsonObjectOrArray( string $value ): bool { diff --git a/tests/Unit/Url/MetadataUrlTransformerTest.php b/tests/Unit/Url/MetadataUrlTransformerTest.php index 32562a6..3995a65 100644 --- a/tests/Unit/Url/MetadataUrlTransformerTest.php +++ b/tests/Unit/Url/MetadataUrlTransformerTest.php @@ -71,6 +71,51 @@ class MetadataUrlTransformerTest extends TestCase { ); } + public function test_it_leaves_invalid_serialized_strings_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + $value = 'a:1:{s:3:"url";s:37:"https://example.test/uploads/hero.jpg";} trailing'; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + + public function test_it_leaves_whitespace_wrapped_serialized_strings_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + // phpcs:ignore -- Test fixture requires native serialized metadata. + $value = ' ' . serialize( array( 'url' => 'https://example.test/uploads/hero.jpg' ) ) . ' '; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + + public function test_it_preserves_serialized_false_and_null_values(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + + self::assertSame( 'b:0;', $transformer->transformValue( 'b:0;', $this->mappings() ) ); + self::assertSame( 'N;', $transformer->transformValue( 'N;', $this->mappings() ) ); + } + + public function test_it_leaves_serialized_objects_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + $value = 'O:8:"stdClass":1:{s:3:"url";s:28:"https://example.test/inside";}'; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + + public function test_it_leaves_invalid_json_strings_unchanged(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + $value = '{"url":"https://example.test/missing"'; + + self::assertSame( $value, $transformer->transformValue( $value, $this->mappings() ) ); + } + + public function test_it_transforms_nested_json_arrays(): void { + $transformer = new MetadataUrlTransformer( new UrlTransformer() ); + + self::assertSame( + '[{"url":"https:\/\/staging.example.test\/one"},{"count":2}]', + $transformer->transformValue( '[{"url":"https:\/\/example.test\/one"},{"count":2}]', $this->mappings() ) + ); + } + public function test_it_transforms_plain_string_metadata(): void { $transformer = new MetadataUrlTransformer( new UrlTransformer() );