From 17db4065c0ef6a14b89d2e2b671861437de3f967 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 28 Aug 2026 23:30:45 -0400 Subject: [PATCH] feat: add local system store provider Signed-off-by: Sebastian Krupinski --- lib/Module.php | 5 +- lib/Providers/SystemStore/Provider.php | 76 +++++ lib/Providers/SystemStore/Service.php | 354 ++++++++++++++++++++++ tests/php/Unit/SystemStoreServiceTest.php | 184 +++++++++++ 4 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 lib/Providers/SystemStore/Provider.php create mode 100644 lib/Providers/SystemStore/Service.php create mode 100644 tests/php/Unit/SystemStoreServiceTest.php diff --git a/lib/Module.php b/lib/Module.php index fb5c071..8d563f0 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -11,7 +11,8 @@ use KTXF\Module\ModuleBrowserInterface; use KTXF\Module\ModuleInstanceAbstract; use KTXF\Resource\Provider\ProviderInterface; use KTXM\ProviderLocalDocuments\Listeners\UserEventListener; -use KTXM\ProviderLocalDocuments\Providers\Provider; +use KTXM\ProviderLocalDocuments\Providers\Provider as UserStoreProvider; +use KTXM\ProviderLocalDocuments\Providers\SystemStore\Provider as SystemStoreProvider; class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface { @@ -69,7 +70,7 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface $this->events->listen($this->handle(), UserCreatedEvent::class, UserEventListener::class, 'onUserCreated', DeliveryMode::Deferred); $this->events->listen($this->handle(), UserDeletingEvent::class, UserEventListener::class, 'onUserDeleting', DeliveryMode::Deferred); // Register providers - $this->providerManager->register(ProviderInterface::TYPE_DOCUMENT, 'default', Provider::class); + $this->providerManager->register(ProviderInterface::TYPE_SYSTEM_STORE, 'local', SystemStoreProvider::class); } public function registerBI(): array { diff --git a/lib/Providers/SystemStore/Provider.php b/lib/Providers/SystemStore/Provider.php new file mode 100644 index 0000000..32f2d8e --- /dev/null +++ b/lib/Providers/SystemStore/Provider.php @@ -0,0 +1,76 @@ + true, + self::CAPABILITY_SERVICE_FETCH => true, + self::CAPABILITY_SERVICE_EXTANT => true, + ]; + + public function __construct(#[Inject('rootDir')] private readonly string $rootDir) + { + } + + public function jsonSerialize(): array + { + return [ + self::PROPERTY_TYPE => self::JSON_TYPE, + self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER, + self::PROPERTY_LABEL => $this->label(), + self::PROPERTY_CAPABILITIES => $this->providerAbilities, + ]; + } + + public function jsonDeserialize(array|string $data): static { return $this; } + public function type(): string { return self::TYPE_SYSTEM_STORE; } + public function identifier(): string { return self::PROVIDER_IDENTIFIER; } + public function label(): string { return 'Local System Store'; } + public function description(): string { return 'Stores tenant-owned internal blobs on local disk'; } + public function icon(): string { return 'hard-drive'; } + public function capable(string $value): bool { return !empty($this->providerAbilities[$value]); } + public function capabilities(): array { return $this->providerAbilities; } + + public function serviceList(string $tenantId, string $userId, array $filter): array + { + if ($userId !== SystemIdentity::USER || ($filter !== [] && !in_array(self::SERVICE_IDENTIFIER, $filter, true))) { + return []; + } + return [self::SERVICE_IDENTIFIER => $this->service($tenantId)]; + } + + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array + { + $result = []; + foreach ($identifiers as $identifier) { + $result[$identifier] = $userId === SystemIdentity::USER && $identifier === self::SERVICE_IDENTIFIER; + } + return $result; + } + + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?ResourceServiceBaseInterface + { + if ($userId !== SystemIdentity::USER || $identifier !== self::SERVICE_IDENTIFIER) { + return null; + } + return $this->service($tenantId); + } + + private function service(string $tenantId): Service + { + return new Service($this->rootDir . '/storage/system-store/' . hash('sha256', $tenantId) . '/default'); + } +} diff --git a/lib/Providers/SystemStore/Service.php b/lib/Providers/SystemStore/Service.php new file mode 100644 index 0000000..cd1abe9 --- /dev/null +++ b/lib/Providers/SystemStore/Service.php @@ -0,0 +1,354 @@ + self::JSON_TYPE, + self::PROPERTY_PROVIDER => 'local', + self::PROPERTY_IDENTIFIER => 'default', + self::PROPERTY_LABEL => 'Local System Store', + self::PROPERTY_ENABLED => true, + self::PROPERTY_CAPABILITIES => [], + self::PROPERTY_AUXILIARY => [], + ]; + } + + public function jsonDeserialize(array|string $data): static { return $this; } + public function capable(string $value): bool { return false; } + public function capabilities(): array { return []; } + public function provider(): string { return 'local'; } + public function identifier(): string { return 'default'; } + public function getLabel(): string { return 'Local System Store'; } + public function getEnabled(): bool { return true; } + public function getLocation(): null { return null; } + public function getIdentity(): null { return null; } + public function getAuxiliary(): array { return []; } + + public function stat(string $key): ?BlobInfo + { + $key = $this->validateKey($key); + $lock = $this->lock($key, LOCK_SH); + try { + return $this->statUnlocked($key); + } finally { + $this->unlock($lock); + } + } + + public function read(string $key): ?BinaryResource + { + $key = $this->validateKey($key); + $lock = $this->lock($key, LOCK_SH); + try { + $info = $this->statUnlocked($key); + if ($info === null) { + return null; + } + $handle = fopen($this->dataPath($key), 'rb'); + if ($handle === false) { + throw new SystemStoreException("Unable to open local blob '{$key}' for reading"); + } + } finally { + $this->unlock($lock); + } + + $stream = (function () use ($handle): \Generator { + try { + while (!feof($handle)) { + $chunk = fread($handle, 8192); + if ($chunk === false) { + throw new SystemStoreException('Unable to read local system-store blob'); + } + if ($chunk !== '') { + yield $chunk; + } + } + } finally { + fclose($handle); + } + })(); + return new BinaryResource(basename($key), $info->mimeType, $stream); + } + + public function write( + string $key, + BinaryResource $content, + array $metadata = [], + ?WriteCondition $condition = null, + ): BlobInfo { + $key = $this->validateKey($key); + if ($content->mimeType() === '') { + throw new \InvalidArgumentException('Local system-store blob MIME type cannot be empty'); + } + $lock = $this->lock($key, LOCK_EX); + $dataTemp = null; + $metaTemp = null; + try { + $this->assertCondition($this->statUnlocked($key), $condition); + $dataPath = $this->dataPath($key); + $metaPath = $this->metaPath($key); + $this->ensureParent($dataPath, $this->dataRoot()); + $this->ensureParent($metaPath, $this->metaRoot()); + + $dataTemp = tempnam(dirname($dataPath), '.write-'); + if ($dataTemp === false || ($handle = fopen($dataTemp, 'wb')) === false) { + throw new SystemStoreException('Unable to create a temporary local blob'); + } + $hash = hash_init('sha256'); + $size = 0; + try { + foreach ($content->stream() as $chunk) { + if (!is_string($chunk)) { + throw new SystemStoreException('BinaryResource streams must yield strings'); + } + $this->writeAll($handle, $chunk); + hash_update($hash, $chunk); + $size += strlen($chunk); + } + if (!fflush($handle) || (function_exists('fsync') && !fsync($handle))) { + throw new SystemStoreException('Unable to finalize a temporary local blob'); + } + } finally { + fclose($handle); + } + + $etag = 'sha256:' . hash_final($hash); + $metaTemp = tempnam(dirname($metaPath), '.write-'); + if ($metaTemp === false) { + throw new SystemStoreException('Unable to create temporary local blob metadata'); + } + try { + $encoded = json_encode([ + 'mimeType' => $content->mimeType(), + 'etag' => $etag, + 'attributes' => $metadata, + ], JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new SystemStoreException('Local blob metadata is not JSON serializable', previous: $error); + } + if (file_put_contents($metaTemp, $encoded, LOCK_EX) === false) { + throw new SystemStoreException('Unable to write temporary local blob metadata'); + } + if (!rename($dataTemp, $dataPath)) { + throw new SystemStoreException("Unable to publish local blob '{$key}'"); + } + $dataTemp = null; + if (!rename($metaTemp, $metaPath)) { + throw new SystemStoreException("Unable to publish metadata for local blob '{$key}'"); + } + $metaTemp = null; + clearstatcache(true, $dataPath); + $modified = filemtime($dataPath); + if ($modified === false) { + throw new SystemStoreException("Unable to inspect local blob '{$key}' after writing"); + } + return new BlobInfo( + $key, + $content->mimeType(), + $size, + $etag, + new DateTimeImmutable('@' . (string) $modified), + $metadata, + ); + } finally { + if ($dataTemp !== null && is_file($dataTemp)) { unlink($dataTemp); } + if ($metaTemp !== null && is_file($metaTemp)) { unlink($metaTemp); } + $this->unlock($lock); + } + } + + public function delete(string $key, ?WriteCondition $condition = null): bool + { + $key = $this->validateKey($key); + $lock = $this->lock($key, LOCK_EX); + try { + $current = $this->statUnlocked($key); + $this->assertCondition($current, $condition); + if ($current === null) { + return false; + } + if (!unlink($this->dataPath($key))) { + throw new SystemStoreException("Unable to delete local blob '{$key}'"); + } + $metaPath = $this->metaPath($key); + if (is_file($metaPath) && !unlink($metaPath)) { + throw new SystemStoreException("Unable to delete metadata for local blob '{$key}'"); + } + return true; + } finally { + $this->unlock($lock); + } + } + + public function list(string $prefix = ''): iterable + { + if ($prefix !== '') { + $prefix = $this->validateKey($prefix, true); + } + $root = $this->dataRoot(); + if (!is_dir($root)) { + return; + } + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + if (!$file->isFile() || $file->isLink()) { + continue; + } + $key = str_replace(DIRECTORY_SEPARATOR, '/', substr($file->getPathname(), strlen($root) + 1)); + if ($prefix !== '' && !str_starts_with($key, $prefix)) { + continue; + } + $info = $this->stat($key); + if ($info !== null) { + yield $info; + } + } + } + + private function statUnlocked(string $key): ?BlobInfo + { + $path = $this->dataPath($key); + if (!is_file($path) || is_link($path)) { + return null; + } + $this->assertExistingPathInside($path, $this->dataRoot()); + $size = filesize($path); + $modified = filemtime($path); + $hash = hash_file('sha256', $path); + if ($size === false || $modified === false || $hash === false) { + throw new SystemStoreException("Unable to inspect local blob '{$key}'"); + } + $etag = 'sha256:' . $hash; + $stored = $this->readMetadata($key); + $trusted = $stored['etag'] === $etag; + return new BlobInfo( + $key, + $trusted ? $stored['mimeType'] : 'application/octet-stream', + $size, + $etag, + new DateTimeImmutable('@' . (string) $modified), + $trusted ? $stored['attributes'] : [], + ); + } + + /** @return array{mimeType: string, etag: string, attributes: array} */ + private function readMetadata(string $key): array + { + $path = $this->metaPath($key); + if (!is_file($path) || is_link($path)) { + return ['mimeType' => 'application/octet-stream', 'etag' => '', 'attributes' => []]; + } + $decoded = json_decode((string) file_get_contents($path), true); + if (!is_array($decoded)) { + return ['mimeType' => 'application/octet-stream', 'etag' => '', 'attributes' => []]; + } + return [ + 'mimeType' => is_string($decoded['mimeType'] ?? null) ? $decoded['mimeType'] : 'application/octet-stream', + 'etag' => is_string($decoded['etag'] ?? null) ? $decoded['etag'] : '', + 'attributes' => is_array($decoded['attributes'] ?? null) ? $decoded['attributes'] : [], + ]; + } + + private function assertCondition(?BlobInfo $current, ?WriteCondition $condition): void + { + if ($condition === null || $condition->mode === WriteCondition::NONE) { return; } + if ($condition->mode === WriteCondition::IF_ABSENT && $current !== null) { + throw new WriteConflictException('The local blob already exists'); + } + if ($condition->mode === WriteCondition::IF_MATCH && ($current === null || $current->etag !== $condition->etag)) { + throw new WriteConflictException('The local blob ETag does not match'); + } + } + + /** @return resource */ + private function lock(string $key, int $operation) + { + $root = $this->root . '/locks'; + $this->ensureDirectory($root); + $this->assertExistingPathInside($root, $this->root); + $handle = fopen($root . '/' . hash('sha256', $key) . '.lock', 'c'); + if ($handle === false || !flock($handle, $operation)) { + if (is_resource($handle)) { fclose($handle); } + throw new SystemStoreException("Unable to lock local blob '{$key}'"); + } + return $handle; + } + + /** @param resource $lock */ + private function unlock($lock): void + { + flock($lock, LOCK_UN); + fclose($lock); + } + + /** @param resource $handle */ + private function writeAll($handle, string $content): void + { + for ($offset = 0, $length = strlen($content); $offset < $length; $offset += $written) { + $written = fwrite($handle, substr($content, $offset)); + if ($written === false || $written === 0) { + throw new SystemStoreException('Unable to write a temporary local blob'); + } + } + } + + private function validateKey(string $key, bool $allowTrailingSlash = false): string + { + if ($key === '' || str_starts_with($key, '/') || (!$allowTrailingSlash && str_ends_with($key, '/')) || str_contains($key, '\\') || str_contains($key, "\0")) { + throw new InvalidKeyException('Local system-store keys must be normalized relative keys'); + } + $segments = explode('/', $key); + if ($allowTrailingSlash && end($segments) === '') { array_pop($segments); } + if (in_array('', $segments, true) || in_array('.', $segments, true) || in_array('..', $segments, true)) { + throw new InvalidKeyException('Local system-store keys cannot contain empty or traversal segments'); + } + return $key; + } + + private function dataRoot(): string { return $this->root . '/data'; } + private function metaRoot(): string { return $this->root . '/meta'; } + private function dataPath(string $key): string { return $this->dataRoot() . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key); } + private function metaPath(string $key): string { return $this->metaRoot() . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key) . '.json'; } + + private function ensureParent(string $path, string $root): void + { + $this->ensureDirectory(dirname($path)); + $this->assertExistingPathInside(dirname($path), $root); + } + + private function ensureDirectory(string $path): void + { + if (!is_dir($path) && !mkdir($path, 0750, true) && !is_dir($path)) { + throw new SystemStoreException("Unable to create local system-store directory '{$path}'"); + } + } + + private function assertExistingPathInside(string $path, string $root): void + { + $resolvedPath = realpath($path); + $resolvedRoot = realpath($root); + if ($resolvedPath === false || $resolvedRoot === false || ($resolvedPath !== $resolvedRoot && !str_starts_with($resolvedPath, $resolvedRoot . DIRECTORY_SEPARATOR))) { + throw new SystemStoreException('Local system-store path escaped its configured root'); + } + } +} diff --git a/tests/php/Unit/SystemStoreServiceTest.php b/tests/php/Unit/SystemStoreServiceTest.php new file mode 100644 index 0000000..9c3ba82 --- /dev/null +++ b/tests/php/Unit/SystemStoreServiceTest.php @@ -0,0 +1,184 @@ +root = sys_get_temp_dir() . '/ktrix-local-system-store-' . bin2hex(random_bytes(8)); + $this->service = new Service($this->root . '/service'); + } + + protected function tearDown(): void + { + if (!is_dir($this->root)) { + return; + } + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() && !$item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($this->root); + } + + #[Test] + public function writesStatsAndStreamsABlob(): void + { + $written = $this->service->write( + 'previews/report.webp', + $this->resource('preview-data'), + ['variant' => 'popover'], + ); + + self::assertSame('previews/report.webp', $written->key); + self::assertSame(strlen('preview-data'), $written->size); + self::assertStringStartsWith('sha256:', $written->etag); + self::assertSame($written->etag, $this->service->stat('previews/report.webp')?->etag); + self::assertSame(['variant' => 'popover'], $this->service->stat('previews/report.webp')?->attributes); + + $read = $this->service->read('previews/report.webp'); + self::assertNotNull($read); + self::assertSame('image/webp', $read->mimeType()); + self::assertSame('preview-data', implode('', iterator_to_array($read->stream()))); + } + + #[Test] + public function enforcesAtomicWriteConditions(): void + { + $first = $this->service->write('preview.webp', $this->resource('first')); + $second = $this->service->write( + 'preview.webp', + $this->resource('second'), + condition: WriteCondition::ifMatch($first->etag), + ); + self::assertNotSame($first->etag, $second->etag); + + try { + $this->service->write( + 'preview.webp', + $this->resource('stale'), + condition: WriteCondition::ifMatch($first->etag), + ); + self::fail('A stale ETag should fail'); + } catch (WriteConflictException) { + self::assertSame($second->etag, $this->service->stat('preview.webp')?->etag); + } + + $this->expectException(WriteConflictException::class); + $this->service->write( + 'preview.webp', + $this->resource('duplicate'), + condition: WriteCondition::ifAbsent(), + ); + } + + #[Test] + public function deletesConditionally(): void + { + $info = $this->service->write('preview.webp', $this->resource('content')); + + self::assertTrue($this->service->delete('preview.webp', WriteCondition::ifMatch($info->etag))); + self::assertNull($this->service->stat('preview.webp')); + self::assertFalse($this->service->delete('preview.webp')); + } + + #[Test] + public function lazilyListsOnlyTheRequestedPrefix(): void + { + $this->service->write('previews/a.webp', $this->resource('a')); + $this->service->write('previews/nested/b.webp', $this->resource('b')); + $this->service->write('icons/c.webp', $this->resource('c')); + + $items = iterator_to_array($this->service->list('previews/')); + $keys = array_map(static fn($item): string => $item->key, $items); + sort($keys); + + self::assertSame(['previews/a.webp', 'previews/nested/b.webp'], $keys); + } + + #[Test] + public function rejectsTraversalKeys(): void + { + $this->expectException(InvalidKeyException::class); + $this->service->read('../secret'); + } + + #[Test] + public function providerExposesTenantIsolatedServicesOnlyToSystem(): void + { + $provider = new Provider($this->root); + + self::assertNull($provider->serviceFetch('tenant-a', 'user-a', 'default')); + self::assertSame([], $provider->serviceList('tenant-a', 'user-a', [])); + $tenantA = $provider->serviceFetch('tenant-a', SystemIdentity::USER, 'default'); + $tenantB = $provider->serviceFetch('tenant-b', SystemIdentity::USER, 'default'); + self::assertInstanceOf(Service::class, $tenantA); + self::assertInstanceOf(Service::class, $tenantB); + + $tenantA->write('same-key', $this->resource('tenant-a')); + $tenantB->write('same-key', $this->resource('tenant-b')); + + self::assertSame('tenant-a', implode('', iterator_to_array($tenantA->read('same-key')->stream()))); + self::assertSame('tenant-b', implode('', iterator_to_array($tenantB->read('same-key')->stream()))); + } + + #[Test] + public function moduleRegistersTheLocalSystemStoreProvider(): void + { + $registrations = []; + $providers = $this->createMock(ProviderManager::class); + $providers->expects(self::exactly(2)) + ->method('register') + ->willReturnCallback(function (string $type, string $identifier, string $class) use (&$registrations): void { + $registrations[] = [$type, $identifier, $class]; + }); + $events = $this->createStub(EventListenerRegistrarInterface::class); + + (new Module($providers, $events))->boot(); + + self::assertContains( + [ProviderInterface::TYPE_DOCUMENT, 'default', DocumentProvider::class], + $registrations, + ); + self::assertContains( + [ProviderInterface::TYPE_SYSTEM_STORE, 'local', Provider::class], + $registrations, + ); + } + + private function resource(string $content): BinaryResource + { + return new BinaryResource( + 'preview.webp', + 'image/webp', + (function () use ($content): \Generator { + yield substr($content, 0, 3); + yield substr($content, 3); + })(), + ); + } +}