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,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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user