Ctrl K
ComunidadGuías…

tests/Unit/ChunkedDecoderTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

declare(strict_types=1);

/**
 * Copyright 2026 codaipro — Lucky Yaduvanshi (https://luckyyaduvanshi.in)
 * Original source: https://github.com/Luckyyaduvanshiofficial/php-s3
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

namespace PhpS3\Tests\Unit;

use PhpS3\Auth\AuthContext;
use PhpS3\Auth\SigningKey;
use PhpS3\S3\ChunkedDecoder;
use PhpS3\S3\Exception\S3Exception;
use PHPUnit\Framework\TestCase;

/** aws-chunked framing + per-chunk signature chain against a real stream filter. */
final class ChunkedDecoderTest extends TestCase
{
    private const SECRET = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY';
    private const REGION = 'us-east-1';
    private const PAYLOAD_SIGNED = 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD';
    private const PAYLOAD_UNSIGNED = 'STREAMING-UNSIGNED-PAYLOAD-TRAILER';

    private string $amzDate;
    private string $shortDate;
    private string $scope;
    private string $key;
    private string $seed;

    protected function setUp(): void
    {
        $this->amzDate = gmdate('Ymd\THis\Z');
        $this->shortDate = substr($this->amzDate, 0, 8);
        $this->scope = $this->shortDate . '/' . self::REGION . '/s3/aws4_request';
        $this->key = SigningKey::derive(self::SECRET, $this->shortDate, self::REGION, 's3');
        $this->seed = hash('sha256', 'seed-signature');
    }

    public function testSignedMultiChunkDecodes(): void
    {
        $body = $this->signedBody(['hello ', 'world'], trailer: false);

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed);
        $decoded = stream_get_contents($stream);
        fclose($stream);

