root = sys_get_temp_dir() . '/php_s3_st_' . bin2hex(random_bytes(6)); mkdir($this->root, 0750, true); $this->storage = new LocalFilesystemStorage($this->root); } protected function tearDown(): void { $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST, ); foreach ($it as $file) { $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); } @rmdir($this->root); } public function testCommitAndOpenPartRoundTrip(): void { $uploadId = str_repeat('a', 32); $staged = $this->stage('hello part'); $this->storage->commitPart($uploadId, 3, $staged); $fh = $this->storage->openPart($uploadId, 3); self::assertSame('hello part', stream_get_contents($fh)); fclose($fh); } public function testCommitPartOverwritesSamePartNumber(): void { $uploadId = str_repeat('b', 32); $this->storage->commitPart($uploadId, 1, $this->stage('first')); $this->storage->commitPart($uploadId, 1, $this->stage('second')); $fh = $this->storage->openPart($uploadId, 1); self::assertSame('second', stream_get_contents($fh)); fclose($fh); } public function testAssemblePartsConcatenatesInOrderAndHashes(): void { $uploadId = str_repeat('c', 32); foreach ([1 => 'AAA', 2 => 'BBB', 3 => 'CC'] as $n => $data) { $this->storage->commitPart($uploadId, $n, $this->stage($data)); } $assembled = $this->storage->assembleParts($uploadId, [1, 2, 3], 8); self::assertSame(8, $assembled->size); self::assertSame('AAABBBCC', file_get_contents($assembled->tmpPath)); self::assertSame(md5('AAABBBCC'), $assembled->md5); self::assertSame(hash('sha256', 'AAABBBCC'), $assembled->sha256); $this->storage->discard($assembled); } public function testAssemblePartsWithoutAllPartsThrowsInvalidPart(): void { $uploadId = str_repeat('d', 32); $this->storage->commitPart($uploadId, 1, $this->stage('x')); try { $this->storage->assembleParts($uploadId, [1, 2], 2); self::fail('expected InvalidPart'); } catch (S3Exception $e) { self::assertSame('InvalidPart', $e->errorCode); } } public function testAssembleSizeMismatchThrowsIncompleteBody(): void { $uploadId = str_repeat('e', 32); $this->storage->commitPart($uploadId, 1, $this->stage('xyz')); try { $this->storage->assembleParts($uploadId, [1], 99); self::fail('expected IncompleteBody'); } catch (S3Exception $e) { self::assertSame('IncompleteBody', $e->errorCode); } } public function testMissingPartFileThrowsInvalidPart(): void { try { $this->storage->openPart(str_repeat('f', 32), 1); self::fail('expected InvalidPart'); } catch (S3Exception $e) { self::assertSame('InvalidPart', $e->errorCode); } } public function testTraversalUploadIdRejected(): void { foreach (['../../etc', str_repeat('A', 32), str_repeat('a', 31), ''] as $bad) { try { $this->storage->commitPart($bad, 1, $this->stage('x')); self::fail("expected NoSuchUpload for '{$bad}'"); } catch (S3Exception $e) { self::assertSame('NoSuchUpload', $e->errorCode); } } } public function testPartNumberOutOfRangeRejected(): void { foreach ([0, 10001] as $bad) { try { $this->storage->commitPart(str_repeat('a', 32), $bad, $this->stage('x')); self::fail("expected NoSuchUpload for part {$bad}"); } catch (S3Exception $e) { self::assertSame('NoSuchUpload', $e->errorCode); } } } public function testDeletePartsIsIdempotent(): void { $uploadId = str_repeat('a', 32); $this->storage->commitPart($uploadId, 1, $this->stage('x')); $this->storage->deleteParts($uploadId); $this->storage->deleteParts($uploadId); // no error self::assertDirectoryDoesNotExist($this->root . '/parts/' . $uploadId); } public function testCompositeEtagMatchesS3Algorithm(): void { // md5(binary md5-of-empty + binary md5-of-"test") . '-2' self::assertSame( 'f666c301a43456b66b9f3a47f696d391-2', MultipartHandler::compositeEtag([ 'd41d8cd98f00b204e9800998ecf8427e', '098f6bcd4621d373cade4e832627b4f6', ]), ); } public function testCompositeEtagRejectsCorruptPartEtag(): void { $this->expectException(S3Exception::class); MultipartHandler::compositeEtag(['zzzz']); } private function stage(string $data): StagedObject { $fh = fopen('php://temp', 'r+b'); fwrite($fh, $data); rewind($fh); return $this->storage->stage($fh, strlen($data)); } }