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