247 lines
13 KiB
PHP
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');
|
|
}
|
|
}
|