Files
MeshVault/tests/Unit/Indexer/StreamingHasherTest.php

261 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\StreamingHasher;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class StreamingHasherTest extends TestCase
{
private string $root;
private string $path;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/meshvault-hash-' . bin2hex(random_bytes(8));
mkdir($this->root);
$this->path = $this->root . '/source';
file_put_contents($this->path, str_repeat('abc', 100000));
self::assertTrue(class_exists(StreamingHasher::class), 'StreamingHasher must exist');
}
protected function tearDown(): void
{
foreach (scandir($this->root) as $name) {
if ($name !== '.' && $name !== '..') {
unlink($this->root . '/' . $name);
}
}
rmdir($this->root);
}
private function hasher(?callable $observer = null, array $hooks = []): StreamingHasher
{
return new StreamingHasher(new PathSafety($this->root), $observer, $hooks);
}
public static function sizes(): iterable
{
foreach ([0, 1, 131072, 131073, 524305] as $size) {
yield (string) $size => [$size];
}
}
#[DataProvider('sizes')]
public function testDigestAndBoundedReads(int $size): void
{
file_put_contents($this->path, str_repeat('x', $size));
$observed = 0;
$hasher = $this->hasher(function (int $bytes) use (&$observed): void {
self::assertGreaterThan($observed, $bytes);
self::assertLessThanOrEqual(131072, $bytes - $observed);
$observed = $bytes;
}, ['read' => static function ($stream, int $length): string|false {
self::assertSame(131072, $length);
return fread($stream, $length);
}]);
self::assertSame(hash_file('sha256', $this->path), $hasher->hash($this->path));
self::assertSame($size, $observed);
}
public static function failures(): iterable
{
yield 'open' => ['open', false, 'HASH_OPEN_FAILED'];
yield 'read' => ['read', false, 'HASH_READ_FAILED'];
yield 'stalled read' => ['read', '', 'HASH_READ_FAILED'];
yield 'path stat' => ['stat', false, 'HASH_STAT_FAILED'];
yield 'handle stat' => ['fstat', false, 'HASH_STAT_FAILED'];
}
#[DataProvider('failures')]
public function testIoFailures(string $hook, mixed $result, string $reason): void
{
$this->rejects(fn () => $this->hasher(null, [$hook => fn () => $result])->hash($this->path), $reason);
}
public function testThrownIoFailurePreservesCauseAndClosesStream(): void
{
$stream = null;
$cause = new RuntimeException('injected read failure');
$hasher = $this->hasher(null, [
'open' => function (string $path) use (&$stream) {
return $stream = fopen($path, 'rb');
},
'read' => static fn () => throw $cause,
]);
$error = $this->rejects(fn () => $hasher->hash($this->path), 'HASH_READ_FAILED');
self::assertSame($cause, $error->getPrevious());
self::assertFalse(is_resource($stream));
}
public static function metadataChanges(): iterable
{
foreach (['stat', 'fstat'] as $hook) {
foreach (['dev', 'ino', 'mode', 'size', 'mtime', 'ctime'] as $field) {
yield "$hook $field" => [$hook, $field];
}
}
}
#[DataProvider('metadataChanges')]
public function testEndMetadataChanges(string $hook, string $field): void
{
$calls = 0;
$hasher = $this->hasher(null, [$hook => static function ($target) use ($hook, $field, &$calls) {
$stat = $hook($target);
if (++$calls === 2) {
++$stat[$field];
}
return $stat;
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testOpenHandleMustMatchPreOpenPath(): void
{
$hasher = $this->hasher(null, ['fstat' => static function ($stream) {
$stat = fstat($stream);
++$stat['ino'];
return $stat;
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testDirectoryRejected(): void
{
$this->rejects(fn () => $this->hasher()->hash($this->root), 'HASH_NOT_REGULAR_FILE');
}
public static function mutations(): iterable
{
yield 'append' => ['append'];
yield 'truncate' => ['truncate'];
yield 'same size with changed timestamp' => ['rewrite'];
yield 'replacement' => ['replace'];
}
#[DataProvider('mutations')]
public function testRealMutation(string $mutation): void
{
$done = false;
$hasher = $this->hasher(function () use ($mutation, &$done): void {
if ($done) {
return;
}
$done = true;
if ($mutation === 'append') {
file_put_contents($this->path, 'extra', FILE_APPEND);
} elseif ($mutation === 'truncate') {
file_put_contents($this->path, 'short');
} elseif ($mutation === 'rewrite') {
file_put_contents($this->path, str_repeat('z', 300000));
touch($this->path, time() + 10);
} else {
if (!@rename($this->path, $this->root . '/old')) {
self::markTestSkipped('Host cannot rename an open file.');
}
file_put_contents($this->path, str_repeat('z', 300000));
touch($this->path, time() + 10);
}
});
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testUnsafeAndMissingSources(): void
{
foreach (
[
'' => 'INVALID_PATH',
'php://memory' => 'INVALID_PATH',
$this->root . '/../source' => 'PATH_TRAVERSAL',
$this->root . '/missing' => 'PATH_NOT_FOUND',
] as $path => $reason
) {
$this->rejects(fn () => $this->hasher()->hash($path), $reason);
}
}
public static function links(): iterable
{
yield 'file' => [false, false];
yield 'ancestor' => [true, false];
yield 'mid-read replacement' => [false, true];
}
#[DataProvider('links')]
public function testSymlinks(bool $ancestor, bool $duringRead): void
{
$link = $this->root . '/link';
if (!@symlink($ancestor ? $this->root : $this->path, $link)) {
self::markTestSkipped('Host cannot create symlinks.');
}
if (!$duringRead) {
$this->rejects(
fn () => $this->hasher()->hash($link . ($ancestor ? '/source' : '')),
'SYMLINK_ESCAPE'
);
return;
}
unlink($link);
file_put_contents($this->root . '/target', 'replacement');
$done = false;
$hasher = $this->hasher(function () use (&$done): void {
if (!$done) {
$done = true;
if (!@rename($this->path, $this->root . '/old')) {
self::markTestSkipped('Host cannot rename an open file.');
}
self::assertTrue(symlink($this->root . '/target', $this->path));
}
});
$this->rejects(fn () => $hasher->hash($this->path), 'SYMLINK_ESCAPE');
}
public function testEndStatFailure(): void
{
foreach (['stat', 'fstat'] as $operation) {
$calls = 0;
$hasher = $this->hasher(null, [$operation => static function ($target) use ($operation, &$calls) {
return ++$calls === 2 ? false : $operation($target);
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_STAT_FAILED');
}
}
public function testObserverFailureClosesStream(): void
{
$stream = null;
$cause = new RuntimeException('observer');
$hasher = $this->hasher(static fn () => throw $cause, [
'open' => function (string $path) use (&$stream) {
return $stream = fopen($path, 'rb');
},
]);
try {
$hasher->hash($this->path);
self::fail('Expected observer exception');
} catch (RuntimeException $error) {
self::assertSame($cause, $error);
}
self::assertFalse(is_resource($stream));
}
private function rejects(callable $operation, string $reason): ImportException
{
try {
$operation();
self::fail('Expected ' . $reason);
} catch (ImportException $error) {
self::assertSame($reason, $error->reason);
self::assertNotSame('', $error->getMessage());
return $error;
}
}
}