handle = $handle; $this->token = $token; $this->sealKey = $sealKey; $this->sources = $sources; } /** Missing/unverifiable runtime identity or cache contention is an ordinary cache miss. */ public static function acquire(string $root, array $sources, array $config, string $python, string $worker, float $deadline, ?callable $abort): ?self { $handle = null; try { $scope = $config['_cache_scope'] ?? null; if (!is_string($scope) || preg_match('/^[a-f0-9]{64}$/D', $scope) !== 1) return null; $identity = self::runtime($config, $python, $worker, $deadline, $abort); if ($identity === null) return null; $digests = self::sourceDigests($sources, $deadline, $abort); if ($digests === null) return null; unset($config['_cache_scope']); ksort($config); $token = hash_hmac('sha256', serialize([self::VERSION, $identity, $config, $digests]), $scope); $handle = self::open($root, $token); // Never spend the clinical request budget waiting for another OCR worker. if ($handle === null || !@flock($handle, LOCK_EX | LOCK_NB)) { if (is_resource($handle)) fclose($handle); return null; } return new self($handle, $token, $scope, $digests); } catch (\Throwable $ignored) { if (is_resource($handle)) { @flock($handle, LOCK_UN); fclose($handle); } return null; } } public function cached(array $sources, float $deadline, ?callable $abort): ?array { try { if (self::sourceDigests($sources, $deadline, $abort) !== $this->sources) return null; $size = (fstat($this->handle) ?: [])['size'] ?? 0; if ($size < 1 || $size > self::LIMIT || !rewind($this->handle)) return null; $raw = stream_get_contents($this->handle, self::LIMIT + 1); $data = is_string($raw) ? json_decode($raw, true, 64, JSON_THROW_ON_ERROR) : null; if (!is_array($data) || ($data['v'] ?? null) !== self::VERSION || !is_string($data['token'] ?? null) || !hash_equals($this->token, $data['token']) || !is_numeric($data['created'] ?? null) || !is_numeric($data['expires'] ?? null)) return null; $seal = $data['seal'] ?? null; unset($data['seal']); if (!is_string($seal) || !hash_equals(hash_hmac('sha256', json_encode($data, JSON_THROW_ON_ERROR), $this->sealKey), $seal)) return null; $created = (float) $data['created']; $expires = (float) $data['expires']; $now = microtime(true); if (!is_finite($created) || !is_finite($expires) || $expires <= $created || $expires - $created > self::TTL + 0.001 || $now < $created - 1 || $now >= $expires) { $this->clear(); return null; } self::check($deadline, $abort); // The bridge applies the original strict output validator to this candidate. return is_array($data['result'] ?? null) ? $data['result'] : null; } catch (\Throwable $ignored) { return null; } } /** Only fully validated, fully attempted recognition (processed or normal no-text) may enter. */ public function remember(array $result, array $sources, float $deadline, ?callable $abort): void { try { if (!in_array($result['status'] ?? '', ['completed', 'partial', 'unavailable'], true) || empty($result['images']) || (($result['status'] ?? '') !== 'completed' && ($result['reason'] ?? '') !== 'unreadable_sources') || array_filter($result['images'], static fn (array $row): bool => !in_array($row['status'] ?? '', ['processed', 'unreadable'], true)) || self::sourceDigests($sources, $deadline, $abort) !== $this->sources) return; self::check($deadline, $abort); $now = microtime(true); $record = ['v' => self::VERSION, 'token' => $this->token, 'created' => $now, 'expires' => $now + self::TTL, 'result' => $result]; $record['seal'] = hash_hmac('sha256', json_encode($record, JSON_THROW_ON_ERROR), $this->sealKey); $raw = json_encode($record, JSON_THROW_ON_ERROR); if (strlen($raw) > self::LIMIT || !$this->clear()) return; if (@fwrite($this->handle, $raw) !== strlen($raw) || !@fflush($this->handle)) $this->clear(); try { self::check($deadline, $abort); } catch (\Throwable $ignored) { $this->clear(); } } catch (\Throwable $ignored) { /* Optional cache failure never changes OCR evidence. */ } } public function close(): void { if (is_resource($this->handle)) { @flock($this->handle, LOCK_UN); fclose($this->handle); } $this->handle = null; } private function clear(): bool { return @rewind($this->handle) && @ftruncate($this->handle, 0) && @fflush($this->handle); } private static function sourceDigests(array $sources, float $deadline, ?callable $abort): ?array { $result = []; foreach ($sources as $source) { $path = $source['path'] ?? null; $index = $source['index'] ?? null; if (!is_string($path) || !is_int($index)) return null; $digest = self::digest($path, 20971520, $deadline, $abort); if ($digest === null) return null; $result[] = [$index, $digest]; } return $result; } /** Fingerprint a private venv, its installed versions, recognizer code/models and Python runtime. */ private static function runtime(array $config, string $python, string $worker, float $deadline, ?callable $abort): ?string { $configured = (string) ($config['python'] ?? ''); $venv = realpath(dirname($configured, 2)); if ($venv === false) return null; $cfgPath = $venv . DIRECTORY_SEPARATOR . 'pyvenv.cfg'; $cfg = @file_get_contents($cfgPath, false, null, 0, 16385); if (!is_string($cfg) || strlen($cfg) > 16384 || preg_match('/^include-system-site-packages\s*=\s*false\s*$/mi', $cfg) !== 1 || preg_match('/^version\s*=\s*(\d+\.\d+\.\d+)\s*$/mi', $cfg, $version) !== 1 || preg_match('/^executable\s*=\s*(.+?)\s*$/mi', $cfg, $executable) !== 1) return null; $site = PHP_OS_FAMILY === 'Windows' ? $venv . '/Lib/site-packages' : $venv . '/lib/python' . implode('.', array_slice(explode('.', $version[1]), 0, 2)) . '/site-packages'; $site = realpath($site); if ($site === false || !is_dir($site)) return null; $files = [$cfgPath, $python, $worker, __FILE__, __DIR__ . '/ClinicalImageTextService.php']; $base = realpath(trim($executable[1])); if ($base === false) return null; $files[] = $base; if (PHP_OS_FAMILY === 'Windows') { $dll = dirname($base) . '/python' . str_replace('.', '', implode('.', array_slice(explode('.', $version[1]), 0, 2))) . '.dll'; if (!is_file($dll)) return null; $files[] = $dll; } $metadata = glob($site . '/*.dist-info/METADATA') ?: []; if (count($metadata) < 2 || count($metadata) > 128) return null; $required = ['rapidocr' => '3.10.0', 'onnxruntime' => '1.31.0']; foreach ($metadata as $path) { $text = @file_get_contents($path, false, null, 0, 2097153); if (!is_string($text) || strlen($text) > 2097152) return null; $text = str_replace("\r\n", "\n", $text); if (preg_match('/^Name: ([^\r\n]+)$/mi', $text, $name) && array_key_exists(strtolower($name[1]), $required)) { $package = strtolower($name[1]); if (preg_match('/^Version: ([^\r\n]+)$/mi', $text, $release) !== 1 || $required[$package] !== $release[1]) return null; unset($required[$package]); } $record = dirname($path) . '/RECORD'; if (!is_file($record)) return null; $files[] = $path; $files[] = $record; } if ($required !== []) return null; foreach (glob($site . '/*.pth') ?: [] as $path) $files[] = $path; $recognizer = $site . '/rapidocr'; if (!is_dir($recognizer) || is_link($recognizer)) return null; foreach (['PP-OCRv6_det_small.onnx', 'ch_ppocr_mobile_v2.0_cls_mobile.onnx', 'PP-OCRv6_rec_small.onnx'] as $model) { if (!is_file($recognizer . '/models/' . $model)) return null; } $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($recognizer, \FilesystemIterator::SKIP_DOTS)); foreach ($iterator as $file) { if ($file->isLink()) return null; if (!$file->isFile() || $file->getExtension() === 'pyc') continue; $files[] = $file->getPathname(); if (count($files) > 4096) return null; } $files = array_values(array_unique($files)); sort($files); $context = hash_init('sha256'); $bytes = 0; // These are the runtime-selection environment values forwarded by the bridge. hash_update($context, serialize([PHP_OS_FAMILY, getenv('PATH'), getenv('SystemRoot'), getenv('WINDIR')])); foreach ($files as $path) { clearstatcache(true, $path); $size = @filesize($path); if ($size === false || ($bytes += $size) > 134217728) return null; $digest = self::digest($path, 67108864, $deadline, $abort); if ($digest === null) return null; hash_update($context, $path . "\0" . $digest . "\0"); } return hash_final($context); } /** Read actual bytes, rejecting symlinks, hardlinks and concurrent replacement/modification. */ private static function digest(string $path, int $limit, float $deadline, ?callable $abort): ?string { self::check($deadline, $abort); clearstatcache(true, $path); if (is_link($path)) return null; $before = @lstat($path); if ($before === false || ($before['mode'] & 0170000) !== 0100000 || $before['nlink'] !== 1 || $before['size'] < 0 || $before['size'] > $limit) return null; $handle = @fopen($path, 'rb'); if ($handle === false) return null; try { $opened = fstat($handle); if ($opened === false || $opened['dev'] !== $before['dev'] || $opened['ino'] !== $before['ino']) return null; $hash = hash_init('sha256'); $bytes = 0; while (!feof($handle)) { self::check($deadline, $abort); $chunk = fread($handle, 1048576); if ($chunk === false || ($bytes += strlen($chunk)) > $limit) return null; hash_update($hash, $chunk); } clearstatcache(true, $path); $after = @lstat($path); if ($after === false || $bytes !== $before['size']) return null; foreach (['dev', 'ino', 'size', 'mtime', 'ctime', 'nlink'] as $field) if ($before[$field] !== $after[$field]) return null; return hash_final($hash); } finally { fclose($handle); } } private static function open(string $root, string $token) { if (is_link($root) || realpath($root) === false) return null; $directory = realpath($root) . DIRECTORY_SEPARATOR . self::DIRECTORY; if (is_link($directory) || (!is_dir($directory) && !@mkdir($directory, 0700) && !is_dir($directory))) return null; clearstatcache(true, $directory); if (is_link($directory) || realpath($directory) !== $directory || !@chmod($directory, 0700)) return null; $stat = @stat($directory); if ($stat === false || (PHP_OS_FAMILY !== 'Windows' && ($stat['mode'] & 077) !== 0)) return null; $path = $directory . DIRECTORY_SEPARATOR . sprintf('%02d.cache', hexdec(substr($token, 0, 4)) % self::SLOTS); clearstatcache(true, $path); if (is_link($path) || (file_exists($path) && !is_file($path))) return null; $handle = @fopen($path, 'x+b'); if ($handle === false) $handle = @fopen($path, 'r+b'); if ($handle === false) return null; clearstatcache(true, $path); $opened = fstat($handle); $current = @lstat($path); if (is_link($path) || realpath($path) !== $path || $opened === false || $current === false || ($opened['mode'] & 0170000) !== 0100000 || ($current['mode'] & 0170000) !== 0100000 || $opened['dev'] !== $current['dev'] || $opened['ino'] !== $current['ino'] || $opened['nlink'] !== 1 || $current['nlink'] !== 1 || !@chmod($path, 0600)) { fclose($handle); return null; } return $handle; } private static function check(float $deadline, ?callable $abort): void { if (!is_finite($deadline) || microtime(true) >= $deadline || ($abort !== null && $abort())) throw new \RuntimeException('OCR_CACHE_UNAVAILABLE'); } }