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->blobPath($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->blobPath($key); $metaPath = $this->metaPath($key); $this->ensureParent($dataPath); $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->blobPath($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->root; if (!is_dir($root)) { return; } $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); foreach ($files as $file) { if (!$file->isFile() || $file->isLink()) { continue; } $pathname = $file->getPathname(); if (!str_ends_with($pathname, self::BLOB_EXTENSION)) { continue; } $relative = substr($pathname, strlen($root) + 1, -strlen(self::BLOB_EXTENSION)); $key = str_replace(DIRECTORY_SEPARATOR, '/', $relative); 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->blobPath($key); if (!is_file($path) || is_link($path)) { return null; } $this->assertExistingPathInside($path, $this->root); $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) { $path = $this->lockPath($key); $this->ensureDirectory(dirname($path)); $this->assertExistingPathInside(dirname($path), $this->root); $handle = fopen($path, '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 blobPath(string $key): string { return $this->root . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key) . self::BLOB_EXTENSION; } private function metaPath(string $key): string { return $this->root . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key) . self::META_EXTENSION; } private function lockPath(string $key): string { return $this->root . '/' . str_replace('/', DIRECTORY_SEPARATOR, $key) . self::LOCK_EXTENSION; } private function ensureParent(string $path): void { $this->ensureDirectory(dirname($path)); $this->assertExistingPathInside(dirname($path), $this->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'); } } }