Files
zyt/server/app/common/service/ClinicalImageTextCache.php
2026-10-30 13:41:35 +08:00

247 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service;
/** Private optional OCR reuse. Call only with freshly authorized request-private copies. */
final class ClinicalImageTextCache
{
private const VERSION = 1;
private const TTL = 300;
private const SLOTS = 64;
private const LIMIT = 524288;
private const DIRECTORY = '.clinical-ocr-reuse-v1';
private $handle;
private string $token;
private string $sealKey;
private array $sources;
private function __construct($handle, string $token, string $sealKey, array $sources)
{
$this->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');
}
}