Files
Netbox-Store/store/src/Database/JsonStoreRepository.php
T
MrBlake f36d6be511
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run
feat: add NetBox plugin store
2026-08-24 20:51:25 +02:00

166 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
namespace NetBoxStore\Database;
use JsonException;
use RuntimeException;
use Throwable;
final class JsonStoreRepository implements StoreRepository
{
private const MAX_DATABASE_BYTES = 134_217_728;
private string $lockPath;
public function __construct(private readonly string $path, private readonly int $maxDatabaseBytes = self::MAX_DATABASE_BYTES)
{
$this->lockPath = $path . '.lock';
}
public function initialize(): void
{
$directory = dirname($this->path);
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('Could not create datastore directory.');
}
if (!is_file($this->path)) {
$lock = $this->lock(LOCK_EX);
try {
if (!is_file($this->path)) {
$this->atomicWrite(State::empty());
}
} finally {
$this->unlock($lock);
}
}
@chmod($this->path, 0600);
$this->read();
}
public function read(): array
{
$lock = $this->lock(LOCK_SH);
try {
return $this->readUnlocked();
} finally {
$this->unlock($lock);
}
}
public function acquireLease(string $name): ?ExclusiveLease
{
$leasePath = $this->path . '.lease-' . substr(hash('sha256', $name), 0, 24) . '.lock';
$handle = fopen($leasePath, 'c+b');
if ($handle === false) {
throw new RuntimeException('Could not open exclusive lease file.');
}
@chmod($leasePath, 0600);
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return null;
}
return new CallbackLease(static function () use ($handle): void {
flock($handle, LOCK_UN);
fclose($handle);
});
}
public function transaction(callable $callback): mixed
{
$lock = $this->lock(LOCK_EX);
try {
$draft = $this->readUnlocked();
$result = $callback($draft);
State::validate($draft);
$this->atomicWrite($draft);
return $result;
} finally {
$this->unlock($lock);
}
}
/** @return resource */
private function lock(int $operation): mixed
{
$handle = fopen($this->lockPath, 'c+b');
if ($handle === false || !flock($handle, $operation)) {
throw new RuntimeException('Could not acquire datastore lock.');
}
@chmod($this->lockPath, 0600);
return $handle;
}
/** @param resource $handle */
private function unlock(mixed $handle): void
{
flock($handle, LOCK_UN);
fclose($handle);
}
/** @return array<string,mixed> */
private function readUnlocked(): array
{
$size = @filesize($this->path);
if ($size === false || $size > $this->maxDatabaseBytes) {
throw new RuntimeException('JSON datastore is missing or exceeds 128 MB.');
}
$contents = file_get_contents($this->path);
if ($contents === false) {
throw new RuntimeException('Could not read JSON datastore.');
}
try {
$state = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('JSON datastore is malformed.', 0, $exception);
}
if (!is_array($state)) {
throw new RuntimeException('JSON datastore root is invalid.');
}
State::validate($state);
return $state;
}
/** @param array<string,mixed> $state */
private function atomicWrite(array $state): void
{
$payload = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n";
if (strlen($payload) > $this->maxDatabaseBytes) {
throw new RuntimeException('JSON datastore would exceed its configured 128 MB safety limit.');
}
$temporary = dirname($this->path) . '/.' . basename($this->path) . '.tmp-' . bin2hex(random_bytes(8));
$handle = fopen($temporary, 'x+b');
if ($handle === false) {
throw new RuntimeException('Could not create temporary datastore file.');
}
try {
$written = 0;
while ($written < strlen($payload)) {
$chunk = fwrite($handle, substr($payload, $written));
if ($chunk === false || $chunk === 0) {
throw new RuntimeException('Could not write temporary datastore file.');
}
$written += $chunk;
}
if (!fflush($handle)) {
throw new RuntimeException('Could not flush temporary datastore file.');
}
if (function_exists('fsync')) {
fsync($handle);
}
fclose($handle);
$handle = null;
@chmod($temporary, 0600);
if (!rename($temporary, $this->path)) {
throw new RuntimeException('Atomic datastore rename failed.');
}
} catch (Throwable $exception) {
if (is_resource($handle)) {
fclose($handle);
}
@unlink($temporary);
throw $exception;
}
}
}