261 lines
8.9 KiB
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;
|
|
}
|
|
}
|
|
}
|