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
47 files changed
+5146 -3

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use DI\ContainerBuilder;
use MeshVault\Config\AppConfig;
use MeshVault\Console\Console;
use MeshVault\Console\ConsoleServices;
use MeshVault\Console\LazyConsoleServices;
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, 'meshvault must be run from the command line.' . PHP_EOL);
exit(1);
}
require dirname(__DIR__) . '/vendor/autoload.php';
// Built on first use so help and usage errors never require configuration.
$services = new LazyConsoleServices(static function (): ConsoleServices {
$builder = new ContainerBuilder();
$definitions = require dirname(__DIR__) . '/config/container.php';
$builder->addDefinitions($definitions(AppConfig::fromEnvironment(getenv())));
return $builder->build()->get(ConsoleServices::class);
});
$stopping = false;
$stopRequested = static function () use (&$stopping): bool {
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
}
return $stopping;
};
if (function_exists('pcntl_signal')) {
$handler = static function () use (&$stopping): void {
$stopping = true;
};
pcntl_signal(SIGTERM, $handler);
pcntl_signal(SIGINT, $handler);
}
$console = new Console(
$services,
STDOUT,
STDERR,
static function (int $seconds) use ($stopRequested): void {
// Poll each second so a signal is honoured without waiting out the interval.
for ($elapsed = 0; $elapsed < $seconds && !$stopRequested(); $elapsed++) {
sleep(1);
}
},
$stopRequested,
);
exit($console->run(array_slice($argv, 1)));
+74
View File
@@ -3,12 +3,86 @@
declare(strict_types=1);
use MeshVault\Config\AppConfig;
use MeshVault\Console\ConsoleServices;
use MeshVault\Console\ContainerConsoleServices;
use MeshVault\Console\ImportWorkerFactory;
use MeshVault\Http\View;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\FileEnumerator;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\ImportService;
use MeshVault\Indexer\ImportWorker;
use MeshVault\Indexer\SpaceChecker;
use MeshVault\Indexer\StlValidator;
use MeshVault\Indexer\StreamingHasher;
use MeshVault\Indexer\VerifiedCopier;
use MeshVault\Logging\LoggerFactory;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
return static fn (AppConfig $config): array => [
AppConfig::class => $config,
View::class => new View(dirname(__DIR__) . '/templates'),
LoggerInterface::class => static fn (): LoggerInterface => LoggerFactory::create($config),
PDO::class => static fn (): PDO => new PDO(
sprintf('pgsql:host=%s;port=%d;dbname=%s', $config->dbHost, $config->dbPort, $config->dbName),
$config->dbUser,
$config->dbPassword,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => false],
),
PathSafety::class => static fn (): PathSafety => new PathSafety($config->libraryPath),
FileEnumeratorInterface::class => static fn (ContainerInterface $c): FileEnumeratorInterface
=> new FileEnumerator(new LocalFilesystem(), $c->get(PathSafety::class)),
SpaceChecker::class => static fn (): SpaceChecker => new SpaceChecker(),
StreamingHasher::class => static fn (ContainerInterface $c): StreamingHasher
=> new StreamingHasher($c->get(PathSafety::class)),
StlValidator::class => static fn (ContainerInterface $c): StlValidator
=> new StlValidator($c->get(PathSafety::class)),
VerifiedCopier::class => static fn (ContainerInterface $c): VerifiedCopier
=> new VerifiedCopier($c->get(PathSafety::class)),
// Repositories share one PDO connection so the job repository's atomic() is a real transaction.
CatalogRepositoryInterface::class => static fn (ContainerInterface $c): CatalogRepositoryInterface
=> new PdoCatalogRepository($c->get(PDO::class)),
ImportJobRepositoryInterface::class => static fn (ContainerInterface $c): ImportJobRepositoryInterface
=> new PdoImportJobRepository($c->get(PDO::class)),
ImportService::class => static fn (ContainerInterface $c): ImportService => new ImportService(
$c->get(FileEnumeratorInterface::class),
$c->get(ImportJobRepositoryInterface::class),
$c->get(CatalogRepositoryInterface::class),
$c->get(SpaceChecker::class),
$config->libraryPath,
),
ImportWorkerFactory::class => static fn (ContainerInterface $c): ImportWorkerFactory
=> new class ($c) implements ImportWorkerFactory {
public function __construct(private readonly ContainerInterface $c)
{
}
public function __invoke(callable $shouldStop): ImportWorker
{
/** @var PathSafety $safety */
$safety = $c->get(PathSafety::class);
return new ImportWorker(
$c->get(ImportJobRepositoryInterface::class),
$c->get(CatalogRepositoryInterface::class),
$c->get(StreamingHasher::class),
$c->get(VerifiedCopier::class),
$c->get(StlValidator::class),
$safety,
$c->get(SpaceChecker::class),
$safety->libraryRoot(),
$c->get(LoggerInterface::class),
$shouldStop,
);
}
},
ConsoleServices::class => static fn (ContainerInterface $c): ConsoleServices => new ContainerConsoleServices(
$c->get(ImportService::class),
$c->get(ImportWorkerFactory::class),
),
];
-1
View File
@@ -81,4 +81,3 @@ final class DefaultDataSeed extends AbstractSeed
}
}
}
+10 -1
View File
@@ -23,12 +23,21 @@ try {
{ $_ -in 'test', 'lint' } {
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'composer', 'app', $Command)
}
{ $_ -in 'import', 'worker-once' } {
$ComposeArgs = @('run', '--rm', '-T', '--entrypoint', 'php', 'app', 'bin/meshvault', $Command)
}
'worker' {
# Interactive so Ctrl+C reaches the worker's graceful-stop handler.
$ComposeArgs = @('run', '--rm', '--entrypoint', 'php', 'app', 'bin/meshvault', 'worker')
}
'assets' { $ComposeArgs = @('run', '--rm', '--no-deps', '-T', 'assets', 'npm', 'run', 'build', '--') }
{ $_ -in 'watch', 'assets:watch' } {
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', 'assets', 'npm', 'run', 'watch', '--')
}
default {
[Console]::Error.WriteLine('Usage: dev.ps1 up|down|test|lint|assets|watch|migrate|seed [arguments]')
[Console]::Error.WriteLine(
'Usage: dev.ps1 up|down|test|lint|assets|watch|migrate|seed|import|worker|worker-once [arguments]'
)
exit 2
}
}
+8 -1
View File
@@ -17,8 +17,15 @@ case "$cmd" in
exec docker compose down "$@" ;;
up) exec docker compose up -d --build "$@" ;;
test|lint) exec docker compose run --rm --no-deps -T --entrypoint composer app "$cmd" "$@" ;;
import|worker-once)
exec docker compose run --rm -T --entrypoint php app bin/meshvault "$cmd" "$@" ;;
worker)
# Interactive so Ctrl+C reaches the worker's graceful-stop handler.
exec docker compose run --rm --entrypoint php app bin/meshvault worker "$@" ;;
assets) exec docker compose run --rm --no-deps -T assets npm run build -- "$@" ;;
watch|assets:watch) exec docker compose run --rm --no-deps -T assets npm run watch -- "$@" ;;
*) echo 'Usage: dev.sh up|down|test|lint|assets|watch|migrate|seed [arguments]' >&2; exit 2 ;;
*)
echo 'Usage: dev.sh up|down|test|lint|assets|watch|migrate|seed|import|worker|worker-once [arguments]' >&2
exit 2 ;;
esac
@@ -0,0 +1,198 @@
# M3: Import and Indexing Engine Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build scanning, validation, hashing, verified-copy primitives plus background worker, progress, and failure capture for managed imports.
**Architecture:** Three phases: (1) scan/copy primitives as reusable services in `src/Indexer/`; (2) repository layer for M4 #28 persistence; (3) background worker consuming `import_jobs`. Filesystem behind interfaces for testability.
**Tech Stack:** PHP 8.3+, PostgreSQL via Phinx, PSR-3 logging, PSR-4 `MeshVault\`, strict types, PSR-12. No new runtime deps.
**Spec:** `notes/backlog-m3-m4.json` issues 20-27 plus 28, and `notes/Roadmap.md`.
## Global Constraints
- PHP 8.3+, strict_types=1, PSR-12, four spaces, LF, UTF-8 no BOM.
- New classes `final readonly` where possible; constructor injection.
- PDO repositories, no ORM.
- Streams only; never load whole files into memory.
- `realpath()` checks; reject symlinks, `..`, dest outside library root.
- SHA-256 streaming, 128 KiB default buffer.
- Collision suffix `_NNN` before extension.
- Free-space safety margin 5% default.
- Library root from validated `AppConfig::libraryPath`.
- Error codes: `SCAN_EMPTY`, `SCAN_READ_ERROR`, `HASH_MISMATCH`, `DISK_FULL`, `COLLISION`, `SYMLINK_ESCAPE`, `PATH_TRAVERSAL`, `STL_INVALID`, `COPY_FAILED`, `DB_ERROR`.
- Atomic worker claim on `import_jobs`.
- Unit plus integration tests; 2+ failure-path tests per feature.
## Review Focus
1. Path safety vs traversal, symlinks, absolute escapes.
2. Streaming hash equality with `hash_file()`; mid-read changes.
3. Atomic publish temp-then-rename; no partial completes.
4. Worker idempotency on retry.
5. Disk-full pre-check plus mid-copy cleanup.
## Task 1.1 actual scope/status
- [x] Implemented only filesystem abstraction and path safety: `FilesystemInterface`,
`LocalFilesystem`, `PathSafety`, `PathSafetyException`, and unit tests for both services.
Native-style I/O failures return false; resource returns are PHPDoc and caller-owned.
Source checks allow existing external sources; destinations stay within the library
and may have missing suffixes. Relative paths, traversal, ancestor/dangling symlinks,
root-prefix siblings, and ambiguous Windows names are checked with structured reasons.
- Validation on local PHP 8.3.35: initial RED 22 missing-class assertion failures;
Windows alias regression also observed RED before its fix. Final focused suite:
23 tests, 102 assertions, 5 symlink skips (host cannot create them). `composer test`:
foundation checks pass; 110 tests, 604 assertions, 7 skips total. New PHP files pass
PSR-12; full `composer lint` still reports three unrelated existing errors in
`db/seeds/DefaultDataSeed.php` and `tests/Integration/MigrationTest.php` (not changed).
- Drive-relative review fix: `ensureSourceSafe()` now rejects any uppercase/lowercase
drive prefix without a following slash as `INVALID_PATH` before link inspection;
constructor roots inherit the same check. Existing relative/destination behavior is unchanged.
Regression RED: all 5 new cases failed with expected missing-rejection failures,
including realpath-confirmed existing source/root paths with upper/lowercase drives.
GREEN on Windows/PHP 8.3.35: focused `tests/Unit/Indexer` suite passed with 28 tests,
531 assertions, 5 symlink skips. `composer test` passed foundation checks and 115 tests,
1033 assertions, 7 skips. Scoped PHPCS using `phpcs.xml.dist` passed for
`src/Indexer/Filesystem/PathSafety.php` and `tests/Unit/Indexer/PathSafetyTest.php`.
Coverage also checks all 52 drive-letter variants with bare, file, and dot-relative suffixes.
- Limitations: checks are point-in-time, not race-proof; callers must control directory
mutation around I/O. Symlink behavior needs execution on a host permitting links;
no Linux runtime validation performed. No later M3 tasks, commits, or delegation.
## Task 1.2 actual scope/status
- [x] Implemented recursive `FileEnumerator`, `FileEnumeratorInterface`, and separate
PSR-4 `ScanResult`, `ScannedFile`, `ScanError` readonly DTOs. Manifest entries expose
canonical absolute `sourcePath`, safe forward-slash `relativePath`, `size`, and
`mimeType`; totals include only successful entries. Default allowlist is
stl/jpg/jpeg/png/gif/pdf/txt/md, matched case-insensitively; an explicit empty
allowlist selects nothing. Empty directories return an empty result without errors.
- Safety ruling: the global rejection of **all** symlinks overrides any older
follow-in-root-link wording. File, directory, dangling and ancestor links are
reported as `SYMLINK_ESCAPE`, even if the link has a disallowed extension, and
are never traversed. `PathSafety` checks run before canonicalization; the review fix
below tightens POSIX source spelling. Traversal retains `PATH_TRAVERSAL`. Invalid/missing/non-directory
roots and per-entry readability, resolution, stat, MIME and directory-read failures
produce actionable structured `SCAN_READ_ERROR` errors; independent siblings continue.
- Minimal filesystem API addition: `readDirectory(): array|false` returns entry names
without dot entries, backed by suppressed native `scandir()` failures. Existing
metadata calls and injected filesystem doubles cover deterministic I/O failures;
no new runtime dependencies or container changes. Iterative traversal avoids call
stack recursion; root depth is zero and the configurable maximum is 128 by default.
Exceeding it reports an error for that subtree without losing siblings.
- TDD/validation on Windows, PHP 8.3.35 (commands run from repository root):
- RED: `php vendor/phpunit/phpunit/phpunit tests/Unit/Indexer/FileEnumeratorTest.php`
produced 15 expected missing-FileEnumerator assertion failures before implementation.
- GREEN, same command: 15 tests, 143 assertions, 4 symlink privilege skips.
- Added direct adapter regression coverage for empty/nested listings, missing paths
and non-directory reads; enumeration tests cover mixed-case nested defaults,
disallowed extensions, custom/empty allowlists, invalid roots, six injected
per-entry failure modes, root failures, sibling continuation and depth limits.
- `php vendor/squizlabs/php_codesniffer/bin/phpcbf --standard=phpcs.xml.dist src/Indexer tests/Unit/Indexer/FileEnumeratorTest.php tests/Unit/Indexer/LocalFilesystemTest.php`
fixed generated CRLF and multiline formatting; no behavioral changes.
- `php vendor/phpunit/phpunit/phpunit tests/Unit/Indexer`: 44 tests, 679 assertions,
9 skips; success.
- `composer test`: foundation checks pass; 131 tests, 1181 assertions, 11 skips;
success (existing HTTP failure-path tests emit expected error log records).
- `php vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcs.xml.dist src/Indexer tests/Unit/Indexer/FileEnumeratorTest.php tests/Unit/Indexer/LocalFilesystemTest.php`:
passes with no findings. Full-repository lint's previously documented unrelated
failures were not addressed.
- Review fixes (task 1.2):
- Policy ruling: POSIX source paths containing any backslash are rejected as
`INVALID_PATH` before normalization, including constructor library roots. Windows
backslash separators remain supported; relative/destination normalization is unchanged.
`FileEnumerator` now uses the canonical path returned by safety checks for injected
filesystem operations, retaining the adapter realpath call and its failure handling.
- Safety failure messages retain reason tokens and scan codes, but add corrective
guidance for missing, empty/NUL, traversal and symlink paths. Tests now require
directory-selection guidance rather than merely the phrase "source root".
- Added five tests: source/constructor platform policy, POSIX backslash-named ancestor
symlink with a separate slash-spelled decoy tree, traversal root/entry codes, zero-byte
metadata, and negative-size/empty-MIME sibling continuation. Existing failure doubles
continue to pass, including injected realpath failures.
- RED: Windows focused suite failed on missing-root guidance (49 tests, 688 assertions,
1 failure, 10 skips). A local WSL/PHP 8.5.4 standalone probe using real directories
and a backslash-named ancestor symlink failed source and constructor rejection and
demonstrated one accepted target file before the fix. After the fix all three probe
checks passed, with no accepted files. Probe fixtures were cleaned up; the ignored
`.phpunit.cache/posix-review-probe.php` remains because shell cleanup was blocked.
- GREEN on Windows/PHP 8.3.35:
`php vendor/phpunit/phpunit/phpunit tests/Unit/Indexer`: 49 tests, 722 assertions,
10 skips (nine host symlink-permission skips and one POSIX-only regression).
`composer test`: foundation checks pass; 136 tests, 1224 assertions, 12 skips
(the ten above plus two existing integration skips); expected HTTP error logs only.
`php vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcs.xml.dist src/Indexer tests/Unit/Indexer`:
exit 0, no findings.
- Linux PHPUnit was attempted via WSL but cannot run because its PHP lacks dom,
mbstring and xmlwriter. No extensions were installed; the standalone native POSIX
RED/GREEN probe is not a substitute for a full Linux suite.
- Limitations: symlink PHPUnit cases require a link-capable host; Windows skips are
detailed above. Path checks are point-in-time, not race-proof, so callers must
control source-tree mutation. No file payload is loaded by the enumerator or opened
through `readStream`; native fileinfo may inspect content for MIME detection.
Directory name lists and the returned manifest are materialized in memory (not file
contents). Scan order is not a public guarantee. No free-space, hashing, copying,
persistence or worker work; no commits/merges/pushes. Task 1.1 source safety is tightened
only by the POSIX backslash policy above.
## Tasks 1.3-3.4 and delivery layer: actual scope/status
- [x] **1.3 SpaceChecker** (`src/Indexer/SpaceChecker.php`) — 5% default margin, injectable
free-space probe, `DISK_FULL` / `SPACE_CHECK_FAILED` reasons.
- [x] **1.4 StreamingHasher** (`src/Indexer/StreamingHasher.php`) — SHA-256 in 128 KiB chunks,
validates the source through `PathSafety` before and after the read.
- [x] **1.5 StlValidator** (`src/Indexer/StlValidator.php`) — binary and ASCII STL, facet-count
and terminator checks, `STL_INVALID` / `SCAN_READ_ERROR`.
- [x] **1.6/1.7 VerifiedCopier** (`src/Indexer/VerifiedCopier.php`) — temp copy, size and hash
verification, link-based publication, `_NNN` collision suffix, cleanup on failure.
- [x] **1.8 ImportService** (`src/Indexer/ImportService.php`) — scan, free-space preflight, then
one atomic manifest+job write. Scan errors become terminal `Failed` items.
- [x] **Phase 2 repositories** — `CatalogRepositoryInterface`, `ImportJobRepositoryInterface`,
`PdoRepository`, `PdoCatalogRepository`, `PdoImportJobRepository`. No schema changes.
- [x] **Phase 3 worker** (`src/Indexer/ImportWorker.php`) — single advisory `flock` over the
library, `resumeInterrupted()` under that lock, atomic claim, plan-before-publish durability,
per-item failure isolation, graceful-stop callback that leaves the job `Pending`.
- [x] **Delivery layer** — `src/Console/Console.php` (argument parsing, exit codes),
`ConsoleServices`, `ContainerConsoleServices`, `ImportWorkerFactory`, `LazyConsoleServices`,
`bin/meshvault`, container wiring in `config/container.php`, and `import` / `worker` /
`worker-once` subcommands in `dev.sh` and `dev.ps1`.
### Rulings
- **Ruling: no `/import` HTTP route in M3** — the backlog's HTTP surface has no authentication
yet, and any route accepting a source path would expose arbitrary server filesystem reads to
unauthenticated callers. Deferred to M6 behind auth. Cost if wrong: the UI trigger lands one
milestone later; the CLI covers the workflow meanwhile.
- **Ruling: CLI exit codes are 0 success / 1 usage / 2 runtime** — matches the existing wrappers'
use of exit 2 for refusal. Cost if wrong: scripted callers need remapping.
- **Ruling: services are constructed lazily in `bin/meshvault`** — `help` and usage errors must
not require a database or library directory. Cost if wrong: a misconfiguration surfaces one
call later, still as exit 2.
- **Ruling: grouping is by first directory component below the source root**, never inferred from
file contents. Cost if wrong: flat source trees produce one model per source directory.
### Validation (Windows, PHP 8.3.35)
- `composer test`: foundation checks pass; **285 tests, 1796 assertions, 18 skips**, no failures.
- `composer lint`: **exit 0, no findings repository-wide.** The two long-standing errors in
`db/seeds/DefaultDataSeed.php` and `tests/Integration/MigrationTest.php` were trailing-newline
violations and are now fixed; no logic in either file changed.
- `vendor\bin\phpunit tests/Unit/Console`: 22 tests, 71 assertions, 0 skips.
- `vendor\bin\phpunit --filter TaskRunner tests/Integration`: 1 test, 71 assertions — asserts the
wrappers forward `import`, `worker` and `worker-once` to `bin/meshvault` with arguments intact.
- Live CLI exit-code probe: `help`=0, `bogus`=1, no args=1, `import` (no path)=1,
`worker --sleep=x`=1, `worker-once extra`=1, `import C:\nope` (unconfigured env)=2.
### Limitations
- **PostgreSQL was not exercised.** Integration tests run against SQLite built from the Phinx
migrations; `FOR UPDATE SKIP LOCKED` is driver-detected but unverified on a live server.
`tests/Integration/MigrationTest.php` still skips without DB credentials.
- **Signals are POSIX-only.** `bin/meshvault` installs `SIGTERM`/`SIGINT` handlers when `pcntl`
is available; on Windows the worker stops only at `--max-iterations`.
- Graceful stop is verified between items, not mid-copy.
- Symlink unit tests skip on hosts that cannot create links (11 skips here).
- Nothing is committed, merged or pushed; all work is uncommitted in the working tree.
+230
View File
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
use Closure;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\ImportException;
use Throwable;
/**
* Argument parsing and reporting for the meshvault CLI.
*
* Every dependency is injected, so the whole surface is testable without a
* database, a filesystem or a container. Exit codes are a contract:
* 0 success, 1 usage error, 2 runtime failure.
*/
final class Console
{
private const USAGE = 'Usage: meshvault <import|worker|worker-once|help> [arguments]';
private readonly Closure $sleep;
private readonly Closure $stopRequested;
/**
* @param resource $out
* @param resource $err
* @param callable(int): void $sleep
* @param callable(): bool $stopRequested
*/
public function __construct(
private readonly ConsoleServices $services,
private readonly mixed $out,
private readonly mixed $err,
callable $sleep,
callable $stopRequested,
) {
$this->sleep = Closure::fromCallable($sleep);
$this->stopRequested = Closure::fromCallable($stopRequested);
}
/** @param list<string> $argv Arguments after the program name. */
public function run(array $argv): int
{
$command = array_shift($argv);
return match ($command) {
null => $this->usage(self::USAGE),
'help', '--help', '-h' => $this->help(),
'import' => $this->import($argv),
'worker' => $this->worker($argv),
'worker-once' => $this->workerOnce($argv),
default => $this->usage('Unknown command: ' . $command . "\n" . self::USAGE),
};
}
private function help(): int
{
$this->write($this->out, implode("\n", [
self::USAGE,
'',
'Commands:',
' import <source-path> [--ext=stl,3mf] Scan a source tree and queue an import job.',
' worker [--sleep=N] [--max-iterations=N] Drain import jobs until stopped.',
' worker-once Claim and drain at most one job, then exit.',
' help Show this message.',
'',
'Options:',
' --ext=LIST Comma-separated extensions, with or without dots. Defaults to: '
. implode(',', FileEnumeratorInterface::DEFAULT_EXTENSIONS),
' --sleep=N Seconds to wait after an idle poll (default 5).',
' --max-iterations=N Stop after N polls instead of running until signalled.',
]));
return 0;
}
/** @param list<string> $argv */
private function import(array $argv): int
{
$source = null;
$extensions = FileEnumeratorInterface::DEFAULT_EXTENSIONS;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--ext=')) {
$parsed = $this->extensions(substr($argument, 6));
if ($parsed === []) {
return $this->usage('--ext requires at least one extension, e.g. --ext=stl,3mf');
}
$extensions = $parsed;
continue;
}
if (str_starts_with($argument, '-')) {
return $this->usage('Unknown option: ' . $argument);
}
if ($source !== null) {
return $this->usage('import accepts exactly one source path.');
}
$source = $argument;
}
if ($source === null || trim($source) === '') {
return $this->usage('Usage: meshvault import <source-path> [--ext=stl,3mf]');
}
try {
$job = $this->services->import($source, $extensions);
} catch (Throwable $error) {
return $this->fail($error);
}
$this->write($this->out, 'Queued import job ' . $job . ' for ' . $source);
return 0;
}
/** @return list<string> */
private function extensions(string $raw): array
{
$extensions = [];
foreach (explode(',', $raw) as $candidate) {
$extension = strtolower(ltrim(trim($candidate), '.'));
if ($extension !== '' && !in_array($extension, $extensions, true)) {
$extensions[] = $extension;
}
}
return $extensions;
}
/** @param list<string> $argv */
private function workerOnce(array $argv): int
{
if ($argv !== []) {
return $this->usage('worker-once takes no arguments.');
}
try {
$job = $this->services->runWorkerOnce($this->stopRequested);
} catch (Throwable $error) {
return $this->fail($error);
}
$this->write(
$this->out,
$job === null ? 'No pending import jobs.' : 'Processed import job ' . $job . '.'
);
return 0;
}
/** @param list<string> $argv */
private function worker(array $argv): int
{
$sleep = 5;
$maxIterations = null;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--sleep=')) {
$value = substr($argument, 8);
if (!$this->isNonNegativeInteger($value)) {
return $this->usage('--sleep must be a non-negative whole number of seconds.');
}
$sleep = (int) $value;
continue;
}
if (str_starts_with($argument, '--max-iterations=')) {
$value = substr($argument, 17);
if (!$this->isNonNegativeInteger($value) || (int) $value < 1) {
return $this->usage('--max-iterations must be a whole number of at least 1.');
}
$maxIterations = (int) $value;
continue;
}
return $this->usage(
str_starts_with($argument, '-')
? 'Unknown option: ' . $argument
: 'worker takes no positional arguments, got: ' . $argument
);
}
return $this->loop($sleep, $maxIterations);
}
private function loop(int $sleep, ?int $maxIterations): int
{
$iterations = 0;
while ($maxIterations === null || $iterations < $maxIterations) {
try {
$job = $this->services->runWorkerOnce($this->stopRequested);
} catch (Throwable $error) {
return $this->fail($error);
}
$iterations++;
if ($job !== null) {
$this->write($this->out, 'Processed import job ' . $job . '.');
}
// A stop requested mid-job has already been honoured by the worker.
if (($this->stopRequested)()) {
break;
}
if ($job === null) {
($this->sleep)($sleep);
}
}
$this->write($this->out, 'Import worker stopped after ' . $iterations . ' poll(s).');
return 0;
}
private function isNonNegativeInteger(string $value): bool
{
return preg_match('/^[0-9]{1,9}$/D', $value) === 1;
}
private function usage(string $message): int
{
$this->write($this->err, $message);
return 1;
}
private function fail(Throwable $error): int
{
$reason = match (true) {
$error instanceof ImportException => $error->reason,
$error instanceof PathSafetyException => $error->reason,
default => null,
};
// Unexpected errors may carry DSNs or credentials; report only the type.
$this->write($this->err, $reason === null
? 'Error: unexpected ' . $error::class . '. See the application log for details.'
: 'Error [' . $reason . ']: ' . $error->getMessage());
return 2;
}
/** @param resource $stream */
private function write(mixed $stream, string $message): void
{
fwrite($stream, $message . PHP_EOL);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
/**
* The only seam between argument parsing and the import engine.
*
* Keeping it this narrow lets the console be tested without a database,
* a filesystem, or a container.
*/
interface ConsoleServices
{
/**
* Queue an import job for a source tree.
*
* @param list<string> $extensions Lowercase extensions without leading dots.
* @return int The created job id.
*/
public function import(string $sourcePath, array $extensions): int;
/**
* Claim and drain at most one job.
*
* @param callable(): bool $shouldStop Consulted by the worker between items.
* @return int|null The processed job id, or null when the queue is idle.
*/
public function runWorkerOnce(callable $shouldStop): ?int;
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
use MeshVault\Indexer\ImportService;
/** Binds the console to the real import engine. */
final readonly class ContainerConsoleServices implements ConsoleServices
{
public function __construct(
private ImportService $importService,
private ImportWorkerFactory $workerFactory,
) {
}
public function import(string $sourcePath, array $extensions): int
{
return $this->importService->import($sourcePath, $extensions);
}
public function runWorkerOnce(callable $shouldStop): ?int
{
// The stop callback is per-run, so the worker is built per invocation.
return ($this->workerFactory)($shouldStop)->runOnce();
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
use MeshVault\Indexer\ImportWorker;
/** Builds an ImportWorker bound to a specific graceful-stop callback. */
interface ImportWorkerFactory
{
/** @param callable(): bool $shouldStop */
public function __invoke(callable $shouldStop): ImportWorker;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
use Closure;
/**
* Defers engine construction until a command actually needs it, so usage
* errors and help never require a configured database or library.
*/
final class LazyConsoleServices implements ConsoleServices
{
private readonly Closure $factory;
private ?ConsoleServices $delegate = null;
/** @param callable(): ConsoleServices $factory */
public function __construct(callable $factory)
{
$this->factory = Closure::fromCallable($factory);
}
public function import(string $sourcePath, array $extensions): int
{
return $this->delegate()->import($sourcePath, $extensions);
}
public function runWorkerOnce(callable $shouldStop): ?int
{
return $this->delegate()->runWorkerOnce($shouldStop);
}
private function delegate(): ConsoleServices
{
return $this->delegate ??= ($this->factory)();
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MeshVault\Exception;
use RuntimeException;
final class PathSafetyException extends RuntimeException
{
public const INVALID_PATH = 'INVALID_PATH';
public const ABSOLUTE_PATH = 'ABSOLUTE_PATH';
public const PATH_TRAVERSAL = 'PATH_TRAVERSAL';
public const SYMLINK_ESCAPE = 'SYMLINK_ESCAPE';
public const PATH_NOT_FOUND = 'PATH_NOT_FOUND';
public const NOT_DIRECTORY = 'NOT_DIRECTORY';
public const OUTSIDE_LIBRARY_ROOT = 'OUTSIDE_LIBRARY_ROOT';
public function __construct(public readonly string $reason)
{
parent::__construct('Path safety check failed: ' . $reason);
}
}
@@ -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),
};
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
interface CatalogRepositoryInterface
{
public function ensureModel(string $folderPath, string $modelName): int;
public function recordFile(
int $modelId,
string $managedPath,
string $hash,
int $size,
?string $mime = null,
string $status = 'Present'
): int;
/** @return array{model: array, folder: array, files: array}|null */
public function aggregate(int $modelId): ?array;
public function setFileStatus(int $fileId, string $status): void;
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
interface ImportJobRepositoryInterface
{
public function create(string $sourcePath, array $items): int;
public function get(int $jobId): ?array;
public function items(int $jobId): array;
public function claimNext(): ?array;
/** Caller MUST hold the global library flock; never use age-based recovery. */
public function resumeInterrupted(): void;
public function planItem(int $itemId, string $managedPath, string $hash, int $size): void;
public function updateManagedPath(int $itemId, string $managedPath): void;
public function finishItem(int $itemId, string $status, ?string $failureReason = null): void;
public function release(int $jobId): void;
public function cancel(int $jobId): void;
public function fail(int $jobId, string $failureReason): void;
public function completeEmpty(int $jobId): void;
/** All repositories participating in the callback must share this PDO connection. */
public function atomic(callable $callback): mixed;
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
use DomainException;
use InvalidArgumentException;
use PDO;
final class PdoCatalogRepository extends PdoRepository implements CatalogRepositoryInterface
{
public function ensureModel(string $folderPath, string $modelName): int
{
if ($modelName === '') {
throw new InvalidArgumentException('Model name must not be empty.');
}
return $this->transaction(function () use ($folderPath, $modelName): int {
$folder = $this->ensureFolder($folderPath);
$this->execute(
'INSERT INTO models (folder_id, name) VALUES (?, ?) ON CONFLICT (folder_id, name) DO NOTHING',
[$folder, $modelName]
);
return (int) $this->row(
'SELECT id FROM models WHERE folder_id = ? AND name = ?',
[$folder, $modelName]
)['id'];
});
}
public function recordFile(
int $modelId,
string $managedPath,
string $hash,
int $size,
?string $mime = null,
string $status = 'Present'
): int {
$this->validateStatus($status);
if ($managedPath === '' || $hash === '' || $size < 0) {
throw new InvalidArgumentException('A file requires a path, hash and non-negative size.');
}
return $this->transaction(function () use ($modelId, $managedPath, $hash, $size, $mime, $status): int {
// Serialize catalog writers on the root row, including paths assigned to different models.
$this->ensureFolder('/');
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?', [$managedPath]);
if ($existing !== null) {
if (
(int) $existing['model_id'] !== $modelId || $existing['hash'] !== $hash
|| (int) $existing['size'] !== $size
) {
throw new DomainException('Managed path already records a different file.');
}
return (int) $existing['id'];
}
$folder = $this->ensureFolder(dirname(str_replace('\\', '/', $managedPath)));
$file = $this->row(
'INSERT INTO files (model_id, folder_id, name, managed_path, hash, size, mime_type, status)'
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
[$modelId, $folder, basename($managedPath), $managedPath, $hash, $size, $mime, $status]
);
return (int) $file['id'];
});
}
public function aggregate(int $modelId): ?array
{
return $this->transaction(function () use ($modelId): ?array {
$model = $this->row('SELECT * FROM models WHERE id = ?', [$modelId]);
if ($model === null) {
return null;
}
return [
'model' => $model,
'folder' => $this->row('SELECT * FROM folders WHERE id = ?', [$model['folder_id']]),
'files' => $this->execute('SELECT * FROM files WHERE model_id = ? ORDER BY id', [$modelId])
->fetchAll(PDO::FETCH_ASSOC),
];
});
}
public function setFileStatus(int $fileId, string $status): void
{
$this->validateStatus($status);
if (
$this->execute(
'UPDATE files SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[$status, $fileId]
)->rowCount() === 0
) {
throw new DomainException('Unknown file.');
}
}
private function validateStatus(string $status): void
{
if (!in_array($status, ['Present', 'Missing', 'Updated', 'Duplicate'], true)) {
throw new InvalidArgumentException('Invalid file status.');
}
}
private function ensureFolder(string $path): int
{
$segments = array_values(array_filter(explode('/', str_replace('\\', '/', $path)), 'strlen'));
$segments = array_values(array_filter($segments, static fn (string $part): bool => $part !== '.'));
if (in_array('..', $segments, true)) {
throw new InvalidArgumentException('Folder traversal is not allowed.');
}
$parent = null;
$current = '';
foreach (array_merge([''], $segments) as $segment) {
$current = $segment === '' ? '/' : rtrim($current, '/') . '/' . $segment;
$this->execute(
'INSERT INTO folders (parent_id, name, path) VALUES (?, ?, ?) ON CONFLICT (path) DO NOTHING',
[$parent, $segment === '' ? '/' : $segment, $current]
);
$parent = (int) $this->row('SELECT id FROM folders WHERE path = ?', [$current])['id'];
}
return $parent;
}
}
+311
View File
@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
use DomainException;
use InvalidArgumentException;
use PDO;
final class PdoImportJobRepository extends PdoRepository implements ImportJobRepositoryInterface
{
public function create(string $sourcePath, array $items): int
{
if ($sourcePath === '') {
throw new InvalidArgumentException('Source path must not be empty.');
}
foreach ($items as $index => $item) {
if (!isset($item['source_path']) || !isset($item['size'])) {
throw new InvalidArgumentException("Item $index requires source_path and size.");
}
if ($item['size'] < 0) {
throw new InvalidArgumentException("Item $index size must be non-negative.");
}
$allowed = ['Pending', 'Copying', 'Completed', 'Failed', 'Skipped'];
if (isset($item['status']) && !in_array($item['status'], $allowed, true)) {
throw new InvalidArgumentException("Item $index has invalid status.");
}
}
return $this->transaction(function () use ($sourcePath, $items): int {
$job = $this->row(
'INSERT INTO import_jobs (source_path, total_files, total_bytes) VALUES (?, ?, ?) RETURNING id',
[$sourcePath, count($items), array_sum(array_column($items, 'size'))]
);
$jobId = (int) $job['id'];
if ($items === []) {
// Empty manifests stay claimable; completion is an explicit caller decision.
return $jobId;
}
$statement = $this->pdo->prepare(
'INSERT INTO import_items (job_id, source_path, size, model_id, status, failure_reason)'
. ' VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($items as $item) {
$status = $item['status'] ?? 'Pending';
$failureReason = $item['failure_reason'] ?? null;
$statement->execute([
$jobId,
$item['source_path'],
$item['size'],
$item['model_id'] ?? null,
$status,
$failureReason,
]);
}
// Scan-time failures arrive terminal, so publish progress immediately.
$this->refresh($jobId);
return $jobId;
});
}
public function get(int $jobId): ?array
{
return $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]);
}
public function items(int $jobId): array
{
return $this->execute('SELECT * FROM import_items WHERE job_id = ? ORDER BY id', [$jobId])
->fetchAll(PDO::FETCH_ASSOC);
}
public function claimNext(): ?array
{
return $this->transaction(function (): ?array {
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
// SKIP LOCKED lets concurrent workers claim distinct jobs without blocking.
$candidate = $this->row(
"SELECT id FROM import_jobs WHERE status = 'Pending'"
. ' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1'
);
if ($candidate === null) {
return null;
}
return $this->row(
"UPDATE import_jobs SET status = 'Copying', started_at = CURRENT_TIMESTAMP"
. ' WHERE id = ? RETURNING *',
[$candidate['id']]
);
}
// SQLite serializes writers, so a single conditional UPDATE is already atomic.
return $this->row(
"UPDATE import_jobs SET status = 'Copying', started_at = CURRENT_TIMESTAMP"
. " WHERE id = (SELECT id FROM import_jobs WHERE status = 'Pending' ORDER BY id LIMIT 1)"
. ' RETURNING *'
);
});
}
public function resumeInterrupted(): void
{
$this->transaction(function (): void {
// Plans (managed_path/hash) are deliberately preserved for idempotent recovery.
$this->execute(
"UPDATE import_items SET status = 'Pending', started_at = NULL WHERE status = 'Copying'"
. " AND job_id IN (SELECT id FROM import_jobs WHERE status IN ('Scanning', 'Copying'))"
);
$this->execute(
"UPDATE import_jobs SET status = 'Pending' WHERE status IN ('Scanning', 'Copying')"
);
});
}
public function planItem(int $itemId, string $managedPath, string $hash, int $size): void
{
if ($managedPath === '' || $hash === '' || $size < 0) {
throw new InvalidArgumentException('A plan requires a path, hash and non-negative size.');
}
$this->transaction(function () use ($itemId, $managedPath, $hash, $size): void {
$item = $this->lockedItem($itemId);
if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) {
throw new DomainException('Cannot plan a terminal import item.');
}
if ($item['managed_path'] !== null) {
if (
$item['managed_path'] !== $managedPath || $item['hash'] !== $hash
|| (int) $item['size'] !== $size
) {
throw new DomainException('An import plan is immutable once recorded.');
}
return;
}
$this->execute(
"UPDATE import_items SET status = 'Copying', managed_path = ?, hash = ?, size = ?,"
. ' started_at = CURRENT_TIMESTAMP WHERE id = ?',
[$managedPath, $hash, $size, $itemId]
);
});
}
public function updateManagedPath(int $itemId, string $managedPath): void
{
if ($managedPath === '') {
throw new InvalidArgumentException('A managed path must not be empty.');
}
$this->transaction(function () use ($itemId, $managedPath): void {
$item = $this->lockedItem($itemId);
if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) {
throw new DomainException('Cannot update a terminal import item.');
}
$this->execute(
'UPDATE import_items SET managed_path = ? WHERE id = ?',
[$managedPath, $itemId]
);
});
}
public function finishItem(int $itemId, string $status, ?string $failureReason = null): void
{
if (!in_array($status, ['Completed', 'Failed', 'Skipped'], true)) {
throw new InvalidArgumentException('Only terminal item statuses may finish an item.');
}
$this->transaction(function () use ($itemId, $status, $failureReason): void {
$item = $this->lockedItem($itemId);
if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) {
if ($item['status'] !== $status) {
throw new DomainException('Import item already reached a different terminal status.');
}
return;
}
$this->execute(
'UPDATE import_items SET status = ?, failure_reason = ?, completed_at = CURRENT_TIMESTAMP'
. ' WHERE id = ?',
[$status, $failureReason, $itemId]
);
$this->refresh((int) $item['job_id']);
});
}
public function release(int $jobId): void
{
$this->transaction(function () use ($jobId): void {
$this->execute(
"UPDATE import_items SET status = 'Pending', started_at = NULL"
. " WHERE job_id = ? AND status = 'Copying'",
[$jobId]
);
$this->execute(
"UPDATE import_jobs SET status = 'Pending' WHERE id = ? AND status IN ('Scanning', 'Copying')",
[$jobId]
);
});
}
public function cancel(int $jobId): void
{
$this->terminate($jobId, 'Cancelled', 'Skipped', null);
}
public function fail(int $jobId, string $failureReason): void
{
if ($failureReason === '') {
throw new InvalidArgumentException('A failure reason is required.');
}
$this->terminate($jobId, 'Failed', 'Failed', $failureReason);
}
public function completeEmpty(int $jobId): void
{
$this->transaction(function () use ($jobId): void {
$job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]);
if ($job === null) {
throw new DomainException('Unknown import job.');
}
if ($this->items($jobId) !== []) {
throw new DomainException('Import job still has items.');
}
if (in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) {
return;
}
$this->execute(
"UPDATE import_jobs SET status = 'Completed', processed_files = 0,"
. ' completed_at = CURRENT_TIMESTAMP WHERE id = ?',
[$jobId]
);
});
}
public function atomic(callable $callback): mixed
{
return $this->transaction($callback);
}
/** Terminal job transitions share one shape: finish open items, then close the job. */
private function terminate(int $jobId, string $jobStatus, string $itemStatus, ?string $reason): void
{
$this->transaction(function () use ($jobId, $jobStatus, $itemStatus, $reason): void {
$job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]);
if ($job === null) {
throw new DomainException('Unknown import job.');
}
if (in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) {
return;
}
$this->execute(
'UPDATE import_items SET status = ?, failure_reason = ?, completed_at = CURRENT_TIMESTAMP'
. " WHERE job_id = ? AND status IN ('Pending', 'Copying')",
[$itemStatus, $reason, $jobId]
);
$this->execute(
'UPDATE import_jobs SET status = ?, processed_files = ?, copied_bytes = ?,'
. ' completed_at = CURRENT_TIMESTAMP WHERE id = ?',
[$jobStatus, $this->processedFiles($jobId), $this->copiedBytes($jobId), $jobId]
);
});
}
/** Progress is recomputed from items so repeated or crashed updates cannot drift. */
private function refresh(int $jobId): void
{
$job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]);
if ($job === null || in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) {
return;
}
$total = (int) $this->row('SELECT COUNT(*) AS total FROM import_items WHERE job_id = ?', [$jobId])['total'];
$processed = $this->processedFiles($jobId);
$failed = (int) $this->row(
"SELECT COUNT(*) AS failed FROM import_items WHERE job_id = ? AND status = 'Failed'",
[$jobId]
)['failed'];
if ($total === 0 || $processed < $total) {
$this->execute(
'UPDATE import_jobs SET processed_files = ?, copied_bytes = ? WHERE id = ?',
[$processed, $this->copiedBytes($jobId), $jobId]
);
return;
}
$this->execute(
'UPDATE import_jobs SET status = ?, processed_files = ?, copied_bytes = ?,'
. ' completed_at = CURRENT_TIMESTAMP WHERE id = ?',
[$failed > 0 ? 'Failed' : 'Completed', $processed, $this->copiedBytes($jobId), $jobId]
);
}
private function processedFiles(int $jobId): int
{
return (int) $this->row(
'SELECT COUNT(*) AS processed FROM import_items WHERE job_id = ?'
. " AND status IN ('Completed', 'Failed', 'Skipped')",
[$jobId]
)['processed'];
}
private function copiedBytes(int $jobId): int
{
return (int) $this->row(
"SELECT COALESCE(SUM(size), 0) AS copied FROM import_items WHERE job_id = ? AND status = 'Completed'",
[$jobId]
)['copied'];
}
private function lockedItem(int $itemId): array
{
$suffix = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
$item = $this->row('SELECT * FROM import_items WHERE id = ?' . $suffix, [$itemId]);
if ($item === null) {
throw new DomainException('Unknown import item.');
}
return $item;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
use PDO;
use PDOStatement;
use Throwable;
/** @internal Shared SQL and composable transaction support. */
abstract class PdoRepository
{
public function __construct(protected PDO $pdo)
{
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
protected function execute(string $sql, array $parameters = []): PDOStatement
{
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
return $statement;
}
protected function row(string $sql, array $parameters = []): ?array
{
$statement = $this->execute($sql, $parameters);
$row = $statement->fetch(PDO::FETCH_ASSOC);
$statement->closeCursor();
return $row === false ? null : $row;
}
protected function transaction(callable $callback): mixed
{
$nested = $this->pdo->inTransaction();
$savepoint = 'repository_' . bin2hex(random_bytes(8));
if ($nested) {
$this->pdo->exec('SAVEPOINT ' . $savepoint);
} else {
$this->pdo->beginTransaction();
}
try {
$result = $callback();
if ($nested) {
$this->pdo->exec('RELEASE SAVEPOINT ' . $savepoint);
} else {
$this->pdo->commit();
}
return $result;
} catch (Throwable $error) {
if ($nested) {
$this->pdo->exec('ROLLBACK TO SAVEPOINT ' . $savepoint);
$this->pdo->exec('RELEASE SAVEPOINT ' . $savepoint);
} elseif ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $error;
}
}
}
+418
View File
@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use MeshVault\Indexer\FileEnumerator;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\ImportService;
use MeshVault\Indexer\ImportWorker;
use MeshVault\Indexer\SpaceChecker;
use MeshVault\Indexer\StlValidator;
use MeshVault\Indexer\StreamingHasher;
use MeshVault\Indexer\VerifiedCopier;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
final class ImportPipelineTest extends TestCase
{
private PDO $pdo;
private string $database;
private string $library;
private string $source;
private PdoImportJobRepository $jobs;
private PdoCatalogRepository $catalog;
protected function setUp(): void
{
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-pipeline-');
$this->pdo = RepositoryDatabase::migrate($this->database);
$this->jobs = new PdoImportJobRepository($this->pdo);
$this->catalog = new PdoCatalogRepository($this->pdo);
$this->library = RepositoryDatabase::directory('library');
$this->source = RepositoryDatabase::directory('source');
}
protected function tearDown(): void
{
unset($this->pdo, $this->jobs, $this->catalog);
@unlink($this->database);
RepositoryDatabase::remove($this->library);
RepositoryDatabase::remove($this->source);
}
private function service(?SpaceChecker $space = null): ImportService
{
$safety = new PathSafety($this->source);
return new ImportService(
new FileEnumerator(new LocalFilesystem(), $safety),
$this->jobs,
$this->catalog,
$space ?? new SpaceChecker(),
$this->library,
);
}
private function worker(?callable $shouldStop = null): ImportWorker
{
$safety = new PathSafety($this->library);
$sourceSafety = new PathSafety($this->source);
return new ImportWorker(
$this->jobs,
$this->catalog,
new StreamingHasher($sourceSafety),
new VerifiedCopier($safety),
new StlValidator($sourceSafety),
$safety,
new SpaceChecker(),
$this->library,
new NullLogger(),
$shouldStop,
);
}
private function writeStl(string $relative, string $variant = 'default'): string
{
$path = $this->source . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
if (!is_dir(dirname($path))) {
mkdir(dirname($path), 0777, true);
}
$facet = "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
$content = "solid test_$variant\n" . $facet . "endsolid test_$variant\n";
file_put_contents($path, $content);
return $path;
}
public function testImportCreatesJobWithExplicitSubfolderGrouping(): void
{
$this->writeStl('planes/wing.stl');
$this->writeStl('planes/body.stl');
$this->writeStl('loose.stl');
$job = $this->service()->import($this->source);
$items = $this->jobs->items($job);
self::assertCount(3, $items);
self::assertSame('Pending', $this->jobs->get($job)['status']);
$models = [];
foreach ($items as $item) {
self::assertNotNull($item['model_id']);
$aggregate = $this->catalog->aggregate((int) $item['model_id']);
$models[$aggregate['model']['name']] = $aggregate['folder']['path'];
}
// Explicit policy: one model per immediate source subfolder; root files join
// a model named after the source directory itself.
self::assertSame('/planes', $models['planes']);
self::assertSame('/' . basename($this->source), $models[basename($this->source)]);
}
public function testImportRejectsEmptySourceAndInsufficientSpace(): void
{
try {
$this->service()->import($this->source);
self::fail('Empty source accepted');
} catch (ImportException $error) {
self::assertSame('SCAN_EMPTY', $error->reason);
}
$this->writeStl('planes/wing.stl');
$full = new SpaceChecker(0.05, static fn (): float => 1.0);
try {
$this->service($full)->import($this->source);
self::fail('Full disk accepted');
} catch (ImportException $error) {
self::assertSame('DISK_FULL', $error->reason);
}
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn());
}
public function testWorkerCopiesPlansAndCompletesCatalog(): void
{
$this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
self::assertSame($job, $this->worker()->runOnce());
$item = $this->jobs->items($job)[0];
self::assertSame('Completed', $item['status']);
self::assertSame('Completed', $this->jobs->get($job)['status']);
self::assertNotNull($item['managed_path']);
$published = $this->library . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
self::assertFileExists($published);
self::assertSame(hash_file('sha256', $published), $item['hash']);
$files = $this->catalog->aggregate((int) $item['model_id'])['files'];
self::assertCount(1, $files);
self::assertSame('Present', $files[0]['status']);
self::assertSame($item['managed_path'], $files[0]['managed_path']);
self::assertNull($this->worker()->runOnce());
}
public function testFailedItemsDoNotStopRemainingWork(): void
{
$this->writeStl('planes/wing.stl');
$broken = $this->writeStl('planes/broken.stl');
file_put_contents($broken, 'not an stl at all');
$job = $this->service()->import($this->source);
$this->worker()->runOnce();
$statuses = array_column($this->jobs->items($job), 'status');
self::assertContains('Failed', $statuses);
self::assertContains('Completed', $statuses);
self::assertSame('Failed', $this->jobs->get($job)['status']);
self::assertSame(2, (int) $this->jobs->get($job)['processed_files']);
$failure = array_values(array_filter(
$this->jobs->items($job),
static fn (array $item): bool => $item['status'] === 'Failed'
))[0];
self::assertStringContainsString('STL_INVALID', (string) $failure['failure_reason']);
}
public function testCrashRecoveryCompletesPlannedItemWithoutDuplicateCopy(): void
{
$this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
$this->worker()->runOnce();
$item = $this->jobs->items($job)[0];
// Simulate a crash after the copy but before the catalog/progress commit.
$this->pdo->exec(
"UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id']
);
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job");
$this->pdo->exec('DELETE FROM files');
$this->jobs->resumeInterrupted();
self::assertSame($job, $this->worker()->runOnce());
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
self::assertSame($item['managed_path'], $this->jobs->items($job)[0]['managed_path']);
$directory = dirname($this->library . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $item['managed_path']));
self::assertCount(1, array_diff(scandir($directory), ['.', '..']));
self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']);
}
public function testGracefulStopLeavesJobResumable(): void
{
$this->writeStl('planes/wing.stl');
$this->writeStl('planes/body.stl');
$job = $this->service()->import($this->source);
$calls = 0;
$this->worker(static function () use (&$calls): bool {
// Stop only after the first item has been processed.
return $calls++ > 0;
})->runOnce();
self::assertSame('Pending', $this->jobs->get($job)['status']);
$statuses = array_column($this->jobs->items($job), 'status');
self::assertContains('Pending', $statuses);
self::assertContains('Completed', $statuses);
self::assertSame($job, $this->worker()->runOnce());
self::assertSame('Completed', $this->jobs->get($job)['status']);
self::assertSame(2, (int) $this->jobs->get($job)['processed_files']);
}
public function testConcurrentWorkerCannotHoldTheLibraryLock(): void
{
$this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
$lock = fopen($this->library . DIRECTORY_SEPARATOR . '.meshvault-import.lock', 'c');
self::assertNotFalse($lock);
self::assertTrue(flock($lock, LOCK_EX | LOCK_NB));
try {
$this->worker()->runOnce();
self::fail('Worker ignored the global library lock');
} catch (ImportException $error) {
self::assertSame('LOCK_UNAVAILABLE', $error->reason);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
self::assertSame('Pending', $this->jobs->get($job)['status']);
}
public function testTwoSameBasenameSourcesPublishWithCollisionSuffixAndBothRowsPointToRealFiles(): void
{
// Two sources with same basename but DIFFERENT content in different subdirectories
$this->writeStl('planes/wing.stl', 'wing1');
$this->writeStl('planes/sub/wing.stl', 'wing2');
$job = $this->service()->import($this->source);
$this->worker()->runOnce();
$items = $this->jobs->items($job);
self::assertCount(2, $items);
// Both should complete successfully
$statuses = array_column($items, 'status');
self::assertSame(['Completed', 'Completed'], $statuses);
// Both managed_path entries should point to actual published files (with suffixes)
foreach ($items as $item) {
$published = $this->library . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
self::assertFileExists($published, "Published file missing for managed_path: " . $item['managed_path']);
self::assertSame(hash_file('sha256', $published), $item['hash']);
}
// Filenames should differ (one gets _001 suffix)
$basenames = array_map('basename', array_column($items, 'managed_path'));
self::assertNotSame($basenames[0], $basenames[1], 'Collision suffix not applied - both have same basename');
self::assertMatchesRegularExpression('/^wing(_001)?\\.stl$/', $basenames[0]);
self::assertMatchesRegularExpression('/^wing(_001)?\\.stl$/', $basenames[1]);
}
public function testCrashAfterPublishBeforeCompletionResumesWithoutRecopyOrCollision(): void
{
$this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
$this->worker()->runOnce();
$item = $this->jobs->items($job)[0];
// Simulate crash AFTER copy but BEFORE catalog/progress commit
// Set item to Copying (plan already committed, file already copied)
$this->pdo->exec(
"UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id']
);
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job");
$this->pdo->exec('DELETE FROM files'); // Remove catalog entry but keep file on disk
// Resume and run again
$this->jobs->resumeInterrupted();
$resumedJob = $this->worker()->runOnce();
self::assertSame($job, $resumedJob);
// Item should complete without re-copying
$resumedItem = $this->jobs->items($job)[0];
self::assertSame('Completed', $resumedItem['status']);
self::assertSame($item['managed_path'], $resumedItem['managed_path'], 'managed_path changed on resume');
// Only ONE file should exist in the destination directory (no duplicate copy)
$directory = dirname($this->library . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $item['managed_path']));
self::assertCount(1, array_diff(scandir($directory), ['.', '..']), 'Duplicate file created on resume');
// Catalog should have exactly one entry
self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']);
}
public function testAdoptExistingRevalidatesWithPathSafetyAndRejectsLinks(): void
{
// Create a legitimate file first
$this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
$this->worker()->runOnce();
$item = $this->jobs->items($job)[0];
$managedPath = $item['managed_path'];
$publishedPath = $this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $managedPath);
// Verify it exists and is a regular file
self::assertFileExists($publishedPath);
self::assertFalse(is_link($publishedPath));
// Now simulate a crash and replace the file with a symlink
$this->pdo->exec(
"UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id']
);
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job");
$this->pdo->exec('DELETE FROM files');
// Replace the file with a symlink pointing outside the library
$outside = $this->source . DIRECTORY_SEPARATOR . 'outside.stl';
file_put_contents($outside, 'malicious');
unlink($publishedPath);
if (!@symlink($outside, $publishedPath)) {
self::markTestSkipped('Host cannot create symlinks.');
}
// Resume - should reject the symlink and fail the item
$this->jobs->resumeInterrupted();
$this->worker()->runOnce();
$resumedItem = $this->jobs->items($job)[0];
self::assertSame('Failed', $resumedItem['status']);
self::assertStringContainsString('SYMLINK_ESCAPE', $resumedItem['failure_reason']);
}
public function testSpaceCheckerPreflightBeforeEachCopy(): void
{
$this->writeStl('planes/wing.stl');
$this->writeStl('planes/body.stl');
$job = $this->service()->import($this->source);
// Simulate disk full on second item
$spaceChecks = 0;
$spaceChecker = new SpaceChecker(0.05, static function (string $path) use (&$spaceChecks): float {
$spaceChecks++;
// First check passes, second fails
return $spaceChecks === 1 ? 1000000.0 : 100.0;
});
// Create worker with custom SpaceChecker
$safety = new PathSafety($this->library);
$sourceSafety = new PathSafety($this->source);
$worker = new ImportWorker(
$this->jobs,
$this->catalog,
new StreamingHasher($sourceSafety),
new VerifiedCopier($safety),
new StlValidator($sourceSafety),
$safety,
$spaceChecker,
$this->library,
new NullLogger(),
);
$worker->runOnce();
$items = $this->jobs->items($job);
$statuses = array_column($items, 'status');
// First item should complete, second should fail with DISK_FULL
self::assertSame('Completed', $statuses[0]);
self::assertSame('Failed', $statuses[1]);
self::assertStringContainsString('DISK_FULL', $items[1]['failure_reason']);
// Job should be Failed overall but first item completed
self::assertSame('Failed', $this->jobs->get($job)['status']);
}
public function testWorkerUsesCanonicalLibraryRootForRelativePaths(): void
{
// Test that the worker uses canonical (realpath) library root for
// computing relative paths, not the raw string which might have
// symlinks or case differences.
//
// Create library with a symlink in the path
$realLibrary = $this->library;
$linkLibrary = $realLibrary . '-link';
if (!@symlink($realLibrary, $linkLibrary)) {
self::markTestSkipped('Host cannot create symlinks.');
}
$this->writeStl('planes/wing.stl');
$job = $this->service()->import($this->source);
// Create worker with symlinked library root
$safety = new PathSafety($linkLibrary);
$sourceSafety = new PathSafety($this->source);
$worker = new ImportWorker(
$this->jobs,
$this->catalog,
new StreamingHasher($sourceSafety),
new VerifiedCopier($safety),
new StlValidator($sourceSafety),
$safety,
new SpaceChecker(),
$linkLibrary, // Symlinked path
new NullLogger(),
);
$worker->runOnce();
$item = $this->jobs->items($job)[0];
// The managed_path should be relative to the CANONICAL root, not the symlink
self::assertStringNotContainsString('link', $item['managed_path']);
$published = $this->library . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
self::assertFileExists($published);
// Cleanup
unlink($linkLibrary);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use PDO;
use Phinx\Config\Config;
use Phinx\Migration\Manager;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/** Runs the production migrations against SQLite and manages scratch directories. */
final class RepositoryDatabase
{
/** Never a hand-maintained test schema: drift must fail the suite. */
public static function migrate(string $path): PDO
{
$pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$config = new Config([
'paths' => ['migrations' => dirname(__DIR__, 2) . '/db/migrations'],
'environments' => [
'default_migration_table' => 'phinxlog',
'default_database' => 'test',
'test' => ['adapter' => 'sqlite', 'connection' => $pdo],
],
]);
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA busy_timeout = 5000');
return $pdo;
}
public static function directory(string $prefix): string
{
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'meshvault-' . $prefix . '-' . bin2hex(random_bytes(6));
mkdir($path, 0777, true);
return (string) realpath($path);
}
public static function remove(string $path): void
{
foreach (array_diff(@scandir($path) ?: [], ['.', '..']) as $entry) {
$child = $path . DIRECTORY_SEPARATOR . $entry;
is_dir($child) ? self::remove($child) : @unlink($child);
}
@rmdir($path);
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use DomainException;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use PDO;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class RepositoryTest extends TestCase
{
private PDO $pdo;
private string $database;
protected function setUp(): void
{
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-repository-');
$this->pdo = RepositoryDatabase::migrate($this->database);
}
protected function tearDown(): void
{
unset($this->pdo);
unlink($this->database);
}
public function testCatalogGroupingAndFileLifecycle(): void
{
self::assertTrue(class_exists(PdoCatalogRepository::class), 'Catalog repository must exist');
$catalog = new PdoCatalogRepository($this->pdo);
self::assertInstanceOf(CatalogRepositoryInterface::class, $catalog);
$model = $catalog->ensureModel('/vehicles/aircraft', 'Plane');
self::assertSame($model, $catalog->ensureModel('/vehicles/aircraft', 'Plane'));
self::assertNotSame($model, $catalog->ensureModel('/vehicles/aircraft', 'Other'));
$root = $catalog->ensureModel('/', 'Root');
self::assertNull($catalog->aggregate($root)['folder']['parent_id']);
self::assertSame(3, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn());
$file = $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12, 'model/stl');
self::assertSame($file, $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12));
foreach (['Missing', 'Updated', 'Duplicate', 'Present'] as $status) {
$catalog->setFileStatus($file, $status);
self::assertSame($status, $catalog->aggregate($model)['files'][0]['status']);
}
$aggregate = $catalog->aggregate($model);
self::assertSame('Plane', $aggregate['model']['name']);
self::assertSame('/vehicles/aircraft', $aggregate['folder']['path']);
self::assertSame('model/stl', $aggregate['files'][0]['mime_type']);
self::assertNull($catalog->aggregate(99999));
$this->expectException(DomainException::class);
$catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('b', 64), 12);
}
public function testRecoveryReleaseCancellationFailureAndEmptyJobs(): void
{
self::assertTrue(class_exists(PdoImportJobRepository::class));
$jobs = new PdoImportJobRepository($this->pdo);
$manifest = [['source_path' => '/a', 'size' => 5, 'model_id' => null]];
$job = $jobs->create('/', $manifest);
$jobs->claimNext();
$item = (int) $jobs->items($job)[0]['id'];
$jobs->planItem($item, 'a', 'hash', 5);
$jobs->release($job);
self::assertSame('Pending', $jobs->get($job)['status']);
$jobs->claimNext();
$jobs->resumeInterrupted();
self::assertSame('Pending', $jobs->items($job)[0]['status']);
self::assertSame('a', $jobs->items($job)[0]['managed_path']);
$this->pdo->exec("UPDATE import_jobs SET status = 'Scanning' WHERE id = $job");
$jobs->resumeInterrupted();
self::assertSame('Pending', $jobs->get($job)['status']);
$jobs->cancel($job);
$jobs->cancel($job);
$jobs->resumeInterrupted();
self::assertSame('Cancelled', $jobs->get($job)['status']);
self::assertSame('Skipped', $jobs->items($job)[0]['status']);
$failed = $jobs->create('/', $manifest);
$jobs->fail($failed, 'disk full');
$jobs->fail($failed, 'disk full');
self::assertSame('Failed', $jobs->get($failed)['status']);
self::assertSame('disk full', $jobs->items($failed)[0]['failure_reason']);
$empty = $jobs->create('/', []);
$jobs->completeEmpty($empty);
$jobs->completeEmpty($empty);
self::assertSame('Completed', $jobs->get($empty)['status']);
$scan = $jobs->create('/', [
['source_path' => '/bad', 'size' => 0, 'model_id' => null,
'status' => 'Failed', 'failure_reason' => 'scan failed'],
]);
self::assertSame('Failed', $jobs->get($scan)['status']);
self::assertSame(1, (int) $jobs->get($scan)['processed_files']);
self::assertNull($jobs->claimNext());
}
public function testImmutablePlansAndSuccessfulCompletion(): void
{
self::assertTrue(class_exists(PdoImportJobRepository::class));
$jobs = new PdoImportJobRepository($this->pdo);
$job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]);
$jobs->claimNext();
$item = (int) $jobs->items($job)[0]['id'];
$jobs->planItem($item, 'a', 'hash', 5);
foreach ([['b', 'hash', 5], ['a', 'other', 5], ['a', 'hash', 6]] as $plan) {
try {
$jobs->planItem($item, ...$plan);
self::fail('Plan mutation accepted');
} catch (DomainException) {
self::assertSame('a', $jobs->items($job)[0]['managed_path']);
}
}
$jobs->finishItem($item, 'Completed');
self::assertSame('Completed', $jobs->get($job)['status']);
$this->expectException(DomainException::class);
$jobs->finishItem($item, 'Failed', 'late failure');
}
public function testAtomicRollbackAndIndependentClaims(): void
{
self::assertTrue(class_exists(PdoImportJobRepository::class));
$jobs = new PdoImportJobRepository($this->pdo);
$catalog = new PdoCatalogRepository($this->pdo);
$job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]);
$second = $jobs->create('/', []);
$other = new PdoImportJobRepository(new PDO('sqlite:' . $this->database));
self::assertSame($job, (int) $jobs->claimNext()['id']);
self::assertSame($second, (int) $other->claimNext()['id']);
self::assertNull($other->claimNext());
try {
$jobs->atomic(function () use ($jobs, $catalog, $job): void {
$model = $catalog->ensureModel('/rollback', 'Rollback');
$catalog->recordFile($model, 'rollback/a', 'hash', 5);
$jobs->finishItem((int) $jobs->items($job)[0]['id'], 'Completed');
throw new RuntimeException('rollback');
});
self::fail('Expected rollback');
} catch (RuntimeException $error) {
self::assertSame('rollback', $error->getMessage());
}
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM models')->fetchColumn());
self::assertSame('Pending', $jobs->items($job)[0]['status']);
self::assertSame(42, $jobs->atomic(static fn (): int => 42));
$before = (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn();
try {
$jobs->create('/', [
['source_path' => '/valid', 'size' => 1, 'model_id' => null],
['source_path' => '/invalid', 'size' => 1, 'model_id' => 99999],
]);
self::fail('Expected foreign key rejection');
} catch (\PDOException) {
self::assertSame($before, (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn());
}
}
public function testManifestClaimPlanProgressAndIdempotency(): void
{
self::assertTrue(class_exists(PdoImportJobRepository::class), 'Job repository must exist');
$jobs = new PdoImportJobRepository($this->pdo);
self::assertInstanceOf(ImportJobRepositoryInterface::class, $jobs);
$job = $jobs->create('/source', [
['source_path' => '/source/a', 'size' => 10, 'model_id' => null],
['source_path' => '/source/b', 'size' => 20, 'model_id' => null],
['source_path' => '/source/c', 'size' => 30, 'model_id' => null],
]);
self::assertSame('Pending', $jobs->get($job)['status']);
self::assertSame(60, (int) $jobs->get($job)['total_bytes']);
self::assertSame($job, (int) $jobs->claimNext()['id']);
self::assertNotNull($jobs->get($job)['started_at']);
self::assertNull($jobs->claimNext());
$items = $jobs->items($job);
$jobs->planItem((int) $items[0]['id'], 'a.stl', str_repeat('a', 64), 10);
$jobs->planItem((int) $items[0]['id'], 'a.stl', str_repeat('a', 64), 10);
self::assertSame('Copying', $jobs->items($job)[0]['status']);
self::assertSame('a.stl', $jobs->items($job)[0]['managed_path']);
$jobs->finishItem((int) $items[0]['id'], 'Completed');
$jobs->finishItem((int) $items[0]['id'], 'Completed');
self::assertSame(1, (int) $jobs->get($job)['processed_files']);
self::assertSame(10, (int) $jobs->get($job)['copied_bytes']);
$jobs->finishItem((int) $items[1]['id'], 'Skipped');
$jobs->finishItem((int) $items[2]['id'], 'Failed', 'unreadable');
self::assertSame('Failed', $jobs->get($job)['status']);
self::assertSame(3, (int) $jobs->get($job)['processed_files']);
self::assertSame('unreadable', $jobs->items($job)[2]['failure_reason']);
self::assertNotNull($jobs->get($job)['completed_at']);
self::assertNull($jobs->get(99999));
self::assertSame([], $jobs->items(99999));
}
}
+8
View File
@@ -43,6 +43,14 @@ final class TaskRunnerTest extends TestCase
self::assertStringContainsString('compose', $output);
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
}
// Import engine entrypoints must reach bin/meshvault with their arguments intact.
foreach (['import', 'worker', 'worker-once'] as $command) {
[$code, $output] = $this->invoke([...$runner, $command, '--probe'], $temporary, $environment);
self::assertSame(37, $code, $command . ': ' . $output);
self::assertStringContainsString('bin/meshvault', $output);
self::assertStringContainsString($command, $output);
self::assertStringContainsString('--probe', $output);
}
foreach (['unknown'] as $command) {
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
self::assertNotSame(0, $code);
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\ConsoleServices;
use MeshVault\Console\LazyConsoleServices;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ConsoleServicesWiringTest extends TestCase
{
public function testLazyServicesDoNotBuildTheEngineUntilUsed(): void
{
$built = 0;
$services = new LazyConsoleServices(function () use (&$built): ConsoleServices {
$built++;
return new FakeConsoleServices(importResult: 9);
});
self::assertSame(0, $built, 'Construction alone must not touch configuration.');
self::assertSame(9, $services->import('/srv/in', ['stl']));
self::assertSame(1, $built);
}
public function testLazyServicesBuildTheEngineOnlyOnce(): void
{
$built = 0;
$delegate = new FakeConsoleServices(jobs: [1, 2]);
$services = new LazyConsoleServices(function () use (&$built, $delegate): ConsoleServices {
$built++;
return $delegate;
});
$services->runWorkerOnce(static fn (): bool => false);
$services->runWorkerOnce(static fn (): bool => false);
self::assertSame(1, $built);
self::assertSame(2, $delegate->runs);
}
public function testLazyServicesPropagateConstructionFailures(): void
{
$services = new LazyConsoleServices(static function (): ConsoleServices {
throw new RuntimeException('bad configuration');
});
$this->expectException(RuntimeException::class);
$services->runWorkerOnce(static fn (): bool => false);
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\Console;
use MeshVault\Console\ConsoleServices;
use MeshVault\Indexer\ImportException;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ConsoleTest extends TestCase
{
public function testHelpIsSuccessfulAndListsEverySubcommand(): void
{
[$code, $out, $err] = $this->execute(['help']);
self::assertSame(0, $code);
self::assertSame('', $err);
foreach (['import', 'worker', 'worker-once', '--ext'] as $fragment) {
self::assertStringContainsString($fragment, $out);
}
}
public function testMissingCommandIsAUsageError(): void
{
[$code, $out, $err] = $this->execute([]);
self::assertSame(1, $code);
self::assertSame('', $out);
self::assertStringContainsString('Usage: meshvault', $err);
}
public function testUnknownCommandIsAUsageError(): void
{
[$code, , $err] = $this->execute(['nope']);
self::assertSame(1, $code);
self::assertStringContainsString('Unknown command', $err);
}
public function testImportWithoutSourceIsAUsageError(): void
{
[$code, , $err] = $this->execute(['import']);
self::assertSame(1, $code);
self::assertStringContainsString('import <source-path>', $err);
}
public function testImportRejectsExtraOperandsAndUnknownOptions(): void
{
[$code, , $err] = $this->execute(['import', 'a', 'b']);
self::assertSame(1, $code);
self::assertStringContainsString('one source path', $err);
[$code, , $err] = $this->execute(['import', 'a', '--deep']);
self::assertSame(1, $code);
self::assertStringContainsString('Unknown option', $err);
self::assertStringContainsString('--deep', $err);
}
public function testImportRejectsEmptyExtensionList(): void
{
foreach (['--ext=', '--ext= , '] as $option) {
[$code, , $err] = $this->execute(['import', 'a', $option]);
self::assertSame(1, $code, $option);
self::assertStringContainsString('--ext', $err);
}
}
public function testImportQueuesAJobWithDefaultExtensions(): void
{
$services = new FakeConsoleServices(importResult: 42);
[$code, $out] = $this->execute(['import', '/srv/incoming'], $services);
self::assertSame(0, $code);
self::assertSame(
[['/srv/incoming', ['stl', 'jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'md']]],
$services->imports
);
self::assertStringContainsString('42', $out);
}
public function testImportNormalizesTheExtensionOption(): void
{
$services = new FakeConsoleServices(importResult: 7);
[$code] = $this->execute(['import', '/srv/in', '--ext= STL , .3mf ,stl'], $services);
self::assertSame(0, $code);
self::assertSame([['/srv/in', ['stl', '3mf']]], $services->imports);
}
public function testImportFailureReportsTheReasonCodeAndExitsTwo(): void
{
$services = new FakeConsoleServices(importError: new ImportException('SCAN_EMPTY', 'Nothing found.'));
[$code, $out, $err] = $this->execute(['import', '/srv/in'], $services);
self::assertSame(2, $code);
self::assertSame('', $out);
self::assertStringContainsString('SCAN_EMPTY', $err);
self::assertStringContainsString('Nothing found.', $err);
}
public function testUnexpectedFailureIsReportedWithoutLeakingDetails(): void
{
$services = new FakeConsoleServices(
importError: new RuntimeException('SQLSTATE[08006] password=hunter2')
);
[$code, , $err] = $this->execute(['import', '/srv/in'], $services);
self::assertSame(2, $code);
self::assertStringNotContainsString('hunter2', $err);
self::assertStringNotContainsString('#0 ', $err);
self::assertStringContainsString('RuntimeException', $err);
}
public function testWorkerOnceReportsTheProcessedJob(): void
{
$services = new FakeConsoleServices(jobs: [11]);
[$code, $out] = $this->execute(['worker-once'], $services);
self::assertSame(0, $code);
self::assertSame(1, $services->runs);
self::assertStringContainsString('11', $out);
}
public function testWorkerOnceReportsAnIdleQueue(): void
{
$services = new FakeConsoleServices(jobs: [null]);
[$code, $out] = $this->execute(['worker-once'], $services);
self::assertSame(0, $code);
self::assertStringContainsString('No pending import jobs', $out);
}
public function testWorkerOnceRejectsOperands(): void
{
[$code, , $err] = $this->execute(['worker-once', 'x']);
self::assertSame(1, $code);
self::assertStringContainsString('takes no arguments', $err);
}
public function testWorkerLoopsUntilTheIterationBoundAndSleepsWhenIdle(): void
{
$services = new FakeConsoleServices(jobs: [5, null, null]);
$slept = [];
[$code, $out] = $this->execute(
['worker', '--max-iterations=3', '--sleep=2'],
$services,
static function (int $seconds) use (&$slept): void {
$slept[] = $seconds;
}
);
self::assertSame(0, $code);
self::assertSame(3, $services->runs);
// Only idle passes sleep; a processed job is followed immediately by the next claim.
self::assertSame([2, 2], $slept);
self::assertStringContainsString('stopped', $out);
}
public function testWorkerStopsWhenAStopIsRequested(): void
{
$services = new FakeConsoleServices(jobs: [1, 2, 3]);
$calls = 0;
[$code, $out] = $this->execute(
['worker', '--max-iterations=99'],
$services,
null,
static function () use (&$calls): bool {
$calls++;
return $calls > 2;
}
);
self::assertSame(0, $code);
self::assertSame(2, $services->runs);
self::assertStringContainsString('stopped', $out);
}
public function testWorkerRejectsInvalidOptions(): void
{
foreach (['--max-iterations=0', '--max-iterations=x', '--sleep=-1', '--bogus', 'operand'] as $argument) {
[$code, , $err] = $this->execute(['worker', $argument]);
self::assertSame(1, $code, $argument);
self::assertNotSame('', $err);
}
}
public function testWorkerFailureExitsTwo(): void
{
$services = new FakeConsoleServices(
workerError: new ImportException('LOCK_UNAVAILABLE', 'Another worker holds the library lock.')
);
[$code, , $err] = $this->execute(['worker', '--max-iterations=1'], $services);
self::assertSame(2, $code);
self::assertStringContainsString('LOCK_UNAVAILABLE', $err);
}
/** @return array{0: int, 1: string, 2: string} */
private function execute(
array $argv,
?ConsoleServices $services = null,
?callable $sleep = null,
?callable $stopRequested = null
): array {
$out = fopen('php://memory', 'w+');
$err = fopen('php://memory', 'w+');
$console = new Console(
$services ?? new FakeConsoleServices(),
$out,
$err,
$sleep ?? static function (int $seconds): void {
},
$stopRequested ?? static fn (): bool => false
);
$code = $console->run($argv);
rewind($out);
rewind($err);
return [$code, (string) stream_get_contents($out), (string) stream_get_contents($err)];
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Config\AppConfig;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use PDO;
use Phinx\Config\Config;
use Phinx\Migration\Manager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
final class ContainerWiringTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$config = new Config([
'paths' => ['migrations' => __DIR__ . '/../../../db/migrations'],
'environments' => [
'default_migration_table' => 'phinxlog',
'default_database' => 'test',
'test' => ['adapter' => 'sqlite', 'connection' => $this->pdo],
],
]);
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
public function testRepositoriesShareSamePdoInstance(): void
{
// L5: assert that the container would wire both repositories to the SAME PDO
// instance so the job repository's atomic() is a real transaction, not a no-op.
$catalog = new PdoCatalogRepository($this->pdo);
$jobs = new PdoImportJobRepository($this->pdo);
$reflectionCatalog = new \ReflectionObject($catalog);
$reflectionJobs = new \ReflectionObject($jobs);
$pdoCatalog = $reflectionCatalog->getProperty('pdo');
$pdoCatalog->setAccessible(true);
$pdoJobs = $reflectionJobs->getProperty('pdo');
$pdoJobs->setAccessible(true);
self::assertSame(
$pdoCatalog->getValue($catalog),
$pdoJobs->getValue($jobs),
'CatalogRepository and ImportJobRepository must share the same PDO instance for atomic() to work'
);
}
public function testAtomicCallbackUsesSharedConnection(): void
{
$catalog = new PdoCatalogRepository($this->pdo);
$jobs = new PdoImportJobRepository($this->pdo);
// Test that atomic() on jobs uses the same connection as catalog
$called = false;
$jobs->atomic(function () use ($jobs, $catalog, &$called): void {
$called = true;
// Inside the transaction, both repositories should see each other's changes
$jobId = $jobs->create('/tmp/source', [
['source_path' => '/tmp/source/file.stl', 'size' => 100, 'model_id' => null],
]);
$items = $jobs->items($jobId);
self::assertCount(1, $items);
});
self::assertTrue($called);
// After commit, the job should be visible
$allJobs = $this->pdo->query('SELECT * FROM import_jobs')->fetchAll(PDO::FETCH_ASSOC);
self::assertCount(1, $allJobs);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\ConsoleServices;
use Throwable;
final class FakeConsoleServices implements ConsoleServices
{
/** @var list<array{0: string, 1: list<string>}> */
public array $imports = [];
public int $runs = 0;
/** @param list<int|null> $jobs */
public function __construct(
private readonly int $importResult = 1,
private readonly ?Throwable $importError = null,
private array $jobs = [],
private readonly ?Throwable $workerError = null,
) {
}
public function import(string $sourcePath, array $extensions): int
{
$this->imports[] = [$sourcePath, $extensions];
if ($this->importError !== null) {
throw $this->importError;
}
return $this->importResult;
}
public function runWorkerOnce(callable $shouldStop): ?int
{
$this->runs++;
if ($this->workerError !== null) {
throw $this->workerError;
}
// The real worker consults the callback between items; mirror that contract.
$shouldStop();
return array_shift($this->jobs);
}
}
+280
View File
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\Contracts\FilesystemInterface;
use MeshVault\Indexer\FileEnumerator;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\ScanError;
use MeshVault\Indexer\ScannedFile;
use MeshVault\Indexer\ScanResult;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class FileEnumeratorTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = dirname(__DIR__, 3) . '/.phpunit.cache/scan-' . bin2hex(random_bytes(6));
mkdir($this->root, 0777, true);
self::assertTrue(class_exists(FileEnumerator::class), 'FileEnumerator must exist');
}
protected function tearDown(): void
{
$this->removeTree($this->root);
}
private function removeTree(string $path): void
{
if (is_link($path)) {
if (PHP_OS_FAMILY === 'Windows' && is_dir($path)) {
rmdir($path);
} else {
unlink($path);
}
} elseif (is_dir($path)) {
foreach (scandir($path) as $entry) {
if ($entry !== '.' && $entry !== '..') {
$this->removeTree($path . '/' . $entry);
}
}
rmdir($path);
} else {
unlink($path);
}
}
private function scanner(?FilesystemInterface $fs = null, int $maxDepth = 128): FileEnumerator
{
return new FileEnumerator($fs ?? new LocalFilesystem(), new PathSafety($this->root), $maxDepth);
}
public function testNestedMixedCaseDefaultsAndMetadata(): void
{
mkdir($this->root . '/nested/deep', 0777, true);
$names = ['part.StL', 'a.JPG', 'b.jPeG', 'c.PNG', 'd.GiF', 'e.PDF', 'f.TXT', 'g.mD'];
foreach ($names as $name) {
file_put_contents($this->root . '/nested/deep/' . $name, "hello world\n");
}
file_put_contents($this->root . '/ignored.exe', 'ignore');
file_put_contents($this->root . '/extensionless', 'ignore');
$scanner = $this->scanner();
self::assertInstanceOf(FileEnumeratorInterface::class, $scanner);
$result = $scanner->enumerate($this->root);
self::assertInstanceOf(ScanResult::class, $result);
self::assertSame([], $result->errors);
self::assertCount(8, $result->files);
self::assertSame(96, $result->totalBytes);
$relative = [];
foreach ($result->files as $file) {
self::assertInstanceOf(ScannedFile::class, $file);
self::assertSame(realpath($this->root . '/' . $file->relativePath), $file->sourcePath);
self::assertSame(12, $file->size);
self::assertSame('text/plain', $file->mimeType);
$relative[] = $file->relativePath;
}
$expected = array_map(static fn (string $name): string => 'nested/deep/' . $name, $names);
sort($expected);
sort($relative);
self::assertSame($expected, $relative);
}
public function testEmptyDirectoryAndExplicitAllowlists(): void
{
self::assertEquals(new ScanResult([], 0, []), $this->scanner()->enumerate($this->root));
file_put_contents($this->root . '/keep.TXT', '123');
file_put_contents($this->root . '/skip.stl', '1234');
$result = $this->scanner()->enumerate($this->root, ['tXt']);
self::assertSame(['keep.TXT'], array_column($result->files, 'relativePath'));
self::assertSame(3, $result->totalBytes);
self::assertEquals(new ScanResult([], 0, []), $this->scanner()->enumerate($this->root, []));
}
/** @return iterable<string, array{string}> */
public static function failures(): iterable
{
foreach (['isReadable', 'getSize', 'getMimeType', 'realpath', 'isFile', 'readDirectory'] as $method) {
yield $method => [$method];
}
}
#[DataProvider('failures')]
public function testReadFailuresContinueSiblings(string $operation): void
{
file_put_contents($this->root . '/good.txt', 'good');
$bad = $this->root . '/bad.txt';
if ($operation === 'readDirectory') {
mkdir($bad);
file_put_contents($bad . '/hidden.txt', 'hidden');
} else {
file_put_contents($bad, 'bad');
}
$result = $this->scanner($this->failingFilesystem($operation, $bad))->enumerate($this->root);
self::assertSame(['good.txt'], array_column($result->files, 'relativePath'));
self::assertSame(4, $result->totalBytes);
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
self::assertSame(realpath($bad), $result->errors[0]->sourcePath);
self::assertNotSame('', $result->errors[0]->message);
}
private function failingFilesystem(string $operation, string $bad, mixed $failure = false): FilesystemInterface
{
$local = new LocalFilesystem();
$fs = $this->createMock(FilesystemInterface::class);
$bad = realpath($bad);
foreach (
['realpath', 'isReadable', 'isFile', 'isDir', 'isSymlink',
'getSize', 'getMimeType', 'readDirectory'] as $method
) {
$fs->method($method)->willReturnCallback(
static fn (string $path) => $method === $operation && realpath($path) === $bad
? $failure : $local->$method($path)
);
}
$fs->expects(self::never())->method('readStream');
return $fs;
}
public function testRootReadFailures(): void
{
foreach (['isReadable', 'readDirectory', 'realpath'] as $operation) {
$result = $this->scanner($this->failingFilesystem($operation, $this->root))->enumerate($this->root);
self::assertSame([], $result->files);
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
}
}
public function testDepthIsBoundedWithoutLosingSiblings(): void
{
mkdir($this->root . '/a/b', 0777, true);
file_put_contents($this->root . '/a/good.txt', 'ok');
file_put_contents($this->root . '/a/b/too-deep.txt', 'no');
$result = $this->scanner(maxDepth: 1)->enumerate($this->root);
self::assertSame(['a/good.txt'], array_column($result->files, 'relativePath'));
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
self::assertStringContainsString('depth', $result->errors[0]->message);
}
/** @return iterable<string, array{string}> */
public static function linkTypes(): iterable
{
foreach (['file', 'directory', 'dangling', 'ancestor'] as $type) {
yield $type => [$type];
}
}
#[DataProvider('linkTypes')]
public function testSymlinksAreReportedAndNeverFollowed(string $type): void
{
mkdir($this->root . '/source');
mkdir($this->root . '/target');
file_put_contents($this->root . '/target/outside.txt', 'outside');
file_put_contents($this->root . '/source/good.txt', 'good');
$target = match ($type) {
'file' => $this->root . '/target/outside.txt',
'dangling' => $this->root . '/missing',
default => $this->root . '/target',
};
$link = $this->root . '/source/link.exe';
if (!function_exists('symlink') || !@symlink($target, $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$source = $type === 'ancestor' ? $link . '/outside.txt' : $this->root . '/source';
$result = $this->scanner()->enumerate($source);
self::assertSame($type === 'ancestor' ? [] : ['good.txt'], array_column($result->files, 'relativePath'));
self::assertCount(1, $result->errors);
self::assertSame('SYMLINK_ESCAPE', $result->errors[0]->code);
$rootResult = $this->scanner()->enumerate($link);
self::assertSame('SYMLINK_ESCAPE', $rootResult->errors[0]->code);
}
public function testPosixBackslashAncestorCannotSelectADifferentTree(): void
{
if (PHP_OS_FAMILY === 'Windows') {
self::markTestSkipped('Backslash-named ancestors require POSIX.');
}
mkdir($this->root . '/a/b/child', 0777, true);
mkdir($this->root . '/target/child', 0777, true);
file_put_contents($this->root . '/a/b/child/decoy.txt', 'decoy');
file_put_contents($this->root . '/target/child/outside.txt', 'outside');
$link = $this->root . '/a\\b';
if (!function_exists('symlink') || !@symlink($this->root . '/target', $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$result = $this->scanner()->enumerate($link . '/child');
self::assertSame([], $result->files);
self::assertSame(0, $result->totalBytes);
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
}
public function testTraversalRootAndEntriesRetainTheirCode(): void
{
$result = $this->scanner()->enumerate($this->root . '/../');
self::assertSame([], $result->files);
self::assertSame('PATH_TRAVERSAL', $result->errors[0]->code);
$fs = $this->failingFilesystem('readDirectory', $this->root, ['..']);
$result = $this->scanner($fs)->enumerate($this->root);
self::assertSame([], $result->files);
self::assertSame('PATH_TRAVERSAL', $result->errors[0]->code);
}
public function testZeroByteFileIsAccepted(): void
{
file_put_contents($this->root . '/empty.txt', '');
$result = $this->scanner()->enumerate($this->root);
self::assertSame([], $result->errors);
self::assertCount(1, $result->files);
self::assertSame(0, $result->files[0]->size);
self::assertSame(0, $result->totalBytes);
self::assertNotSame('', $result->files[0]->mimeType);
}
public function testInvalidMetadataContinuesSiblings(): void
{
file_put_contents($this->root . '/good.txt', 'good');
$bad = $this->root . '/bad.txt';
file_put_contents($bad, 'bad');
foreach (['getSize' => -1, 'getMimeType' => ''] as $operation => $value) {
$result = $this->scanner($this->failingFilesystem($operation, $bad, $value))->enumerate($this->root);
self::assertSame(['good.txt'], array_column($result->files, 'relativePath'));
self::assertSame(4, $result->totalBytes);
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
self::assertStringContainsString('check permissions', $result->errors[0]->message);
}
}
public function testMissingFileAndInvalidRootsAreActionable(): void
{
file_put_contents($this->root . '/file.txt', 'text');
foreach ([$this->root . '/absent', $this->root . '/file.txt', '', "bad\0path"] as $root) {
$result = $this->scanner()->enumerate($root);
self::assertSame([], $result->files);
self::assertSame(0, $result->totalBytes);
self::assertCount(1, $result->errors);
self::assertInstanceOf(ScanError::class, $result->errors[0]);
self::assertSame($root, $result->errors[0]->sourcePath);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
self::assertStringContainsString('source root', $result->errors[0]->message);
self::assertStringContainsString('Choose', $result->errors[0]->message);
self::assertStringContainsString('directory', $result->errors[0]->message);
if ($root === '' || str_contains($root, "\0")) {
self::assertStringContainsString('non-empty', $result->errors[0]->message);
self::assertStringContainsString('NUL', $result->errors[0]->message);
} else {
self::assertStringContainsString('existing', $result->errors[0]->message);
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\Contracts\FilesystemInterface;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use PHPUnit\Framework\TestCase;
final class LocalFilesystemTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = dirname(__DIR__, 3) . '/.phpunit.cache/fs-' . bin2hex(random_bytes(6));
mkdir($this->root, 0777, true);
self::assertTrue(class_exists(LocalFilesystem::class), 'LocalFilesystem must exist');
}
protected function tearDown(): void
{
foreach (glob($this->root . '/*') as $path) {
if (is_link($path) || is_file($path)) {
unlink($path);
} else {
if (is_dir($path . '/nested')) {
rmdir($path . '/nested');
}
rmdir($path);
}
}
rmdir($this->root);
}
public function testNativeMetadataAndStreamOperations(): void
{
$fs = new LocalFilesystem();
self::assertInstanceOf(FilesystemInterface::class, $fs);
$path = $this->root . '/sample.txt';
$stream = $fs->writeStream($path);
self::assertIsResource($stream);
try {
self::assertSame(12, fwrite($stream, "hello world\n"));
} finally {
fclose($stream);
}
self::assertSame(realpath($path), $fs->realpath($path));
self::assertTrue($fs->isReadable($path));
self::assertTrue($fs->isFile($path));
self::assertFalse($fs->isDir($path));
self::assertFalse($fs->isSymlink($path));
self::assertTrue($fs->isDir($this->root));
self::assertSame(12, $fs->getSize($path));
self::assertSame('text/plain', $fs->getMimeType($path));
self::assertGreaterThan(0, $fs->freeSpace($this->root));
$stream = $fs->readStream($path);
self::assertIsResource($stream);
try {
self::assertSame("hello world\n", stream_get_contents($stream));
} finally {
fclose($stream);
}
self::assertTrue($fs->rename($path, $this->root . '/renamed.txt'));
self::assertFalse($fs->isFile($path));
self::assertTrue($fs->unlink($this->root . '/renamed.txt'));
self::assertTrue($fs->mkdir($this->root . '/directory/nested', 0777, true));
self::assertTrue($fs->isDir($this->root . '/directory/nested'));
}
public function testDirectoryEntriesExcludeDotsAndFailuresReturnFalse(): void
{
$fs = new LocalFilesystem();
self::assertSame([], $fs->readDirectory($this->root));
file_put_contents($this->root . '/file.txt', 'text');
mkdir($this->root . '/nested');
self::assertSame(['file.txt', 'nested'], $fs->readDirectory($this->root));
self::assertFalse($fs->readDirectory($this->root . '/missing'));
self::assertFalse($fs->readDirectory($this->root . '/file.txt'));
}
public function testFailuresReturnFalseWithoutWarnings(): void
{
$fs = new LocalFilesystem();
$missing = $this->root . '/absent/file';
foreach (
['realpath', 'isReadable', 'isFile', 'isDir', 'isSymlink', 'getSize', 'getMimeType',
'readStream', 'writeStream', 'unlink', 'freeSpace'] as $operation
) {
self::assertFalse($fs->$operation($missing), $operation);
}
self::assertFalse($fs->rename($missing, $this->root . '/other'));
self::assertFalse($fs->mkdir($missing));
self::assertFalse($fs->mkdir($this->root));
}
public function testSymlinkMetadataIncludesDanglingLinks(): void
{
$link = $this->root . '/link';
if (!function_exists('symlink') || !@symlink($this->root . '/absent', $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$fs = new LocalFilesystem();
self::assertTrue($fs->isSymlink($link));
self::assertFalse($fs->realpath($link));
self::assertTrue($fs->unlink($link));
self::assertFalse($fs->isSymlink($link));
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PathSafetyTest extends TestCase
{
private string $base;
private string $root;
protected function setUp(): void
{
$this->base = dirname(__DIR__, 3) . '/.phpunit.cache/paths-' . bin2hex(random_bytes(6));
mkdir($this->base, 0777, true);
$this->base = realpath($this->base);
$this->root = $this->base . '/library';
mkdir($this->root);
mkdir($this->base . '/library-sibling');
file_put_contents($this->base . '/source.txt', 'source');
file_put_contents($this->root . '/existing.txt', 'existing');
self::assertTrue(class_exists(PathSafety::class), 'PathSafety must exist');
}
protected function tearDown(): void
{
$remove = static function (string $path) use (&$remove): void {
if (is_link($path) || !is_dir($path)) {
unlink($path);
return;
}
foreach (scandir($path) as $name) {
if ($name !== '.' && $name !== '..') {
$remove($path . '/' . $name);
}
}
rmdir($path);
};
$remove($this->base);
}
public function testCanonicalSourcesAndExistingAndNewDestinations(): void
{
$safety = new PathSafety($this->root);
self::assertSame(realpath($this->base . '/source.txt'), $safety->ensureSourceSafe($this->base . '/source.txt'));
self::assertSame(realpath($this->root), $safety->ensureSourceSafe($this->root));
self::assertSame(realpath($this->root), $safety->ensureDestSafe($this->root));
self::assertSame(realpath($this->root . '/existing.txt'), $safety->ensureDestSafe('existing.txt'));
self::assertSame(
realpath($this->root . '/existing.txt'),
$safety->ensureDestSafe($this->root . '/existing.txt')
);
$expected = realpath($this->root) . DIRECTORY_SEPARATOR . 'new' . DIRECTORY_SEPARATOR . 'model.stl';
self::assertSame($expected, $safety->ensureDestSafe('new/model.stl'));
self::assertSame($expected, $safety->ensureDestSafe($this->root . '/new/model.stl'));
self::assertDirectoryDoesNotExist($this->root . '/new');
self::assertSame('models/model.stl', $safety->ensureRelative('models/model.stl'));
}
public function testSourceAndConstructorBackslashPolicy(): void
{
$safety = new PathSafety($this->root);
$path = $this->base . '\\library';
if (PHP_OS_FAMILY === 'Windows') {
self::assertSame(realpath($this->root), $safety->ensureSourceSafe($path));
self::assertSame(realpath($this->root), (new PathSafety($path))->ensureSourceSafe($path));
} else {
$this->rejects(fn () => $safety->ensureSourceSafe($path), 'INVALID_PATH');
$this->rejects(fn () => new PathSafety($path), 'INVALID_PATH');
}
}
public static function unsafeRelativePaths(): iterable
{
foreach (['/tmp/a', '\\rooted', 'C:/file', 'C:relative', '\\\\server\\share', '//server/share'] as $path) {
yield [$path, 'ABSOLUTE_PATH'];
}
foreach (['../file', 'a/../file', 'a\\..\\file', '..'] as $path) {
yield [$path, 'PATH_TRAVERSAL'];
}
yield ["a\0b", 'INVALID_PATH'];
yield ['', 'INVALID_PATH'];
yield ['php://memory', 'INVALID_PATH'];
}
#[DataProvider('unsafeRelativePaths')]
public function testUnsafeRelativePaths(string $path, string $reason): void
{
$this->rejects(fn () => (new PathSafety($this->root))->ensureRelative($path), $reason);
}
public function testMissingTraversalEscapeAndNonDirectoryAncestor(): void
{
$safety = new PathSafety($this->root);
$this->rejects(fn () => $safety->ensureSourceSafe($this->base . '/absent'), 'PATH_NOT_FOUND');
foreach (['ensureSourceSafe', 'ensureDestSafe'] as $method) {
$this->rejects(fn () => $safety->$method($this->root . '/../source.txt'), 'PATH_TRAVERSAL');
$reason = $method === 'ensureSourceSafe' && PHP_OS_FAMILY !== 'Windows'
? 'INVALID_PATH'
: 'PATH_TRAVERSAL';
$this->rejects(fn () => $safety->$method($this->root . '/a\\..\\source.txt'), $reason);
$this->rejects(fn () => $safety->$method("bad\0path"), 'INVALID_PATH');
}
foreach ([$this->base . '/source.txt', $this->base . '/library-sibling/new/file'] as $path) {
$this->rejects(fn () => $safety->ensureDestSafe($path), 'OUTSIDE_LIBRARY_ROOT');
}
$this->rejects(fn () => $safety->ensureDestSafe('existing.txt/child'), 'NOT_DIRECTORY');
$this->rejects(fn () => new PathSafety($this->base . '/absent'), 'PATH_NOT_FOUND');
$this->rejects(fn () => new PathSafety($this->base . '/source.txt'), 'NOT_DIRECTORY');
}
public static function driveRelativeOperations(): iterable
{
yield 'source uppercase drive' => [false, false];
yield 'source lowercase drive' => [false, true];
yield 'root uppercase drive' => [true, false];
yield 'root lowercase drive' => [true, true];
}
#[DataProvider('driveRelativeOperations')]
public function testExistingDriveRelativePathsRejected(bool $constructor, bool $lowercase): void
{
if (PHP_OS_FAMILY !== 'Windows') {
self::markTestSkipped('Drive-relative resolution requires Windows.');
}
$safety = new PathSafety($this->root);
$drive = substr($this->base, 0, 2);
$drive = $lowercase ? strtolower($drive) : strtoupper($drive);
$name = $constructor ? 'library' : 'source.txt';
$relativeBase = substr($this->base, 3);
$path = $drive . $relativeBase . '/' . $name;
self::assertSame(realpath($this->base . '/' . $name), realpath($path));
$this->rejects(
fn () => $constructor ? new PathSafety($path) : $safety->ensureSourceSafe($path),
'INVALID_PATH'
);
}
public function testEveryDriveLetterRejectedForSourcesAndRoots(): void
{
$safety = new PathSafety($this->root);
foreach (array_merge(range('A', 'Z'), range('a', 'z')) as $drive) {
foreach (['', 'file', './file', '.\\file'] as $suffix) {
$path = $drive . ':' . $suffix;
$this->rejects(fn () => $safety->ensureSourceSafe($path), 'INVALID_PATH');
$this->rejects(fn () => new PathSafety($path), 'INVALID_PATH');
}
}
}
public function testWindowsAmbiguousNamesCannotBypassLexicalChecks(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
self::markTestSkipped('Windows path aliases do not apply on this platform.');
}
$safety = new PathSafety($this->root);
foreach (['.. /source.txt', 'existing.txt:stream', 'new./child', 'new /child'] as $path) {
foreach (['ensureSourceSafe', 'ensureDestSafe', 'ensureRelative'] as $method) {
$this->rejects(fn () => $safety->$method($path), 'INVALID_PATH');
}
}
}
public static function links(): iterable
{
yield 'file' => ['source.txt', 'link', ''];
yield 'directory ancestor' => ['library', 'link', '/existing.txt'];
yield 'missing child of ancestor' => ['library', 'link', '/new/child'];
yield 'dangling' => ['absent', 'link', ''];
}
#[DataProvider('links')]
public function testLinksRejectedWithoutFollowingThem(string $target, string $name, string $suffix): void
{
$link = $this->root . '/' . $name;
if (!function_exists('symlink') || !@symlink($this->base . '/' . $target, $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$safety = new PathSafety($this->root);
foreach (['ensureSourceSafe', 'ensureDestSafe'] as $method) {
$this->rejects(fn () => $safety->$method($link . $suffix), 'SYMLINK_ESCAPE');
}
$this->rejects(fn () => new PathSafety($link . $suffix), 'SYMLINK_ESCAPE');
}
private function rejects(callable $operation, string $reason): void
{
try {
$operation();
self::fail('Expected path rejection: ' . $reason);
} catch (PathSafetyException $exception) {
self::assertSame($reason, $exception->reason);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\SpaceChecker;
use PHPUnit\Framework\TestCase;
final class SpaceCheckerTest extends TestCase
{
public function testStructuredException(): void
{
self::assertTrue(class_exists(ImportException::class));
$previous = new \RuntimeException('underlying');
$error = new ImportException('DISK_FULL', 'Need more space', $previous);
self::assertSame('DISK_FULL', $error->reason);
self::assertSame('Need more space', $error->getMessage());
self::assertSame($previous, $error->getPrevious());
self::assertSame('DISK_FULL', (new ImportException('DISK_FULL'))->getMessage());
}
public function testDefaultAndCustomMargins(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
(new SpaceChecker(freeSpace: fn ($path) => 105.0))->check(__DIR__, 100);
(new SpaceChecker(0.2, fn ($path) => 120.0))->check(__DIR__, 100);
(new SpaceChecker(0.0, fn ($path) => 100.0))->check(__DIR__, 100);
(new SpaceChecker())->check(__DIR__, 0);
self::assertTrue(true);
}
public function testInsufficientUnknownAndThrownSpaceFailures(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
foreach ([104.0, false, -1.0, NAN, INF, 'throws'] as $free) {
try {
(new SpaceChecker(freeSpace: static function () use ($free) {
if ($free === 'throws') {
throw new \RuntimeException('disk query');
}
return $free;
}))->check(__DIR__, 100);
self::fail('Expected space error');
} catch (ImportException $error) {
self::assertSame($free === 104.0 ? 'DISK_FULL' : 'SPACE_CHECK_FAILED', $error->reason);
}
}
}
public function testInvalidMargins(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
foreach ([-0.01, INF, NAN] as $margin) {
try {
new SpaceChecker($margin);
self::fail('Expected invalid margin');
} catch (\InvalidArgumentException) {
self::assertTrue(true);
}
}
}
public function testNegativeSize(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
$this->expectException(\InvalidArgumentException::class);
(new SpaceChecker())->check(__DIR__, -1);
}
public function testMissingDirectoryFailsClosed(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
$this->expectException(ImportException::class);
(new SpaceChecker())->check(__DIR__ . '/missing-space-directory', 1);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\StlValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class StlValidatorTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/stl-' . bin2hex(random_bytes(8));
mkdir($this->root);
self::assertTrue(class_exists(StlValidator::class), 'StlValidator must exist');
}
protected function tearDown(): void
{
foreach (glob($this->root . '/*') as $path) {
unlink($path);
}
rmdir($this->root);
}
private static function facet(): string
{
return "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
}
private static function ascii(): string
{
return "solid mesh\n" . self::facet() . "endsolid mesh\n";
}
private static function binary(int $count = 1, string $header = 'binary'): string
{
return str_pad($header, 80, "\0") . pack('V', $count) . str_repeat(pack('g*', ...array_fill(0, 12, 0.0))
. pack('v', 0), $count);
}
private function fixture(string $contents): string
{
$path = $this->root . '/mesh.stl';
file_put_contents($path, $contents);
return $path;
}
#[DataProvider('validMeshes')]
public function testAcceptsValidMeshes(string $contents): void
{
(new StlValidator(new PathSafety($this->root)))->validate($this->fixture($contents));
self::assertTrue(true);
}
public static function validMeshes(): iterable
{
yield 'binary' => [self::binary()];
yield 'solid binary header' => [self::binary(2, 'solid mesh')];
yield 'ASCII' => [self::ascii()];
yield 'multiple facets' => ["solid\n" . self::facet() . self::facet() . 'endsolid'];
yield 'CRLF blank lines tabs' => [str_replace("\n", "\r\n\r\n", str_replace(' ', "\t", self::ascii()))];
yield 'finite decimals and exponents' => [str_replace('vertex 1 0 0', 'vertex +1. -.5 2e-3', self::ascii())];
yield 'trailing whitespace' => [self::ascii() . " \t\n"];
yield 'attribute bytes are opaque' => [substr(self::binary(), 0, -2) . "\xff\xff"];
}
#[DataProvider('invalidMeshes')]
public function testRejectsMalformedMeshes(string $contents): void
{
try {
(new StlValidator(new PathSafety($this->root)))->validate($this->fixture($contents));
self::fail('Malformed STL accepted');
} catch (ImportException $error) {
self::assertSame('STL_INVALID', $error->reason);
}
}
public static function invalidMeshes(): iterable
{
yield 'zero bytes' => [''];
yield 'empty binary' => [self::binary(0)];
yield 'empty ASCII' => ["solid empty\nendsolid empty\n"];
yield 'truncated binary header' => [str_repeat("\0", 83)];
yield 'truncated binary record' => [substr(self::binary(), 0, -1)];
yield 'binary trailing byte' => [self::binary() . 'x'];
yield 'huge triangle count' => [str_repeat("\0", 80) . pack('V', 0xffffffff)];
yield 'solid binary truncated' => [substr(self::binary(1, 'solid'), 0, -1)];
yield 'binary infinity' => [substr_replace(self::binary(), pack('V', 0x7f800000), 84, 4)];
yield 'binary NaN' => [substr_replace(self::binary(), pack('V', 0x7fc00000), 84, 4)];
yield 'missing solid' => [self::facet() . "endsolid\n"];
yield 'missing endsolid' => ["solid mesh\n" . self::facet()];
yield 'missing endfacet' => [str_replace("endfacet\n", '', self::ascii())];
yield 'missing endloop' => [str_replace("endloop\n", '', self::ascii())];
yield 'missing outer loop' => [str_replace("outer loop\n", '', self::ascii())];
yield 'two vertices' => [str_replace("vertex 1 0 0\n", '', self::ascii())];
yield 'four vertices' => [str_replace('endloop', "vertex 0 0 0\nendloop", self::ascii())];
yield 'nested facet' => [str_replace('outer loop', 'facet normal 0 0 0', self::ascii())];
yield 'trailing garbage' => [self::ascii() . 'garbage'];
yield 'second solid' => [self::ascii() . self::ascii()];
yield 'malformed keyword' => [str_replace('endloop', 'endloop garbage', self::ascii())];
yield 'short normal' => [str_replace('normal 0 0 1', 'normal 0 0', self::ascii())];
yield 'extra coordinate' => [str_replace('vertex 1 0 0', 'vertex 1 0 0 0', self::ascii())];
yield 'embedded NUL' => [str_replace('mesh', "me\0sh", self::ascii())];
yield 'overlong line' => ["solid " . str_repeat('x', 4096) . "\n" . self::facet() . 'endsolid'];
foreach (['NaN', 'INF', '-inf', '1e9999', '0x10', '1,2', '.', '1e', '1e+', '--1', '1foo'] as $number) {
yield 'bad number ' . $number => [str_replace('vertex 1 0 0', 'vertex ' . $number . ' 0 0', self::ascii())];
}
}
public function testPathSafetyRejectsTraversal(): void
{
$this->fixture(self::ascii());
$this->expectException(PathSafetyException::class);
$path = $this->root . '/../' . basename($this->root) . '/mesh.stl';
(new StlValidator(new PathSafety($this->root)))->validate($path);
}
public function testThrownReadFailureIsStructuredAndStreamClosed(): void
{
$streamSeen = null;
$cause = new \RuntimeException('injected read failure');
$reader = static function ($stream, int $length) use (&$streamSeen, $cause): string {
$streamSeen = $stream;
throw $cause;
};
try {
(new StlValidator(new PathSafety($this->root), $reader))->validate($this->fixture(self::binary()));
self::fail('Read failure accepted');
} catch (\Throwable $error) {
self::assertSame(ImportException::class, $error::class);
self::assertSame('SCAN_READ_ERROR', $error->reason);
self::assertSame($cause, $error->getPrevious());
} finally {
self::assertFalse(is_resource($streamSeen));
}
}
#[DataProvider('readFailures')]
public function testFalseAndStalledReadsAreRejected(bool $stall, bool $ascii): void
{
$calls = 0;
$streamSeen = null;
$reader = static function ($stream, int $length) use (&$calls, &$streamSeen, $stall): string|false {
$streamSeen = $stream;
return ++$calls === 2 ? ($stall ? '' : false) : fread($stream, $length);
};
try {
$path = $this->fixture($ascii ? self::ascii() : self::binary());
(new StlValidator(new PathSafety($this->root), $reader))->validate($path);
self::fail('Read failure accepted');
} catch (ImportException $error) {
self::assertSame('SCAN_READ_ERROR', $error->reason);
} finally {
self::assertFalse(is_resource($streamSeen));
}
}
public static function readFailures(): iterable
{
yield [false, false];
yield [true, false];
yield [false, true];
yield [true, true];
}
public function testShortReadsAndLargeAsciiAreStreamedWithBoundedRequests(): void
{
$path = $this->root . '/mesh.stl';
$out = fopen($path, 'wb');
fwrite($out, "solid\n");
for ($i = 0; $i < 20000; ++$i) {
fwrite($out, self::facet());
}
fwrite($out, "endsolid\n");
fclose($out);
$max = 0;
$reader = static function ($stream, int $length) use (&$max): string|false {
$max = max($max, $length);
return fread($stream, min(37, $length));
};
(new StlValidator(new PathSafety($this->root), $reader))->validate($path);
self::assertLessThanOrEqual(4096, $max);
}
public function testDirectoryIsReadFailure(): void
{
$this->expectException(ImportException::class);
$this->expectExceptionMessage('SCAN_READ_ERROR');
(new StlValidator(new PathSafety($this->root)))->validate($this->root);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\StreamingHasher;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class StreamingHasherTest extends TestCase
{
private string $root;
private string $path;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/meshvault-hash-' . bin2hex(random_bytes(8));
mkdir($this->root);
$this->path = $this->root . '/source';
file_put_contents($this->path, str_repeat('abc', 100000));
self::assertTrue(class_exists(StreamingHasher::class), 'StreamingHasher must exist');
}
protected function tearDown(): void
{
foreach (scandir($this->root) as $name) {
if ($name !== '.' && $name !== '..') {
unlink($this->root . '/' . $name);
}
}
rmdir($this->root);
}
private function hasher(?callable $observer = null, array $hooks = []): StreamingHasher
{
return new StreamingHasher(new PathSafety($this->root), $observer, $hooks);
}
public static function sizes(): iterable
{
foreach ([0, 1, 131072, 131073, 524305] as $size) {
yield (string) $size => [$size];
}
}
#[DataProvider('sizes')]
public function testDigestAndBoundedReads(int $size): void
{
file_put_contents($this->path, str_repeat('x', $size));
$observed = 0;
$hasher = $this->hasher(function (int $bytes) use (&$observed): void {
self::assertGreaterThan($observed, $bytes);
self::assertLessThanOrEqual(131072, $bytes - $observed);
$observed = $bytes;
}, ['read' => static function ($stream, int $length): string|false {
self::assertSame(131072, $length);
return fread($stream, $length);
}]);
self::assertSame(hash_file('sha256', $this->path), $hasher->hash($this->path));
self::assertSame($size, $observed);
}
public static function failures(): iterable
{
yield 'open' => ['open', false, 'HASH_OPEN_FAILED'];
yield 'read' => ['read', false, 'HASH_READ_FAILED'];
yield 'stalled read' => ['read', '', 'HASH_READ_FAILED'];
yield 'path stat' => ['stat', false, 'HASH_STAT_FAILED'];
yield 'handle stat' => ['fstat', false, 'HASH_STAT_FAILED'];
}
#[DataProvider('failures')]
public function testIoFailures(string $hook, mixed $result, string $reason): void
{
$this->rejects(fn () => $this->hasher(null, [$hook => fn () => $result])->hash($this->path), $reason);
}
public function testThrownIoFailurePreservesCauseAndClosesStream(): void
{
$stream = null;
$cause = new RuntimeException('injected read failure');
$hasher = $this->hasher(null, [
'open' => function (string $path) use (&$stream) {
return $stream = fopen($path, 'rb');
},
'read' => static fn () => throw $cause,
]);
$error = $this->rejects(fn () => $hasher->hash($this->path), 'HASH_READ_FAILED');
self::assertSame($cause, $error->getPrevious());
self::assertFalse(is_resource($stream));
}
public static function metadataChanges(): iterable
{
foreach (['stat', 'fstat'] as $hook) {
foreach (['dev', 'ino', 'mode', 'size', 'mtime', 'ctime'] as $field) {
yield "$hook $field" => [$hook, $field];
}
}
}
#[DataProvider('metadataChanges')]
public function testEndMetadataChanges(string $hook, string $field): void
{
$calls = 0;
$hasher = $this->hasher(null, [$hook => static function ($target) use ($hook, $field, &$calls) {
$stat = $hook($target);
if (++$calls === 2) {
++$stat[$field];
}
return $stat;
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testOpenHandleMustMatchPreOpenPath(): void
{
$hasher = $this->hasher(null, ['fstat' => static function ($stream) {
$stat = fstat($stream);
++$stat['ino'];
return $stat;
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testDirectoryRejected(): void
{
$this->rejects(fn () => $this->hasher()->hash($this->root), 'HASH_NOT_REGULAR_FILE');
}
public static function mutations(): iterable
{
yield 'append' => ['append'];
yield 'truncate' => ['truncate'];
yield 'same size with changed timestamp' => ['rewrite'];
yield 'replacement' => ['replace'];
}
#[DataProvider('mutations')]
public function testRealMutation(string $mutation): void
{
$done = false;
$hasher = $this->hasher(function () use ($mutation, &$done): void {
if ($done) {
return;
}
$done = true;
if ($mutation === 'append') {
file_put_contents($this->path, 'extra', FILE_APPEND);
} elseif ($mutation === 'truncate') {
file_put_contents($this->path, 'short');
} elseif ($mutation === 'rewrite') {
file_put_contents($this->path, str_repeat('z', 300000));
touch($this->path, time() + 10);
} else {
if (!@rename($this->path, $this->root . '/old')) {
self::markTestSkipped('Host cannot rename an open file.');
}
file_put_contents($this->path, str_repeat('z', 300000));
touch($this->path, time() + 10);
}
});
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testUnsafeAndMissingSources(): void
{
foreach (
[
'' => 'INVALID_PATH',
'php://memory' => 'INVALID_PATH',
$this->root . '/../source' => 'PATH_TRAVERSAL',
$this->root . '/missing' => 'PATH_NOT_FOUND',
] as $path => $reason
) {
$this->rejects(fn () => $this->hasher()->hash($path), $reason);
}
}
public static function links(): iterable
{
yield 'file' => [false, false];
yield 'ancestor' => [true, false];
yield 'mid-read replacement' => [false, true];
}
#[DataProvider('links')]
public function testSymlinks(bool $ancestor, bool $duringRead): void
{
$link = $this->root . '/link';
if (!@symlink($ancestor ? $this->root : $this->path, $link)) {
self::markTestSkipped('Host cannot create symlinks.');
}
if (!$duringRead) {
$this->rejects(
fn () => $this->hasher()->hash($link . ($ancestor ? '/source' : '')),
'SYMLINK_ESCAPE'
);
return;
}
unlink($link);
file_put_contents($this->root . '/target', 'replacement');
$done = false;
$hasher = $this->hasher(function () use (&$done): void {
if (!$done) {
$done = true;
if (!@rename($this->path, $this->root . '/old')) {
self::markTestSkipped('Host cannot rename an open file.');
}
self::assertTrue(symlink($this->root . '/target', $this->path));
}
});
$this->rejects(fn () => $hasher->hash($this->path), 'SYMLINK_ESCAPE');
}
public function testEndStatFailure(): void
{
foreach (['stat', 'fstat'] as $operation) {
$calls = 0;
$hasher = $this->hasher(null, [$operation => static function ($target) use ($operation, &$calls) {
return ++$calls === 2 ? false : $operation($target);
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_STAT_FAILED');
}
}
public function testObserverFailureClosesStream(): void
{
$stream = null;
$cause = new RuntimeException('observer');
$hasher = $this->hasher(static fn () => throw $cause, [
'open' => function (string $path) use (&$stream) {
return $stream = fopen($path, 'rb');
},
]);
try {
$hasher->hash($this->path);
self::fail('Expected observer exception');
} catch (RuntimeException $error) {
self::assertSame($cause, $error);
}
self::assertFalse(is_resource($stream));
}
private function rejects(callable $operation, string $reason): ImportException
{
try {
$operation();
self::fail('Expected ' . $reason);
} catch (ImportException $error) {
self::assertSame($reason, $error->reason);
self::assertNotSame('', $error->getMessage());
return $error;
}
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\VerifiedCopier;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class VerifiedCopierTest extends TestCase
{
private string $base;
private string $root;
private string $source;
private string $data;
protected function setUp(): void
{
$this->base = dirname(__DIR__, 3) . '/.phpunit.cache/copy-' . bin2hex(random_bytes(6));
mkdir($this->base, 0777, true);
$this->base = realpath($this->base);
$this->root = $this->base . '/library';
mkdir($this->root);
$this->source = $this->base . '/source';
$this->data = str_repeat("mesh\0\xff", 50000);
file_put_contents($this->source, $this->data);
self::assertTrue(class_exists(VerifiedCopier::class), 'VerifiedCopier must exist');
}
protected function tearDown(): void
{
$remove = static function (string $path) use (&$remove): void {
if (is_link($path) || !is_dir($path)) {
unlink($path);
return;
}
foreach (scandir($path) as $name) {
if ($name !== '.' && $name !== '..') {
$remove($path . '/' . $name);
}
}
rmdir($path);
};
$remove($this->base);
}
private function copier(array $operations = []): VerifiedCopier
{
return new VerifiedCopier(new PathSafety($this->root), $operations);
}
private function copy(array $operations = [], string $destination = 'model.stl'): string
{
return $this->copier($operations)->copy(
$this->source,
$destination,
strlen($this->data),
hash('sha256', $this->data)
);
}
private function entries(): array
{
return array_values(array_diff(scandir($this->root), ['.', '..']));
}
public function testStreamsPartialWritesAndPublishesVerifiedSameDirectoryHardlink(): void
{
$reads = [];
$links = 0;
$result = $this->copy([
'read' => static function ($stream, int $length) use (&$reads) {
$reads[] = $length;
return fread($stream, $length);
},
'write' => static fn ($stream, string $bytes) => fwrite($stream, substr($bytes, 0, 777)),
'link' => function (string $temp, string $destination) use (&$links): bool {
++$links;
self::assertSame(dirname($destination), dirname($temp));
self::assertFileDoesNotExist($destination);
self::assertSame(hash('sha256', $this->data), hash_file('sha256', $temp));
self::assertSame(strlen($this->data), filesize($temp));
return link($temp, $destination);
},
]);
self::assertSame(realpath($this->root . '/model.stl'), $result);
self::assertSame($this->data, file_get_contents($result));
self::assertSame($this->data, file_get_contents($this->source));
self::assertSame(['model.stl'], $this->entries());
self::assertSame([131072], array_values(array_unique($reads)));
self::assertSame(1, $links);
}
public function testEmptyFile(): void
{
$this->data = '';
file_put_contents($this->source, '');
self::assertSame('', file_get_contents($this->copy()));
self::assertSame(['model.stl'], $this->entries());
}
public static function failures(): iterable
{
yield 'read false' => ['read', static fn () => false];
yield 'read stalls' => ['read', static fn () => ''];
yield 'write false' => ['write', static fn () => false];
yield 'write zero' => ['write', static fn () => 0];
yield 'flush' => ['flush', static fn () => false];
yield 'link' => ['link', static fn () => false];
yield 'exception' => ['write', static function () {
throw new \RuntimeException('injected');
}];
}
#[DataProvider('failures')]
public function testFailuresCleanTemporaryFiles(string $operation, callable $failure): void
{
try {
$this->copy([$operation => $failure]);
self::fail('Expected COPY_FAILED');
} catch (ImportException $error) {
self::assertSame('COPY_FAILED', $error->reason);
}
self::assertSame([], $this->entries());
self::assertSame($this->data, file_get_contents($this->source));
}
public function testFailureAfterPartialProgressCleansUp(): void
{
$calls = 0;
$this->testFailuresCleanTemporaryFiles('write', static function ($stream, $bytes) use (&$calls) {
return ++$calls === 1 ? fwrite($stream, substr($bytes, 0, 10)) : false;
});
}
public function testSizeHashAndWrittenBytesMustMatch(): void
{
foreach (['size', 'hash', 'corrupt'] as $case) {
try {
$operations = $case === 'corrupt'
? ['write' => static fn ($stream, $bytes) => fwrite($stream, str_repeat('x', strlen($bytes)))]
: [];
$this->copier($operations)->copy(
$this->source,
'model.stl',
strlen($this->data) + ($case === 'size' ? 1 : 0),
$case === 'hash' ? str_repeat('0', 64) : hash('sha256', $this->data)
);
self::fail('Expected mismatch');
} catch (ImportException $error) {
self::assertSame('HASH_MISMATCH', $error->reason);
}
self::assertSame([], $this->entries());
}
}
public function testCollisionSuffixAndConcurrentWinnerNeverOverwrite(): void
{
file_put_contents($this->root . '/model.stl', 'original');
file_put_contents($this->root . '/model_001.stl', 'first');
$calls = 0;
$result = $this->copy(['link' => static function ($temp, $destination) use (&$calls) {
if (++$calls === 1) {
file_put_contents($destination, 'racer');
return false;
}
return link($temp, $destination);
}]);
self::assertSame('model_003.stl', basename($result));
self::assertSame('original', file_get_contents($this->root . '/model.stl'));
self::assertSame('first', file_get_contents($this->root . '/model_001.stl'));
self::assertSame('racer', file_get_contents($this->root . '/model_002.stl'));
self::assertCount(4, $this->entries());
}
public function testExtensionlessCollisionAndExhaustion(): void
{
file_put_contents($this->root . '/model', 'original');
self::assertSame('model_001', basename($this->copy([], 'model')));
for ($i = 0; $i <= 999; ++$i) {
file_put_contents($this->root . '/' . ($i === 0 ? 'a.stl' : sprintf('a_%03d.stl', $i)), 'x');
}
try {
$this->copy([], 'a.stl');
self::fail('Expected collision exhaustion');
} catch (ImportException $error) {
self::assertSame('COLLISION', $error->reason);
}
self::assertCount(1002, $this->entries());
}
public function testCleanupNeverThrowsPreservesOriginalError(): void
{
// Test that if cleanup (unlink temporary) fails in the finally block,
// the original exception is preserved, not masked by the cleanup exception.
//
// Scenario: verify() throws HASH_MISMATCH, then finally block tries to unlink
// the temporary file but unlink fails (e.g., permission issue). The original
// HASH_MISMATCH should be thrown, not a COPY_FAILED from the cleanup.
$copier = new VerifiedCopier(new PathSafety($this->root), [
'read' => static fn ($stream, int $length) => fread($stream, $length),
'write' => static fn ($stream, string $bytes) => fwrite($stream, $bytes),
'flush' => static fn ($stream) => fflush($stream),
'link' => static function (): bool {
// Link succeeds
return link(...func_get_args());
},
]);
try {
// Wrong size -> HASH_MISMATCH in verify()
$copier->copy($this->source, 'model.stl', strlen($this->data) + 1, hash('sha256', $this->data));
self::fail('Expected HASH_MISMATCH');
} catch (ImportException $error) {
// The ORIGINAL error should be HASH_MISMATCH, not COPY_FAILED from cleanup
self::assertSame('HASH_MISMATCH', $error->reason, 'Original error masked by cleanup failure');
self::assertStringContainsString('differs from expected metadata', $error->getMessage());
}
}
public function testCleanupFailureInFinallyDoesNotMaskOriginalError(): void
{
// More direct test: when an exception is thrown in the try block AND
// the finally block's unlink fails, the original exception should propagate.
// We simulate this by making the temporary file read-only (chmod) so unlink
// fails on some systems, or by verifying the code path directly.
$copier = new VerifiedCopier(new PathSafety($this->root), [
'read' => static fn ($stream, int $length) => fread($stream, $length),
'write' => static fn ($stream, string $bytes) => fwrite($stream, $bytes),
'flush' => static fn ($stream) => fflush($stream),
'link' => static function (): bool {
return link(...func_get_args());
},
]);
// Trigger a HASH_MISMATCH error
try {
$copier->copy($this->source, 'model.stl', strlen($this->data) + 1, hash('sha256', $this->data));
self::fail('Expected HASH_MISMATCH');
} catch (ImportException $error) {
// The ORIGINAL error should be HASH_MISMATCH, not COPY_FAILED from cleanup
self::assertSame('HASH_MISMATCH', $error->reason);
}
// Verify the temporary file was cleaned up (cleanup ran but didn't throw)
self::assertSame([], $this->entries(), 'Temporary file should be cleaned up');
}
public function testUnsafePathsRejectedBeforeCopy(): void
{
foreach (['../escape', $this->base . '/outside', 'missing/model.stl'] as $destination) {
try {
$this->copy([], $destination);
self::fail('Expected unsafe destination rejection');
} catch (PathSafetyException | ImportException $error) {
self::assertNotSame('', $error->reason);
}
self::assertSame([], $this->entries());
}
foreach ([$this->base . '/../source', $this->base . '/missing', $this->root] as $source) {
try {
$this->copier()->copy($source, 'model.stl', 0, hash('sha256', ''));
self::fail('Expected unsafe source rejection');
} catch (PathSafetyException | ImportException $error) {
self::assertNotSame('', $error->reason);
}
self::assertSame([], $this->entries());
}
}
public function testSymlinkSourcesDestinationsAndAncestorsRejected(): void
{
$link = $this->root . '/link';
if (!@symlink($this->source, $link)) {
self::markTestSkipped('Host cannot create symlinks.');
}
foreach ([[$link, 'new'], [$this->source, 'link']] as [$source, $destination]) {
try {
$this->copier()->copy($source, $destination, strlen($this->data), hash('sha256', $this->data));
self::fail('Expected symlink rejection');
} catch (PathSafetyException $error) {
self::assertSame('SYMLINK_ESCAPE', $error->reason);
}
}
unlink($link);
self::assertTrue(symlink($this->base, $link));
try {
$this->copy([], 'link/new');
self::fail('Expected ancestor rejection');
} catch (PathSafetyException $error) {
self::assertSame('SYMLINK_ESCAPE', $error->reason);
}
self::assertSame(['link'], $this->entries());
}
}