Files
provider_local_documents/lib/Providers/SystemStore/Service.php
T
Sebastian 7832527511 refactor: change storage tree
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-29 16:31:26 -04:00

362 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\ProviderLocalDocuments\Providers\SystemStore;
use DateTimeImmutable;
use JsonException;
use KTXF\Resource\BinaryResource;
use KTXF\SystemStore\BlobInfo;
use KTXF\SystemStore\InvalidKeyException;
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
use KTXF\SystemStore\SystemStoreException;
use KTXF\SystemStore\WriteCondition;
use KTXF\SystemStore\WriteConflictException;
final class Service implements SystemStoreServiceInterface
{
private const BLOB_EXTENSION = '.blob';
private const META_EXTENSION = '.meta';
private const LOCK_EXTENSION = '.lock';
public function __construct(private readonly string $root)
{
}
public function jsonSerialize(): array
{
return [
self::PROPERTY_TYPE => 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');
}
}
}