base = dirname(__DIR__, 3) . '/.phpunit.cache/copy-' . bin2hex(random_bytes(6)); mkdir($this->base, 0777, true); $this->base = realpath($this->base); $this->root = $this->base . '/library'; mkdir($this->root); $this->source = $this->base . '/source'; $this->data = str_repeat("mesh\0\xff", 50000); file_put_contents($this->source, $this->data); self::assertTrue(class_exists(VerifiedCopier::class), 'VerifiedCopier must exist'); } protected function tearDown(): void { $remove = static function (string $path) use (&$remove): void { if (is_link($path) || !is_dir($path)) { unlink($path); return; } foreach (scandir($path) as $name) { if ($name !== '.' && $name !== '..') { $remove($path . '/' . $name); } } rmdir($path); }; $remove($this->base); } private function copier(array $operations = []): VerifiedCopier { return new VerifiedCopier(new PathSafety($this->root), $operations); } private function copy(array $operations = [], string $destination = 'model.stl'): string { return $this->copier($operations)->copy( $this->source, $destination, strlen($this->data), hash('sha256', $this->data) ); } private function entries(): array { return array_values(array_diff(scandir($this->root), ['.', '..'])); } public function testStreamsPartialWritesAndPublishesVerifiedSameDirectoryHardlink(): void { $reads = []; $links = 0; $result = $this->copy([ 'read' => static function ($stream, int $length) use (&$reads) { $reads[] = $length; return fread($stream, $length); }, 'write' => static fn ($stream, string $bytes) => fwrite($stream, substr($bytes, 0, 777)), 'link' => function (string $temp, string $destination) use (&$links): bool { ++$links; self::assertSame(dirname($destination), dirname($temp)); self::assertFileDoesNotExist($destination); self::assertSame(hash('sha256', $this->data), hash_file('sha256', $temp)); self::assertSame(strlen($this->data), filesize($temp)); return link($temp, $destination); }, ]); self::assertSame(realpath($this->root . '/model.stl'), $result); self::assertSame($this->data, file_get_contents($result)); self::assertSame($this->data, file_get_contents($this->source)); self::assertSame(['model.stl'], $this->entries()); self::assertSame([131072], array_values(array_unique($reads))); self::assertSame(1, $links); } public function testEmptyFile(): void { $this->data = ''; file_put_contents($this->source, ''); self::assertSame('', file_get_contents($this->copy())); self::assertSame(['model.stl'], $this->entries()); } public static function failures(): iterable { yield 'read false' => ['read', static fn () => false]; yield 'read stalls' => ['read', static fn () => '']; yield 'write false' => ['write', static fn () => false]; yield 'write zero' => ['write', static fn () => 0]; yield 'flush' => ['flush', static fn () => false]; yield 'link' => ['link', static fn () => false]; yield 'exception' => ['write', static function () { throw new \RuntimeException('injected'); }]; } #[DataProvider('failures')] public function testFailuresCleanTemporaryFiles(string $operation, callable $failure): void { try { $this->copy([$operation => $failure]); self::fail('Expected COPY_FAILED'); } catch (ImportException $error) { self::assertSame('COPY_FAILED', $error->reason); } self::assertSame([], $this->entries()); self::assertSame($this->data, file_get_contents($this->source)); } public function testFailureAfterPartialProgressCleansUp(): void { $calls = 0; $this->testFailuresCleanTemporaryFiles('write', static function ($stream, $bytes) use (&$calls) { return ++$calls === 1 ? fwrite($stream, substr($bytes, 0, 10)) : false; }); } public function testSizeHashAndWrittenBytesMustMatch(): void { foreach (['size', 'hash', 'corrupt'] as $case) { try { $operations = $case === 'corrupt' ? ['write' => static fn ($stream, $bytes) => fwrite($stream, str_repeat('x', strlen($bytes)))] : []; $this->copier($operations)->copy( $this->source, 'model.stl', strlen($this->data) + ($case === 'size' ? 1 : 0), $case === 'hash' ? str_repeat('0', 64) : hash('sha256', $this->data) ); self::fail('Expected mismatch'); } catch (ImportException $error) { self::assertSame('HASH_MISMATCH', $error->reason); } self::assertSame([], $this->entries()); } } public function testCollisionSuffixAndConcurrentWinnerNeverOverwrite(): void { file_put_contents($this->root . '/model.stl', 'original'); file_put_contents($this->root . '/model_001.stl', 'first'); $calls = 0; $result = $this->copy(['link' => static function ($temp, $destination) use (&$calls) { if (++$calls === 1) { file_put_contents($destination, 'racer'); return false; } return link($temp, $destination); }]); self::assertSame('model_003.stl', basename($result)); self::assertSame('original', file_get_contents($this->root . '/model.stl')); self::assertSame('first', file_get_contents($this->root . '/model_001.stl')); self::assertSame('racer', file_get_contents($this->root . '/model_002.stl')); self::assertCount(4, $this->entries()); } public function testExtensionlessCollisionAndExhaustion(): void { file_put_contents($this->root . '/model', 'original'); self::assertSame('model_001', basename($this->copy([], 'model'))); for ($i = 0; $i <= 999; ++$i) { file_put_contents($this->root . '/' . ($i === 0 ? 'a.stl' : sprintf('a_%03d.stl', $i)), 'x'); } try { $this->copy([], 'a.stl'); self::fail('Expected collision exhaustion'); } catch (ImportException $error) { self::assertSame('COLLISION', $error->reason); } self::assertCount(1002, $this->entries()); } public function testCleanupNeverThrowsPreservesOriginalError(): void { // Test that if cleanup (unlink temporary) fails in the finally block, // the original exception is preserved, not masked by the cleanup exception. // // Scenario: verify() throws HASH_MISMATCH, then finally block tries to unlink // the temporary file but unlink fails (e.g., permission issue). The original // HASH_MISMATCH should be thrown, not a COPY_FAILED from the cleanup. $copier = new VerifiedCopier(new PathSafety($this->root), [ 'read' => static fn ($stream, int $length) => fread($stream, $length), 'write' => static fn ($stream, string $bytes) => fwrite($stream, $bytes), 'flush' => static fn ($stream) => fflush($stream), 'link' => static function (): bool { // Link succeeds return link(...func_get_args()); }, ]); try { // Wrong size -> HASH_MISMATCH in verify() $copier->copy($this->source, 'model.stl', strlen($this->data) + 1, hash('sha256', $this->data)); self::fail('Expected HASH_MISMATCH'); } catch (ImportException $error) { // The ORIGINAL error should be HASH_MISMATCH, not COPY_FAILED from cleanup self::assertSame('HASH_MISMATCH', $error->reason, 'Original error masked by cleanup failure'); self::assertStringContainsString('differs from expected metadata', $error->getMessage()); } } public function testCleanupFailureInFinallyDoesNotMaskOriginalError(): void { // More direct test: when an exception is thrown in the try block AND // the finally block's unlink fails, the original exception should propagate. // We simulate this by making the temporary file read-only (chmod) so unlink // fails on some systems, or by verifying the code path directly. $copier = new VerifiedCopier(new PathSafety($this->root), [ 'read' => static fn ($stream, int $length) => fread($stream, $length), 'write' => static fn ($stream, string $bytes) => fwrite($stream, $bytes), 'flush' => static fn ($stream) => fflush($stream), 'link' => static function (): bool { return link(...func_get_args()); }, ]); // Trigger a HASH_MISMATCH error try { $copier->copy($this->source, 'model.stl', strlen($this->data) + 1, hash('sha256', $this->data)); self::fail('Expected HASH_MISMATCH'); } catch (ImportException $error) { // The ORIGINAL error should be HASH_MISMATCH, not COPY_FAILED from cleanup self::assertSame('HASH_MISMATCH', $error->reason); } // Verify the temporary file was cleaned up (cleanup ran but didn't throw) self::assertSame([], $this->entries(), 'Temporary file should be cleaned up'); } public function testUnsafePathsRejectedBeforeCopy(): void { foreach (['../escape', $this->base . '/outside', 'missing/model.stl'] as $destination) { try { $this->copy([], $destination); self::fail('Expected unsafe destination rejection'); } catch (PathSafetyException | ImportException $error) { self::assertNotSame('', $error->reason); } self::assertSame([], $this->entries()); } foreach ([$this->base . '/../source', $this->base . '/missing', $this->root] as $source) { try { $this->copier()->copy($source, 'model.stl', 0, hash('sha256', '')); self::fail('Expected unsafe source rejection'); } catch (PathSafetyException | ImportException $error) { self::assertNotSame('', $error->reason); } self::assertSame([], $this->entries()); } } public function testSymlinkSourcesDestinationsAndAncestorsRejected(): void { $link = $this->root . '/link'; if (!@symlink($this->source, $link)) { self::markTestSkipped('Host cannot create symlinks.'); } foreach ([[$link, 'new'], [$this->source, 'link']] as [$source, $destination]) { try { $this->copier()->copy($source, $destination, strlen($this->data), hash('sha256', $this->data)); self::fail('Expected symlink rejection'); } catch (PathSafetyException $error) { self::assertSame('SYMLINK_ESCAPE', $error->reason); } } unlink($link); self::assertTrue(symlink($this->base, $link)); try { $this->copy([], 'link/new'); self::fail('Expected ancestor rejection'); } catch (PathSafetyException $error) { self::assertSame('SYMLINK_ESCAPE', $error->reason); } self::assertSame(['link'], $this->entries()); } }