        self::assertSame('hello world', $decoded);
        ChunkedDecoder::assertValid($state, 11); // no exception
    }

    public function testSignedChunkWithTrailerDecodes(): void
    {
        $body = $this->signedBody(['payload-bytes'], trailer: true);

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed);
        $decoded = stream_get_contents($stream);
        fclose($stream);

        self::assertSame('payload-bytes', $decoded);
        ChunkedDecoder::assertValid($state, 13);
    }

    public function testUnsignedTrailerFramingDecodes(): void
    {
        $body = "6\r\nabcdef\r\n5\r\n12345\r\n0\r\nx-amz-checksum-crc32:abcd1234\r\n\r\n";

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_UNSIGNED, null);
        $decoded = stream_get_contents($stream);
        fclose($stream);

        self::assertSame('abcdef12345', $decoded);
        ChunkedDecoder::assertValid($state, 11);
    }

    public function testTamperedChunkDataRejected(): void
    {
        $body = $this->signedBody(['hello ', 'world'], trailer: false);
        $body = str_replace('world', 'WORLD', $body);

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed);
        stream_get_contents($stream);
        fclose($stream);

        try {
            ChunkedDecoder::assertValid($state, 11);
            self::fail('expected SignatureDoesNotMatch');
        } catch (S3Exception $e) {
            self::assertSame('SignatureDoesNotMatch', $e->errorCode);
        }
    }

    public function testTamperedFinalChunkSignatureRejected(): void
    {
        $body = $this->signedBody(['hello'], trailer: false);
        $body = preg_replace('/0;chunk-signature=[0-9a-f]{64}/', '0;chunk-signature=' . str_repeat('0', 64), $body);

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed);
        stream_get_contents($stream);
        fclose($stream);

        try {
            ChunkedDecoder::assertValid($state, 5);
            self::fail('expected SignatureDoesNotMatch');
        } catch (S3Exception $e) {
            self::assertSame('SignatureDoesNotMatch', $e->errorCode);
        }
    }

    public function testMissingChunkSignatureInSignedModeRejected(): void
    {
        $body = "5\r\nhello\r\n0;chunk-signature=" . hash('sha256', 'x') . "\r\n\r\n";

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed);
        stream_get_contents($stream);
        fclose($stream);

        self::assertSame('SignatureDoesNotMatch', $state->error);
    }

    public function testTruncatedStreamRejected(): void
    {
        $body = $this->signedBody(['hello'], trailer: false);
        $body = substr($body, 0, (int) (strpos($body, "\r\n0;") ?: 10) + 3); // cut before terminal chunk

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed);
        stream_get_contents($stream);
        fclose($stream);

        self::assertNotNull($state->error);
        self::assertStringContainsString('IncompleteBody', $state->error);
    }

    public function testWrongDeclaredLengthRejected(): void
    {
        $body = $this->signedBody(['hello'], trailer: false);

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_SIGNED, $this->seed);
        stream_get_contents($stream);
        fclose($stream);

        try {
            ChunkedDecoder::assertValid($state, 999);
            self::fail('expected IncompleteBody');
        } catch (S3Exception $e) {
            self::assertSame('IncompleteBody', $e->errorCode);
        }
    }

    public function testMalformedChunkHeaderRejected(): void
    {
        $body = "not-hex-size\r\ndata\r\n";

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_UNSIGNED, null);
        stream_get_contents($stream);
        fclose($stream);

        self::assertNotNull($state->error);
    }

    public function testChunkSizeOverflowRejected(): void
    {
        // 16 hex digits exceed PHP_INT_MAX; the size must be rejected rather
        // than overflowing the int cast and misparsing the remainder.
        $body = "ffffffffffffffff\r\ndata\r\n";

        [$stream, $state] = $this->wrap($body, self::PAYLOAD_UNSIGNED, null);
        stream_get_contents($stream);
        fclose($stream);

        self::assertNotNull($state->error);
        self::assertStringContainsString('malformed chunk header', $state->error);
    }

    public function testSignedChunkWithByteByByteDelivery(): void
    {
        if (!in_array('test_byte_filter', stream_get_filters(), true)) {
            stream_filter_register('test_byte_filter', ChunkedTestByteFilter::class);
        }
        $body = $this->signedBody(['hello ', 'world'], trailer: false);

        $auth = new AuthContext(
            accessKeyId: 'AKIAEXAMPLEKEY000EX',
            secret: self::SECRET,
            ownerId: 1,
            allowedBuckets: null,
            region: self::REGION,
            shortDate: $this->shortDate,
            payloadHash: self::PAYLOAD_SIGNED,
            isPresigned: false,
            chunkSeedSignature: $this->seed,
            amzDate: $this->amzDate,
        );

        $raw = fopen('php://temp', 'r+b');
        fwrite($raw, $body);
        rewind($raw);

        stream_filter_append($raw, 'test_byte_filter', STREAM_FILTER_READ);

        [$stream, $state] = ChunkedDecoder::wrap($raw, $auth);
        $decoded = stream_get_contents($stream);
        fclose($stream);

        self::assertSame('hello world', $decoded);
        ChunkedDecoder::assertValid($state, 11);
    }

    /* --------------------------------------------------------- helpers */

    /** @param list<string> $parts */
    private function signedBody(array $parts, bool $trailer): string
    {
        $prev = $this->seed;
        $out = '';
        foreach ($parts as $data) {
            $sig = $this->chunkSig($data, $prev);
            $out .= sprintf('%x;chunk-signature=%s', strlen($data), $sig) . "\r\n" . $data . "\r\n";
            $prev = $sig;
        }
        $final = $this->chunkSig('', $prev);
        $out .= '0;chunk-signature=' . $final . "\r\n";
        if ($trailer) {
            $out .= "x-amz-checksum-crc32:abcd1234\r\n";
        }

        return $out . "\r\n";
    }

    private function chunkSig(string $data, string $prev): string
    {
        $stringToSign = "AWS4-HMAC-SHA256-PAYLOAD\n"
            . $this->amzDate . "\n"
            . $this->scope . "\n"
            . $prev . "\n"
            . hash('sha256', '') . "\n"
            . hash('sha256', $data);

        return hash_hmac('sha256', $stringToSign, $this->key);
    }

    /** @return array{0: resource, 1: \stdClass} */
    private function wrap(string $body, string $payloadHash, ?string $seed): array
    {
        $auth = new AuthContext(
            accessKeyId: 'AKIAEXAMPLEKEY000EX',
            secret: self::SECRET,
            ownerId: 1,
            allowedBuckets: null,
            region: self::REGION,
            shortDate: $this->shortDate,
            payloadHash: $payloadHash,
            isPresigned: false,
            chunkSeedSignature: $seed,
            amzDate: $this->amzDate,
        );

        $stream = fopen('php://temp', 'r+b');
        fwrite($stream, $body);
        rewind($stream);

        [$stream, $state] = ChunkedDecoder::wrap($stream, $auth);

        return [$stream, $state];
    }
}

/**
 * Splits stream data into 1-byte buckets to test filter resilience against packet fragmentation.
 */
final class ChunkedTestByteFilter extends \php_user_filter
{
    #[\Override]
    public function filter($in, $out, &$consumed, bool $closing): int
    {
        while ($bucket = stream_bucket_make_writeable($in)) {
            $data = (string) $bucket->data;
            $len = strlen($data);
            $consumed += $len;
            for ($i = 0; $i < $len; $i++) {
                $b = stream_bucket_new($this->stream, $data[$i]);
                stream_bucket_append($out, $b);
            }
        }

        return PSFS_PASS_ON;
    }
}