feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console

This commit is contained in:
Cline committed 2026-10-12 11:29:17 -05:00
1 parent 93d991703c
commit 9d277d9e72
48 files changed
+5147 -4

No files matched your search

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer\Contracts;
use MeshVault\Indexer\ScanResult;
interface FileEnumeratorInterface
{
public const DEFAULT_EXTENSIONS = ['stl', 'jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'md'];
/** @param list<string> $extensions Case-insensitive extensions without leading dots. */
public function enumerate(string $sourceRoot, array $extensions = self::DEFAULT_EXTENSIONS): ScanResult;
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer\Contracts;
/** Native-style results: failed I/O returns false; callers own and close streams. */
interface FilesystemInterface
{
public function realpath(string $path): string|false;
public function isReadable(string $path): bool;
public function isFile(string $path): bool;
public function isDir(string $path): bool;
public function isSymlink(string $path): bool;
public function getSize(string $path): int|false;
public function getMimeType(string $path): string|false;
/** @return list<string>|false Entry names (not paths), excluding dot entries; false on read failure. */
public function readDirectory(string $path): array|false;
/** @return resource|false Binary stream opened for reading. */
public function readStream(string $path);
/** @return resource|false Binary stream opened for writing, truncating existing contents. */
public function writeStream(string $path);
public function rename(string $from, string $to): bool;
public function unlink(string $path): bool;
public function mkdir(string $path, int $permissions = 0777, bool $recursive = false): bool;
public function freeSpace(string $path): float|false;
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\Contracts\FilesystemInterface;
use MeshVault\Indexer\Filesystem\PathSafety;
use RuntimeException;
/** Point-in-time manifest only: callers must prevent concurrent source-tree mutation. */
final readonly class FileEnumerator implements FileEnumeratorInterface
{
public function __construct(
private FilesystemInterface $filesystem,
private PathSafety $pathSafety,
private int $maxDepth = 128,
) {
}
public function enumerate(string $sourceRoot, array $extensions = self::DEFAULT_EXTENSIONS): ScanResult
{
$files = [];
$errors = [];
$totalBytes = 0;
$allowed = array_fill_keys(array_map('strtolower', $extensions), true);
try {
$root = $this->canonicalSource($sourceRoot);
if (!$this->filesystem->isDir($root) || !$this->filesystem->isReadable($root)) {
throw new RuntimeException('Choose an existing, readable directory.');
}
} catch (RuntimeException $error) {
return new ScanResult([], 0, [$this->scanError($sourceRoot, $error, 'Invalid source root: ')]);
}
// Explicit stack avoids PHP call-stack recursion. Root is depth zero.
$pending = [[$root, '', 0]];
while ($pending !== []) {
[$directory, $prefix, $depth] = array_pop($pending);
try {
$directory = $this->canonicalSource($directory);
if ($depth > $this->maxDepth) {
throw new RuntimeException('Maximum directory depth exceeded; use a shallower source root.');
}
if (!$this->filesystem->isReadable($directory)) {
throw new RuntimeException('Directory is not readable; check permissions.');
}
$entries = $this->filesystem->readDirectory($directory);
if ($entries === false) {
throw new RuntimeException('Cannot read directory; check permissions and availability.');
}
} catch (RuntimeException $error) {
$errors[] = $this->scanError($directory, $error);
continue;
}
foreach ($entries as $entry) {
$path = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $entry;
try {
// Directory entries must remain a single portable path component.
if ($entry === '' || $entry === '.' || strpbrk($entry, '/\\') !== false) {
throw new PathSafetyException(PathSafetyException::INVALID_PATH);
}
$relative = $prefix . $entry;
$this->pathSafety->ensureRelative($relative);
$canonical = $this->canonicalSource($path);
if ($this->filesystem->isDir($canonical)) {
$pending[] = [$canonical, $relative . '/', $depth + 1];
continue;
}
if (!isset($allowed[strtolower(pathinfo($entry, PATHINFO_EXTENSION))])) {
continue;
}
if (!$this->filesystem->isFile($canonical) || !$this->filesystem->isReadable($canonical)) {
throw new RuntimeException('Not a readable regular file; check permissions and availability.');
}
$size = $this->filesystem->getSize($canonical);
if ($size === false || $size < 0) {
throw new RuntimeException('Cannot stat file size; check permissions and availability.');
}
$mime = $this->filesystem->getMimeType($canonical);
if ($mime === false || $mime === '') {
throw new RuntimeException('Cannot detect MIME type; check permissions and availability.');
}
if ($size > PHP_INT_MAX - $totalBytes) {
throw new RuntimeException('Total byte count exceeds the supported integer range.');
}
$files[] = new ScannedFile($canonical, $relative, $size, $mime);
$totalBytes += $size;
} catch (RuntimeException $error) {
$errors[] = $this->scanError($path, $error);
}
}
}
return new ScanResult($files, $totalBytes, $errors);
}
private function canonicalSource(string $path): string
{
// Safety must inspect lexical ancestors before canonicalization can hide links.
$path = $this->pathSafety->ensureSourceSafe($path);
if ($this->filesystem->isSymlink($path)) {
throw new PathSafetyException(PathSafetyException::SYMLINK_ESCAPE);
}
$canonical = $this->filesystem->realpath($path);
if ($canonical === false) {
throw new RuntimeException('Cannot resolve source; check existence and permissions.');
}
return $canonical;
}
private function scanError(string $path, RuntimeException $error, string $prefix = ''): ScanError
{
$safetyCodes = [PathSafetyException::SYMLINK_ESCAPE, PathSafetyException::PATH_TRAVERSAL];
$code = $error instanceof PathSafetyException && in_array($error->reason, $safetyCodes, true)
? $error->reason : ScanError::SCAN_READ_ERROR;
$message = $error->getMessage();
if ($error instanceof PathSafetyException) {
$guidance = match ($error->reason) {
PathSafetyException::PATH_NOT_FOUND, PathSafetyException::NOT_DIRECTORY =>
'Choose an existing, readable directory; check the path and permissions.',
PathSafetyException::INVALID_PATH =>
'Choose a non-empty directory path without NUL bytes or URL schemes; '
. 'use forward slashes on POSIX and an absolute path for Windows drives.',
PathSafetyException::PATH_TRAVERSAL => 'Choose a path without parent (..) components.',
PathSafetyException::SYMLINK_ESCAPE => 'Choose a path with no symlinks, including its ancestors.',
default => 'Choose a directory path permitted by the path safety policy.',
};
$message .= '. ' . $guidance;
}
return new ScanError($path, $code, $prefix . $message);
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer\Filesystem;
use finfo;
use MeshVault\Indexer\Contracts\FilesystemInterface;
/** Thin local I/O adapter; path authorization is the caller's responsibility. */
final readonly class LocalFilesystem implements FilesystemInterface
{
public function realpath(string $path): string|false
{
clearstatcache(true, $path);
return realpath($path);
}
public function isReadable(string $path): bool
{
clearstatcache(true, $path);
return is_readable($path);
}
public function isFile(string $path): bool
{
clearstatcache(true, $path);
return is_file($path);
}
public function isDir(string $path): bool
{
clearstatcache(true, $path);
return is_dir($path);
}
public function isSymlink(string $path): bool
{
clearstatcache(true, $path);
return is_link($path);
}
public function getSize(string $path): int|false
{
clearstatcache(true, $path);
return @filesize($path);
}
public function getMimeType(string $path): string|false
{
return @(new finfo(FILEINFO_MIME_TYPE))->file($path);
}
public function readDirectory(string $path): array|false
{
$entries = @scandir($path);
return $entries === false ? false : array_values(array_diff($entries, ['.', '..']));
}
/** @return resource|false */
public function readStream(string $path)
{
return @fopen($path, 'rb');
}
/** @return resource|false */
public function writeStream(string $path)
{
return @fopen($path, 'wb');
}
public function rename(string $from, string $to): bool
{
return @rename($from, $to);
}
public function unlink(string $path): bool
{
return @unlink($path);
}
public function mkdir(string $path, int $permissions = 0777, bool $recursive = false): bool
{
return @mkdir($path, $permissions, $recursive);
}
public function freeSpace(string $path): float|false
{
return @disk_free_space($path);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer\Filesystem;
use MeshVault\Exception\PathSafetyException as Error;
/**
* Point-in-time checks, not race-proof authorization. Callers must control directory
* mutation and recheck before I/O; no check here makes a later open/rename atomic.
*/
final readonly class PathSafety
{
private string $libraryRoot;
public function __construct(string $libraryRoot)
{
$root = $this->ensureSourceSafe($libraryRoot);
if (!is_dir($root)) {
throw new Error(Error::NOT_DIRECTORY);
}
$this->libraryRoot = $root;
}
/** The canonical, realpath-resolved library root. */
public function libraryRoot(): string
{
return $this->libraryRoot;
}
/** Sources may be outside the managed library, but must already exist. */
public function ensureSourceSafe(string $path): string
{
// On POSIX a backslash is a filename character, not a separator. Reject it
// before normalization so lexical checks cannot inspect a different tree.
if (PHP_OS_FAMILY !== 'Windows' && str_contains($path, '\\')) {
throw new Error(Error::INVALID_PATH);
}
$path = $this->validate($path);
// Drive-relative paths can resolve differently from the lexical link-check path.
if (preg_match('~^[A-Za-z]:(?!/)~', $path) === 1) {
throw new Error(Error::INVALID_PATH);
}
$this->rejectLinks($path);
$canonical = realpath($path);
if ($canonical === false) {
throw new Error(Error::PATH_NOT_FOUND);
}
return $canonical;
}
/** Relative destinations are resolved under the library; missing suffixes are allowed. */
public function ensureDestSafe(string $path): string
{
$path = $this->validate($path);
if (!$this->isAbsolute($path)) {
$this->ensureRelative($path);
$path = $this->libraryRoot . DIRECTORY_SEPARATOR . $path;
}
$this->rejectLinks($path);
$ancestor = $path;
$suffix = [];
while (($canonical = realpath($ancestor)) === false) {
$parent = dirname($ancestor);
if ($parent === $ancestor) {
throw new Error(Error::PATH_NOT_FOUND);
}
array_unshift($suffix, basename($ancestor));
$ancestor = $parent;
}
if ($suffix !== [] && !is_dir($canonical)) {
throw new Error(Error::NOT_DIRECTORY);
}
$root = rtrim($this->libraryRoot, '/\\');
$comparison = $canonical;
if (PHP_OS_FAMILY === 'Windows') {
$root = strtolower($root);
$comparison = strtolower($comparison);
}
if ($comparison !== $root && !str_starts_with($comparison, $root . DIRECTORY_SEPARATOR)) {
throw new Error(Error::OUTSIDE_LIBRARY_ROOT);
}
return $suffix === [] ? $canonical
: rtrim($canonical, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $suffix);
}
/** Validate without rewriting the supplied relative name. */
public function ensureRelative(string $path): string
{
$normalized = $this->validate($path);
if (str_starts_with($normalized, '/') || preg_match('/^[A-Za-z]:/', $normalized)) {
throw new Error(Error::ABSOLUTE_PATH);
}
return $path;
}
private function validate(string $path): string
{
if ($path === '' || str_contains($path, "\0") || str_contains($path, '://')) {
throw new Error(Error::INVALID_PATH);
}
$path = str_replace('\\', '/', $path);
foreach (explode('/', $path) as $index => $part) {
if ($part === '..') {
throw new Error(Error::PATH_TRAVERSAL);
}
// Win32 trims trailing dots/spaces and interprets colons as stream names.
$drive = $index === 0 && preg_match('/^[A-Za-z]:/', $part) === 1;
if (
PHP_OS_FAMILY === 'Windows'
&& (($part !== '.' && preg_match('/[. ]$/', $part))
|| (str_contains($part, ':') && !$drive))
) {
throw new Error(Error::INVALID_PATH);
}
}
return $path;
}
private function isAbsolute(string $path): bool
{
return str_starts_with($path, '/')
|| (PHP_OS_FAMILY === 'Windows' && preg_match('~^[A-Za-z]:/~', $path) === 1);
}
/** Inspect the lexical path before realpath can hide a link, including dangling links. */
private function rejectLinks(string $path): void
{
if (!$this->isAbsolute($path)) {
$path = getcwd() . '/' . $path;
}
$path = rtrim($path, '/');
while (true) {
clearstatcache(true, $path);
if (is_link($path)) {
throw new Error(Error::SYMLINK_ESCAPE);
}
$parent = dirname($path);
if ($parent === $path || $path === '') {
break;
}
$path = $parent;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use RuntimeException;
use Throwable;
/** Machine-readable reason independent of the human-readable message. */
final class ImportException extends RuntimeException
{
public function __construct(public readonly string $reason, string $message = '', ?Throwable $previous = null)
{
parent::__construct($message === '' ? $reason : $message, 0, $previous);
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use Throwable;
/**
* Turns a source tree into a durable import job manifest.
*
* Grouping policy (explicit, never inferred from file contents): each file is
* assigned to the model named after its first directory component below the
* source root; files sitting directly in the source root join a model named
* after the source directory itself. Every model lives in a folder of the same
* name directly under the library root, so scans never silently merge trees.
* No filesystem mutation happens here; the worker owns all copying.
*/
final readonly class ImportService
{
public function __construct(
private FileEnumeratorInterface $enumerator,
private ImportJobRepositoryInterface $jobs,
private CatalogRepositoryInterface $catalog,
private SpaceChecker $space,
private string $libraryRoot,
) {
}
/**
* Scan, preflight free space, then persist the manifest atomically.
*
* @param list<string> $extensions Case-insensitive extensions without dots.
* @throws ImportException SCAN_EMPTY, DISK_FULL, SPACE_CHECK_FAILED, DB_ERROR.
*/
public function import(string $sourcePath, array $extensions = FileEnumeratorInterface::DEFAULT_EXTENSIONS): int
{
$scan = $this->enumerator->enumerate($sourcePath, $extensions);
if ($scan->files === []) {
$detail = $scan->errors === [] ? 'No importable files were found.' : $scan->errors[0]->message;
throw new ImportException('SCAN_EMPTY', $detail);
}
// Preflight before writing any row so a full disk never leaves a job behind.
$this->space->check($this->libraryRoot, $scan->totalBytes);
try {
return $this->jobs->atomic(
fn (): int => $this->jobs->create($sourcePath, $this->manifest($sourcePath, $scan))
);
} catch (ImportException $error) {
throw $error;
} catch (Throwable $error) {
throw new ImportException('DB_ERROR', 'Could not persist the import job.', $error);
}
}
/** @return list<array<string, mixed>> */
private function manifest(string $sourcePath, ScanResult $scan): array
{
$models = [];
$items = [];
foreach ($scan->files as $file) {
$group = $this->group($sourcePath, $file);
$models[$group] ??= $this->catalog->ensureModel('/' . $group, $group);
$items[] = [
'source_path' => $file->sourcePath,
'size' => $file->size,
'model_id' => $models[$group],
];
}
// Scan failures become terminal items so the job reports them durably.
foreach ($scan->errors as $error) {
$items[] = [
'source_path' => $error->sourcePath,
'size' => 0,
'model_id' => null,
'status' => 'Failed',
'failure_reason' => $error->code . ': ' . $error->message,
];
}
return $items;
}
private function group(string $sourcePath, ScannedFile $file): string
{
$parent = trim(str_replace('\\', '/', dirname($file->relativePath)), '/');
if ($parent === '' || $parent === '.') {
return basename(rtrim(str_replace('\\', '/', $sourcePath), '/'));
}
return explode('/', $parent)[0];
}
}
+237
View File
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\SpaceChecker;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* Serializes every managed-library mutation behind one advisory lock file and
* drives a claimed job item by item.
*
* Durability contract: the planned managed path plus the source digest are
* committed BEFORE any byte is published. After a crash the recovered worker
* re-reads that plan, verifies the expected destination, and completes the
* catalog instead of copying again, so restarts never orphan or duplicate
* files. Items fail independently; one failure never abandons the remaining
* work, and the job reports Failed only once every item is terminal.
* A graceful-stop callback is consulted before each item and leaves the job
* Pending (resumable) rather than terminal.
*/
final readonly class ImportWorker
{
public const LOCK_FILE = '.meshvault-import.lock';
/** @param null|callable(): bool $shouldStop Consulted before each item. */
public function __construct(
private ImportJobRepositoryInterface $jobs,
private CatalogRepositoryInterface $catalog,
private StreamingHasher $hasher,
private VerifiedCopier $copier,
private StlValidator $validator,
private PathSafety $pathSafety,
private SpaceChecker $spaceChecker,
private string $libraryRoot,
private LoggerInterface $logger,
private mixed $shouldStop = null,
) {
}
/**
* Claim and drain at most one job. Returns the job id, or null when idle.
*
* @throws ImportException LOCK_UNAVAILABLE when another worker holds the library.
*/
public function runOnce(): ?int
{
$lock = $this->lock();
try {
// Recovery runs under the lock, so no live worker can be reset.
$this->jobs->resumeInterrupted();
$job = $this->jobs->claimNext();
if ($job === null) {
return null;
}
$jobId = (int) $job['id'];
$this->drain($jobId);
return $jobId;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** @return resource */
private function lock()
{
$path = $this->pathSafety->ensureDestSafe($this->libraryRoot . DIRECTORY_SEPARATOR . self::LOCK_FILE);
$handle = @fopen($path, 'c');
if ($handle === false) {
throw new ImportException('LOCK_UNAVAILABLE', 'Cannot open the library lock file.');
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
throw new ImportException('LOCK_UNAVAILABLE', 'Another import worker holds the library lock.');
}
return $handle;
}
private function drain(int $jobId): void
{
foreach ($this->jobs->items($jobId) as $item) {
if (!in_array($item['status'], ['Pending', 'Copying'], true)) {
continue;
}
if ($this->shouldStop !== null && ($this->shouldStop)()) {
// Graceful stop: return the job and its unfinished items to Pending.
$this->jobs->release($jobId);
$this->logger->info('Import worker stopped gracefully.', ['job_id' => $jobId]);
return;
}
$this->process($jobId, $item);
}
}
/** @param array<string, mixed> $item */
private function process(int $jobId, array $item): void
{
$itemId = (int) $item['id'];
try {
$plan = $this->plan($item);
$destination = $this->pathSafety->ensureDestSafe(
$this->libraryRoot . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $plan['managed_path'])
);
$published = $this->publish($plan, $destination);
$relative = $this->relative($published);
// Catalog and progress commit together, so a crash here simply replays.
$this->jobs->atomic(function () use ($item, $itemId, $plan, $published, $relative): void {
// Update managed_path to the actual published path (with collision suffix if any)
// so import_items.managed_path always points at the published file.
$this->jobs->updateManagedPath($itemId, $relative);
if ($item['model_id'] !== null) {
$this->catalog->recordFile(
(int) $item['model_id'],
$relative,
$plan['hash'],
$plan['size'],
null,
'Present'
);
}
$this->jobs->finishItem($itemId, 'Completed');
});
} catch (Throwable $error) {
$this->failItem($jobId, $itemId, $error);
}
}
/**
* Persist the immutable plan (destination plus digest) before publication.
*
* @param array<string, mixed> $item
* @return array{source: string, managed_path: string, hash: string, size: int}
*/
private function plan(array $item): array
{
$source = (string) $item['source_path'];
if (strtolower(pathinfo($source, PATHINFO_EXTENSION)) === 'stl') {
$this->validator->validate($source);
}
if ($item['managed_path'] !== null && $item['hash'] !== null) {
// Recovered plan: trust the committed intent, never choose a new name.
return [
'source' => $source,
'managed_path' => (string) $item['managed_path'],
'hash' => (string) $item['hash'],
'size' => (int) $item['size'],
];
}
$hash = $this->hasher->hash($source);
$size = filesize($source);
if ($size === false) {
throw new ImportException('SCAN_READ_ERROR', 'Cannot determine the source size.');
}
$managed = $this->managedPath($item, $source);
$this->jobs->planItem((int) $item['id'], $managed, $hash, $size);
return ['source' => $source, 'managed_path' => $managed, 'hash' => $hash, 'size' => $size];
}
/** @param array<string, mixed> $item */
private function managedPath(array $item, string $source): string
{
$folder = 'unfiled';
if ($item['model_id'] !== null) {
$aggregate = $this->catalog->aggregate((int) $item['model_id']);
if ($aggregate !== null) {
$folder = trim((string) $aggregate['folder']['path'], '/');
}
}
return ($folder === '' ? '' : $folder . '/') . basename(str_replace('\\', '/', $source));
}
/**
* Publish, or adopt an already-published destination left by a crashed run.
*
* @param array{source: string, managed_path: string, hash: string, size: int} $plan
* @return string The actual published path (may have collision suffix)
*/
private function publish(array $plan, string $destination): string
{
$directory = dirname($destination);
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new ImportException('COPY_FAILED', 'Cannot create the managed destination directory.');
}
// Re-validate the destination with PathSafety and reject links before adopting.
$destination = $this->pathSafety->ensureDestSafe($destination);
clearstatcache(true, $destination);
if (is_file($destination)) {
// Idempotent restart: the planned destination already holds our bytes.
if (hash_file('sha256', $destination) === $plan['hash']) {
return $destination;
}
// Different content at the planned destination: let VerifiedCopier resolve the collision
// by appending a _001 suffix. This keeps restart idempotent (we don't re-copy the same
// file) while still allowing two same-basename sources with different content to coexist.
} else {
// New file being copied: check free space before copying
$this->spaceChecker->check($directory, $plan['size']);
}
return $this->copier->copy($plan['source'], $destination, $plan['size'], $plan['hash']);
}
private function relative(string $published): string
{
// Use the canonical (realpath-resolved) root from PathSafety so prefix comparison
// is reliable even when the configured library root contains symlinks or case differences.
$prefix = rtrim(str_replace('\\', '/', $this->pathSafety->libraryRoot()), '/') . '/';
$path = str_replace('\\', '/', $published);
if (!str_starts_with($path, $prefix)) {
throw new ImportException('PATH_TRAVERSAL', 'The published file escaped the managed library.');
}
return substr($path, strlen($prefix));
}
private function failItem(int $jobId, int $itemId, Throwable $error): void
{
$reason = match (true) {
$error instanceof ImportException => $error->reason,
$error instanceof PathSafetyException => $error->reason,
default => 'COPY_FAILED',
};
$this->logger->error('Import item failed.', [
'job_id' => $jobId,
'item_id' => $itemId,
'reason' => $reason,
]);
// Failures are per item: the loop continues with the remaining work.
$this->jobs->finishItem($itemId, 'Failed', $reason . ': ' . $error->getMessage());
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
final readonly class ScanError
{
public const SCAN_READ_ERROR = 'SCAN_READ_ERROR';
public function __construct(
public string $sourcePath,
public string $code,
public string $message,
) {
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
final readonly class ScanResult
{
/** @param list<ScannedFile> $files @param list<ScanError> $errors */
public function __construct(
public array $files,
public int $totalBytes,
public array $errors,
) {
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
final readonly class ScannedFile
{
public function __construct(
public string $sourcePath,
public string $relativePath,
public int $size,
public string $mimeType,
) {
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use Closure;
use InvalidArgumentException;
use Throwable;
/** Advisory preflight only: free space may change immediately after checking. */
final readonly class SpaceChecker
{
private Closure $freeSpace;
/** Margin is a fraction of payload bytes, not a fraction of disk capacity. */
public function __construct(private float $margin = 0.05, ?callable $freeSpace = null)
{
if (!is_finite($margin) || $margin < 0) {
throw new InvalidArgumentException('Space margin must be finite and nonnegative.');
}
$this->freeSpace = $freeSpace === null
? static fn (string $path): float|false => @disk_free_space($path)
: Closure::fromCallable($freeSpace);
}
/** Require ceil(bytes * (1 + margin)) available bytes on an existing destination directory. */
public function check(string $directory, int $bytes): void
{
if ($bytes < 0) {
throw new InvalidArgumentException('Payload size must be nonnegative.');
}
try {
$free = ($this->freeSpace)($directory);
} catch (Throwable $error) {
throw new ImportException('SPACE_CHECK_FAILED', 'Cannot query destination free space.', $error);
}
if ((!is_float($free) && !is_int($free)) || !is_finite((float) $free) || $free < 0) {
throw new ImportException('SPACE_CHECK_FAILED', 'Cannot determine destination free space.');
}
$required = ceil($bytes + $bytes * $this->margin);
if (!is_finite($required) || $free < $required) {
throw new ImportException('DISK_FULL', 'Insufficient free space including the safety margin.');
}
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use Closure;
use MeshVault\Indexer\Filesystem\PathSafety;
/**
* Structural validation, not manifoldness, winding or non-degeneracy validation.
* Empty meshes are rejected in both encodings. Exact binary length wins over a
* "solid" header. Binary attributes are opaque; all 12 floats must be finite.
* ASCII uses lowercase, one statement per LF/CRLF line (4096 bytes excluding LF),
* optional blank lines and printable names (opening/closing names need not match).
* One solid only; no comments, BOM, bare-CR lines or trailing non-whitespace.
* O(1) auxiliary memory, O(file size) time. Callers must prevent source mutation;
* PathSafety checks and final size checks do not provide a race-proof snapshot.
*/
final readonly class StlValidator
{
/** @param null|Closure(resource, int): (string|false) $reader Test seam; like fread. */
public function __construct(private PathSafety $pathSafety, private ?Closure $reader = null)
{
}
public function validate(string $path): void
{
$path = $this->pathSafety->ensureSourceSafe($path);
if (!is_file($path) || ($stream = @fopen($path, 'rb')) === false) {
throw new ImportException('SCAN_READ_ERROR', 'SCAN_READ_ERROR: Cannot open STL file.');
}
try {
$stat = @fstat($stream);
if ($stat === false || !is_int($stat['size']) || $stat['size'] < 0) {
throw new ImportException('SCAN_READ_ERROR', 'Cannot determine STL size.');
}
$size = $stat['size'];
$header = $this->readUpTo($stream, 84);
$count = strlen($header) === 84 ? unpack('Vcount', substr($header, 80, 4))['count'] : 0;
// Division avoids overflowing 84 + 50 * an untrusted uint32 count.
$binary = $size >= 84 && ($size - 84) % 50 === 0 && intdiv($size - 84, 50) === $count;
if ($binary) {
if ($count === 0) {
$this->invalid('Empty mesh.');
}
for ($index = 0; $index < $count; ++$index) {
$record = $this->readUpTo($stream, 50);
if (strlen($record) !== 50) {
$this->invalid('Truncated binary facet.');
}
foreach (unpack('g12', substr($record, 0, 48)) as $number) {
if (!is_finite($number)) {
$this->invalid('Non-finite binary coordinate or normal.');
}
}
}
if ($this->readUpTo($stream, 1) !== '') {
$this->invalid('Trailing binary data.');
}
} else {
if (@fseek($stream, 0) !== 0) {
throw new ImportException('SCAN_READ_ERROR', 'Cannot rewind STL file.');
}
$this->validateAscii($stream);
}
$after = @fstat($stream);
if ($after === false || $after['size'] !== $size) {
throw new ImportException('SCAN_READ_ERROR', 'STL size changed during validation.');
}
} finally {
fclose($stream);
}
}
/** @param resource $stream */
private function readUpTo($stream, int $length): string
{
$result = '';
while (strlen($result) < $length) {
$remaining = $length - strlen($result);
try {
$chunk = $this->reader === null ? @fread($stream, $remaining) : ($this->reader)($stream, $remaining);
} catch (\Throwable $error) {
throw new ImportException('SCAN_READ_ERROR', 'Failed reading STL stream.', $error);
}
if ($chunk === false || ($chunk === '' && !feof($stream)) || strlen($chunk) > $remaining) {
throw new ImportException('SCAN_READ_ERROR', 'Failed reading STL stream.');
}
if ($chunk === '') {
break;
}
$result .= $chunk;
}
return $result;
}
/** @param resource $stream @return \Generator<int, string> */
private function lines($stream): \Generator
{
$pending = '';
while (($chunk = $this->readUpTo($stream, 4096)) !== '') {
$pending .= $chunk;
while (($newline = strpos($pending, "\n")) !== false) {
if ($newline > 4096) {
$this->invalid('ASCII line exceeds 4096 bytes.');
}
yield substr($pending, 0, $newline);
$pending = substr($pending, $newline + 1);
}
if (strlen($pending) > 4096) {
$this->invalid('ASCII line exceeds 4096 bytes.');
}
}
if ($pending !== '') {
yield $pending;
}
}
/** @param resource $stream */
private function validateAscii($stream): void
{
$state = 'solid';
$hasFacet = false;
foreach ($this->lines($stream) as $line) {
$line = str_ends_with($line, "\r") ? substr($line, 0, -1) : $line;
if (preg_match('/[^\x20-\x7e\t]/', $line)) {
$this->invalid('Non-ASCII or control byte.');
}
$line = trim($line, " \t");
if ($line === '') {
continue;
}
switch ($state) {
case 'solid':
if (!preg_match('/^solid(?:[ \t].*)?$/D', $line)) {
$this->invalid('Expected solid.');
}
$state = 'facet';
break;
case 'facet':
if (preg_match('/^endsolid(?:[ \t].*)?$/D', $line)) {
if (!$hasFacet) {
$this->invalid('Empty mesh.');
}
$state = 'done';
break;
}
$this->vector($line, 'facet normal');
$state = 'outer';
break;
case 'outer':
if (!preg_match('/^outer[ \t]+loop$/D', $line)) {
$this->invalid('Expected outer loop.');
}
$state = 'vertex1';
break;
case 'vertex1':
case 'vertex2':
case 'vertex3':
$this->vector($line, 'vertex');
$state = ['vertex1' => 'vertex2', 'vertex2' => 'vertex3', 'vertex3' => 'endloop'][$state];
break;
case 'endloop':
case 'endfacet':
if ($line !== $state) {
$this->invalid('Expected ' . $state . '.');
}
if ($state === 'endfacet') {
$hasFacet = true;
$state = 'facet';
} else {
$state = 'endfacet';
}
break;
default:
$this->invalid('Trailing ASCII data.');
}
}
if ($state !== 'done') {
$this->invalid('Incomplete ASCII STL.');
}
}
private function vector(string $line, string $prefix): void
{
$number = '([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)';
$prefix = str_replace(' ', '[ \t]+', $prefix);
$pattern = '/^' . $prefix . '[ \t]+' . $number . '[ \t]+' . $number . '[ \t]+' . $number . '$/D';
if (!preg_match($pattern, $line, $m)) {
$this->invalid('Expected a normal or vertex with three decimal coordinates.');
}
foreach (array_slice($m, 1) as $value) {
if (!is_finite((float) $value)) {
$this->invalid('Non-finite ASCII coordinate or normal.');
}
}
}
private function invalid(string $message): never
{
throw new ImportException('STL_INVALID', $message);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use Closure;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use Throwable;
/**
* Bounded SHA-256 with best-effort mutation detection, not an atomic snapshot.
* Stat timestamps may have coarse resolution and some platforms lack useful inode
* identities. Callers must prevent concurrent writers for a strong guarantee.
*/
final readonly class StreamingHasher
{
public const CHUNK_SIZE = 131072;
private const FIELDS = ['dev', 'ino', 'mode', 'size', 'mtime', 'ctime'];
private ?Closure $chunkObserver;
/**
* Observer receives cumulative bytes after each nonempty chunk; exceptions propagate.
* Trusted fault hooks override native operations: open(path): resource|false,
* read(stream, length): string|false, stat(path): array|false,
* fstat(stream): array|false. The hasher owns and closes the opened stream.
*
* @param array<string, callable> $hooks
*/
public function __construct(
private PathSafety $pathSafety,
?callable $chunkObserver = null,
private array $hooks = [],
) {
$this->chunkObserver = $chunkObserver === null ? null : Closure::fromCallable($chunkObserver);
}
/** @throws ImportException Safety reasons are preserved; I/O reasons use HASH_* tokens. */
public function hash(string $path): string
{
$canonical = $this->safePath($path);
$before = $this->metadata('stat', $canonical);
if (($before['mode'] & 0170000) !== 0100000) {
throw new ImportException('HASH_NOT_REGULAR_FILE', 'Hashing requires a regular file.');
}
$stream = $this->io('open', 'HASH_OPEN_FAILED', $canonical);
if (!is_resource($stream)) {
throw new ImportException('HASH_OPEN_FAILED', 'Unable to open source for hashing.');
}
try {
$this->unchanged($before, $this->metadata('fstat', $stream));
$context = hash_init('sha256');
$bytes = 0;
while (!feof($stream)) {
$chunk = $this->io('read', 'HASH_READ_FAILED', $stream, self::CHUNK_SIZE);
if (!is_string($chunk) || ($chunk === '' && !feof($stream))) {
throw new ImportException('HASH_READ_FAILED', 'Source read failed or made no progress.');
}
if ($chunk === '') {
break;
}
$bytes += strlen($chunk);
if ($bytes > $before['size']) {
throw new ImportException('HASH_SOURCE_CHANGED', 'Source grew while hashing.');
}
hash_update($context, $chunk);
if ($this->chunkObserver !== null) {
($this->chunkObserver)($bytes);
}
}
$this->unchanged($before, $this->metadata('fstat', $stream));
if ($this->safePath($path) !== $canonical) {
throw new ImportException('HASH_SOURCE_CHANGED', 'Source path changed while hashing.');
}
$this->unchanged($before, $this->metadata('stat', $canonical));
if ($bytes !== $before['size']) {
throw new ImportException('HASH_SOURCE_CHANGED', 'Source length changed while hashing.');
}
return hash_final($context);
} finally {
fclose($stream);
}
}
private function safePath(string $path): string
{
clearstatcache(true);
try {
return $this->pathSafety->ensureSourceSafe($path);
} catch (PathSafetyException $error) {
throw new ImportException($error->reason, 'Unsafe hashing source: ' . $error->reason, $error);
}
}
private function metadata(string $operation, mixed $target): array
{
clearstatcache(true);
$stat = $this->io($operation, 'HASH_STAT_FAILED', $target);
foreach (self::FIELDS as $field) {
if (!is_array($stat) || !isset($stat[$field]) || !is_int($stat[$field])) {
throw new ImportException('HASH_STAT_FAILED', 'Unable to inspect source metadata.');
}
}
return $stat;
}
private function unchanged(array $before, array $after): void
{
foreach (self::FIELDS as $field) {
if ($before[$field] !== $after[$field]) {
throw new ImportException('HASH_SOURCE_CHANGED', 'Source metadata changed while hashing: ' . $field);
}
}
}
private function io(string $operation, string $reason, mixed ...$arguments): mixed
{
try {
if (isset($this->hooks[$operation])) {
return ($this->hooks[$operation])(...$arguments);
}
return match ($operation) {
'open' => @fopen($arguments[0], 'rb'),
'read' => @fread($arguments[0], $arguments[1]),
'stat' => @stat($arguments[0]),
'fstat' => @fstat($arguments[0]),
};
} catch (Throwable $error) {
throw new ImportException($reason, 'Hashing I/O failed: ' . $operation, $error);
}
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use Throwable;
/**
* Verified, no-overwrite publication via hard link (never rename fallback).
* PathSafety checks are point-in-time: callers MUST control source/destination
* tree mutation. Native PHP streams lack openat/O_NOFOLLOW; checks cannot prevent
* adversarial path replacement between checking and I/O. Atomic publication does
* not imply power-loss durability (no directory fsync), nor source immutability.
*/
final readonly class VerifiedCopier
{
/**
* Trusted test seams only; replacements follow native return conventions:
* read(resource, int): string|false; write(resource, string): int|false;
* flush(resource): bool; link(string $temporary, string $destination): bool.
* A successful replacement must actually perform the native operation.
*
* @param array<string, callable> $operations
*/
public function __construct(private PathSafety $pathSafety, private array $operations = [])
{
foreach ($operations as $name => $operation) {
if (!in_array($name, ['read', 'write', 'flush', 'link'], true) || !is_callable($operation)) {
throw new \InvalidArgumentException('Unknown or non-callable copy operation: ' . $name);
}
}
}
/**
* Copy an existing regular source to a filename under the managed library.
* Parent must exist. Returns absolute published path; collisions try _001
* through _999 before the last extension. Never deletes/overwrites a winner.
* Throws PathSafetyException unchanged; ImportException reasons are
* COPY_FAILED, HASH_MISMATCH (size or digest), COLLISION, INVALID_ARGUMENT.
*/
public function copy(string $source, string $destination, int $expectedSize, string $expectedSha256): string
{
if ($expectedSize < 0 || preg_match('/\A[0-9a-fA-F]{64}\z/', $expectedSha256) !== 1) {
throw new ImportException('INVALID_ARGUMENT', 'Expected a nonnegative size and SHA-256 hex digest.');
}
$source = $this->pathSafety->ensureSourceSafe($source);
$destination = $this->pathSafety->ensureDestSafe($destination);
if (!is_file($source) || !is_dir(dirname($destination))) {
throw new ImportException('COPY_FAILED', 'Source must be regular and destination parent must exist.');
}
$input = $output = null;
$temporary = null;
try {
$input = @fopen($this->pathSafety->ensureSourceSafe($source), 'rb');
if ($input === false) {
throw new ImportException('COPY_FAILED', 'Cannot open source.');
}
$candidate = dirname($destination) . DIRECTORY_SEPARATOR
. '.meshvault-' . bin2hex(random_bytes(16)) . '.tmp';
$candidate = $this->pathSafety->ensureDestSafe($candidate);
$output = @fopen($candidate, 'x+b');
if ($output === false) {
throw new ImportException('COPY_FAILED', 'Cannot exclusively create temporary file.');
}
$temporary = $candidate; // Only clean up a file we successfully created.
$size = 0;
$hash = hash_init('sha256');
while (!feof($input)) {
$bytes = $this->invoke('read', $input, 131072);
if ($bytes === false || ($bytes === '' && !feof($input))) {
throw new ImportException('COPY_FAILED', 'Source read failed or stalled.');
}
$length = strlen($bytes);
$size += $length;
if ($size > $expectedSize) {
throw new ImportException('HASH_MISMATCH', 'Source exceeds expected size.');
}
hash_update($hash, $bytes);
for ($offset = 0; $offset < $length; $offset += $written) {
$written = $this->invoke('write', $output, substr($bytes, $offset));
if (!is_int($written) || $written <= 0 || $written > $length - $offset) {
throw new ImportException('COPY_FAILED', 'Temporary write failed or stalled.');
}
}
}
if ($size !== $expectedSize || !hash_equals(strtolower($expectedSha256), hash_final($hash))) {
throw new ImportException('HASH_MISMATCH', 'Source size or SHA-256 differs from expected metadata.');
}
if (!$this->invoke('flush', $output)) {
throw new ImportException('COPY_FAILED', 'Temporary flush failed.');
}
$this->verify($output, $expectedSize, strtolower($expectedSha256));
fclose($input);
$input = null;
if (!fclose($output)) {
throw new ImportException('COPY_FAILED', 'Temporary close failed.');
}
$output = null;
return $this->publish($temporary, $destination);
} catch (ImportException | PathSafetyException $error) {
throw $error;
} catch (Throwable $error) {
throw new ImportException('COPY_FAILED', 'Copy operation failed.', $error);
} finally {
if (is_resource($input)) {
fclose($input);
}
if (is_resource($output)) {
fclose($output);
}
if ($temporary !== null) {
// Cleanup must never throw: a thrown exception here would mask the original
// error (HASH_MISMATCH, COPY_FAILED, etc.) that triggered the finally block.
// Publication may already have succeeded; never remove its final name.
@unlink($temporary);
}
}
}
/** @param resource $stream */
private function verify($stream, int $size, string $digest): void
{
if (!rewind($stream)) {
throw new ImportException('COPY_FAILED', 'Cannot rewind temporary file.');
}
$hash = hash_init('sha256');
$actualSize = 0;
while (!feof($stream)) {
$bytes = $this->invoke('read', $stream, 131072);
if ($bytes === false || ($bytes === '' && !feof($stream))) {
throw new ImportException('COPY_FAILED', 'Cannot read back temporary file.');
}
$actualSize += strlen($bytes);
hash_update($hash, $bytes);
}
if ($actualSize !== $size || !hash_equals($digest, hash_final($hash))) {
throw new ImportException('HASH_MISMATCH', 'Written size or SHA-256 differs from expected metadata.');
}
}
private function publish(string $temporary, string $destination): string
{
$extension = pathinfo($destination, PATHINFO_EXTENSION);
$stem = $extension === '' ? $destination : substr($destination, 0, -strlen($extension) - 1);
for ($suffix = 0; $suffix <= 999; ++$suffix) {
$candidate = $suffix === 0 ? $destination
: $stem . sprintf('_%03d', $suffix) . ($extension === '' ? '' : '.' . $extension);
$candidate = $this->pathSafety->ensureDestSafe($candidate);
clearstatcache(true, $candidate);
if (file_exists($candidate)) {
continue;
}
$this->pathSafety->ensureDestSafe($temporary);
if ($this->invoke('link', $temporary, $candidate)) {
return $candidate;
}
// A failed link is a collision only if another entry now occupies it.
$this->pathSafety->ensureDestSafe($candidate);
clearstatcache(true, $candidate);
if (!file_exists($candidate)) {
throw new ImportException('COPY_FAILED', 'Hard-link publication failed.');
}
}
throw new ImportException('COLLISION', 'Destination and all 999 suffixes are occupied.');
}
private function invoke(string $operation, mixed ...$arguments): mixed
{
if (isset($this->operations[$operation])) {
return ($this->operations[$operation])(...$arguments);
}
return match ($operation) {
'read' => @fread(...$arguments),
'write' => @fwrite(...$arguments),
'flush' => @fflush(...$arguments),
'link' => @link(...$arguments),
};
}
}