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 */ 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 $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; } } }