feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console
This commit is contained in:
1 parent
93d991703c
commit
9d277d9e72
48 files changed
+5147
-4
No files matched your search
@@ -0,0 +1,260 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user