hash_file('sha256', $row['path']), $request['images']); $rows = array_map(static fn (array $row): array => ['index' => $row['index'], 'status' => 'processed', 'lines' => [['text' => 'SYNTHETIC 6.70 %; -0.12 mmol/L', 'confidence' => 0.91, 'box' => [[0, 0], [2, 0], [2, 1], [0, 1]]]]], $request['images']); $scenario = $GLOBALS['cacheScenario'] ?? ''; if (in_array($scenario, ['partial', 'no-text', 'mixed-no-text', 'text-limit', 'output-limit', 'inference', 'not-processed', 'deadline'], true)) { $reason = ['no-text' => 'no_text', 'mixed-no-text' => 'no_text', 'text-limit' => 'text_limit', 'output-limit' => 'output_limit', 'inference' => 'inference_failed', 'not-processed' => 'not_processed', 'deadline' => 'not_processed'][$scenario] ?? null; foreach ($rows as $position => $row) { if ($scenario !== 'no-text' && $position > 0) break; $rows[$position] = ['index' => $row['index'], 'status' => 'unreadable', 'lines' => []]; if ($reason !== null) $rows[$position]['reason'] = $reason; } } if ($scenario === 'missing') array_pop($rows); $processed = count(array_filter($rows, static fn (array $row): bool => $row['status'] === 'processed')); $status = $processed === count($request['images']) ? 'completed' : ($processed ? 'partial' : 'unavailable'); $output = ['status' => $status, 'images' => $rows]; if ($status !== 'completed') $output['reason'] = $scenario === 'deadline' ? 'deadline' : 'unreadable_sources'; fwrite($descriptors[1], ($GLOBALS['cacheScenario'] ?? '') === 'invalid' ? '{' : json_encode($output)); return fopen('php://temp', 'w+b'); } function proc_get_status($process): array { return ['running' => false, 'exitcode' => 0]; } function proc_close($process): int { fclose($process); return 0; } function proc_terminate($process, $signal = 15): bool { return true; } function curl_init(): \stdClass { return (object) ['errno' => 0, 'code' => 201]; } function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; } function curl_exec(\stdClass $handle): bool { $options = $handle->options; if (!str_starts_with($options[CURLOPT_URL], 'https://dify.synthetic.test/') || !str_ends_with($options[CURLOPT_URL], '/files/upload') || !is_array($options[CURLOPT_POSTFIELDS])) { throw new \RuntimeException('external I/O forbidden'); } $GLOBALS['cacheUploads']++; ($options[CURLOPT_WRITEFUNCTION])($handle, json_encode(['id' => 'synthetic-' . $GLOBALS['cacheUploads']])); return true; } function curl_errno(\stdClass $handle): int { return $handle->errno; } function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code; } function curl_close(\stdClass $handle): void {} } namespace { require dirname(__DIR__) . '/app/common/service/ClinicalImageTextService.php'; require dirname(__DIR__) . '/app/common/service/DifyRequiredAttachmentUpload.php'; use app\common\service\ClinicalImageTextCache as Cache; use app\common\service\ClinicalImageTextService as Ocr; use app\common\service\DifyRequiredAttachmentUpload as Uploader; function cacheExpect(bool $condition, string $message): void { if (!$condition) throw new RuntimeException($message); $GLOBALS['cacheAssertions']++; } function cacheRead(array $images, string $root, array $settings, ?callable $abort = null): array { return Ocr::read($images, $root, microtime(true) + 5, $abort, $settings); } function cacheOnlyRecord(string $root): string { $files = glob($root . '/.clinical-ocr-reuse-v1/*.cache') ?: []; cacheExpect(count($files) === 1, 'one bounded cache slot for the fixture'); return $files[0]; } function cacheSeal(array $record, string $scope): string { unset($record['seal']); $record['seal'] = hash_hmac('sha256', json_encode($record, JSON_THROW_ON_ERROR), $scope); return json_encode($record, JSON_THROW_ON_ERROR); } function cacheTreeDelete(string $path, string $base): void { $real = realpath($path); $allowed = realpath($base); if ($real === false || $allowed === false || ($real !== $allowed && !str_starts_with($real, $allowed . DIRECTORY_SEPARATOR))) { throw new RuntimeException('cleanup outside synthetic fixture'); } foreach (new DirectoryIterator($path) as $entry) { if ($entry->isDot()) continue; if ($entry->isDir() && !$entry->isLink()) cacheTreeDelete($entry->getPathname(), $base); else unlink($entry->getPathname()); } rmdir($path); } $base = sys_get_temp_dir() . '/clinical-ocr-cache-test-' . bin2hex(random_bytes(8)); mkdir($base, 0700); mkdir($base . '/runtime', 0700); mkdir($base . '/private', 0700); $venv = $base . '/venv'; $bin = $venv . (PHP_OS_FAMILY === 'Windows' ? '/Scripts' : '/bin'); $site = $venv . (PHP_OS_FAMILY === 'Windows' ? '/Lib/site-packages' : '/lib/python3.11/site-packages'); mkdir($bin, 0700, true); mkdir($site, 0700, true); $python = $bin . (PHP_OS_FAMILY === 'Windows' ? '/python.exe' : '/python'); $executable = $base . '/runtime/' . (PHP_OS_FAMILY === 'Windows' ? 'python.exe' : 'python'); file_put_contents($python, 'SYNTHETIC_VENV_LAUNCHER'); chmod($python, 0700); file_put_contents($executable, 'SYNTHETIC_PYTHON_RUNTIME'); file_put_contents($base . '/runtime/python311.dll', 'SYNTHETIC_PYTHON_LIBRARY'); file_put_contents($venv . '/pyvenv.cfg', "include-system-site-packages = false\nversion = 3.11.7\nexecutable = " . $executable . "\n"); foreach (['rapidocr' => '3.10.0', 'onnxruntime' => '1.31.0'] as $package => $version) { $info = $site . '/' . $package . '-' . $version . '.dist-info'; mkdir($info, 0700); file_put_contents($info . '/METADATA', "Name: {$package}\r\nVersion: {$version}\r\n"); file_put_contents($info . '/RECORD', 'SYNTHETIC_INSTALLED_RECORD'); } mkdir($site . '/rapidocr/models', 0700, true); file_put_contents($site . '/rapidocr/__init__.py', 'SYNTHETIC_RECOGNIZER_CODE'); file_put_contents($site . '/rapidocr/py.typed', ''); foreach (['PP-OCRv6_det_small.onnx', 'ch_ppocr_mobile_v2.0_cls_mobile.onnx', 'PP-OCRv6_rec_small.onnx'] as $model) { file_put_contents($site . '/rapidocr/models/' . $model, 'SYNTHETIC_MODEL_' . $model); } $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='); $root = $base . '/private'; $path = $root . '/source.png'; file_put_contents($path, $png); $images = [['index' => 7, 'path' => $path]]; $settings = ['enabled' => true, 'python' => $python, 'timeout_seconds' => 5, 'threads' => 1, 'detection_limit' => 640, 'max_text_bytes' => 4096, '_cache_scope' => hash('sha256', 'SYNTHETIC_SCOPE_A')]; $GLOBALS['cacheRuns'] = 0; $GLOBALS['cacheUploads'] = 0; $GLOBALS['cacheAssertions'] = 0; try { $first = cacheRead($images, $root, $settings); $second = cacheRead($images, $root, $settings); cacheExpect($first['status'] === 'completed' && $second === $first && $GLOBALS['cacheRuns'] === 1, 'unchanged validated success is reused without another OCR process; text/confidence/box/index remain exact'); $recordPath = cacheOnlyRecord($root); $record = file_get_contents($recordPath); cacheExpect(!str_contains($record, $base) && !str_contains($record, $settings['_cache_scope']) && !str_contains($record, 'SYNTHETIC_SCOPE_A') && !str_contains($record, 'source.png'), 'cache exposes no raw source path or scope'); if (PHP_OS_FAMILY !== 'Windows') cacheExpect((fileperms($recordPath) & 0777) === 0600 && (fileperms(dirname($recordPath)) & 0777) === 0700, 'private cache permissions'); $before = $GLOBALS['cacheRuns']; $mtime = filemtime($path); file_put_contents($path, $png . 'changed'); touch($path, $mtime); cacheRead($images, $root, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 1, 'fresh source bytes invalidate reuse even with preserved mtime'); $before = $GLOBALS['cacheRuns']; $changed = $settings; $changed['threads'] = 2; cacheRead($images, $root, $changed); cacheExpect($GLOBALS['cacheRuns'] === $before + 1, 'recognizer configuration change invalidates reuse'); $before = $GLOBALS['cacheRuns']; $changed = $settings; $changed['_cache_scope'] = hash('sha256', 'SYNTHETIC_SCOPE_B'); cacheRead($images, $root, $changed); cacheExpect($GLOBALS['cacheRuns'] === $before + 1, 'different scoped user/application cannot reuse text'); $before = $GLOBALS['cacheRuns']; $differentIndex = [['index' => 19, 'path' => $path]]; $indexed = cacheRead($differentIndex, $root, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 1 && $indexed['images'][0]['index'] === 19, 'source ordering and provenance indices are bound'); foreach ([$site . '/rapidocr/__init__.py', $site . '/rapidocr/models/PP-OCRv6_rec_small.onnx', $site . '/onnxruntime-1.31.0.dist-info/RECORD', $python, $executable] as $changedPath) { cacheRead($images, $root, $settings); $before = $GLOBALS['cacheRuns']; file_put_contents($changedPath, 'UPDATED_SYNTHETIC_RUNTIME'); cacheRead($images, $root, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 1, 'runtime/recognizer/model installation change invalidates reuse'); } $worker = $base . '/synthetic-worker.py'; file_put_contents($worker, 'SYNTHETIC_WORKER_V1'); $lease = Cache::acquire($root, $images, $settings, $python, $worker, microtime(true) + 5, null); cacheExpect($lease !== null, 'verified synthetic runtime can acquire private slot'); $lease->remember($first, $images, microtime(true) + 5, null); $lease->close(); file_put_contents($worker, 'SYNTHETIC_WORKER_V2'); $lease = Cache::acquire($root, $images, $settings, $python, $worker, microtime(true) + 5, null); cacheExpect($lease !== null && $lease->cached($images, microtime(true) + 5, null) === null, 'worker code change invalidates prior successful text'); $lease->close(); foreach (['partial', 'invalid', 'text-limit', 'output-limit', 'inference', 'not-processed', 'deadline', 'missing'] as $scenario) { $GLOBALS['cacheScenario'] = $scenario; $changed = $settings; $changed['_cache_scope'] = hash('sha256', $scenario); $before = $GLOBALS['cacheRuns']; cacheRead($images, $root, $changed); cacheRead($images, $root, $changed); cacheExpect($GLOBALS['cacheRuns'] === $before + 2, 'incomplete/failed/budget-exhausted OCR must never be cached'); } $normalSources = [['index' => 7, 'path' => $path], ['index' => 19, 'path' => $path]]; foreach (['mixed-no-text' => 'partial', 'no-text' => 'unavailable'] as $scenario => $expectedStatus) { $GLOBALS['cacheScenario'] = $scenario; $changed = $settings; $changed['_cache_scope'] = hash('sha256', $scenario); $before = $GLOBALS['cacheRuns']; $normal = cacheRead($normalSources, $root, $changed); $cachedNormal = cacheRead($normalSources, $root, $changed); cacheExpect($GLOBALS['cacheRuns'] === $before + 1 && $cachedNormal === $normal && $normal['status'] === $expectedStatus && $normal['images'][0]['status'] === 'unreadable' && $normal['images'][0]['lines'] === [] && array_column($normal['images'], 'index') === [7, 19], 'normal no-text results can be reused without relabeling unreadable or partial/unavailable status'); } $GLOBALS['cacheScenario'] = ''; $unknown = $settings; unset($unknown['_cache_scope']); $before = $GLOBALS['cacheRuns']; cacheRead($images, $root, $unknown); cacheRead($images, $root, $unknown); cacheExpect($GLOBALS['cacheRuns'] === $before + 2, 'missing trusted reuse scope keeps original OCR behavior'); $metadata = $site . '/onnxruntime-1.31.0.dist-info/METADATA'; $originalMetadata = file_get_contents($metadata); file_put_contents($metadata, "Name: onnxruntime\nVersion: UNKNOWN\n"); $before = $GLOBALS['cacheRuns']; cacheRead($images, $root, $settings); cacheRead($images, $root, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 2, 'unverifiable runtime version bypasses cache'); file_put_contents($metadata, $originalMetadata); $before = $GLOBALS['cacheRuns']; $cancelled = cacheRead($images, $root, $settings, static fn (): bool => true); cacheExpect($cancelled['reason'] === 'cancelled' && $GLOBALS['cacheRuns'] === $before, 'cancelled request cannot consume cached OCR'); $checks = 0; $cancelled = cacheRead($images, $root, $settings, static function () use (&$checks): bool { return ++$checks > 2; }); cacheExpect($cancelled['reason'] === 'cancelled' && $GLOBALS['cacheRuns'] === $before, 'cancellation during runtime fingerprinting never starts OCR'); $expired = Ocr::read($images, $root, microtime(true) - 1, null, $settings); cacheExpect($expired['reason'] === 'deadline' && $GLOBALS['cacheRuns'] === $before, 'expired request cannot consume cached OCR'); $ttlRoot = $base . '/ttl'; mkdir($ttlRoot, 0700); file_put_contents($ttlRoot . '/source.png', $png); $ttlImages = [['index' => 1, 'path' => $ttlRoot . '/source.png']]; cacheRead($ttlImages, $ttlRoot, $settings); $slot = cacheOnlyRecord($ttlRoot); $saved = json_decode(file_get_contents($slot), true); $saved['created'] = microtime(true) - 301; $saved['expires'] = microtime(true) - 1; file_put_contents($slot, cacheSeal($saved, $settings['_cache_scope'])); $before = $GLOBALS['cacheRuns']; cacheRead($ttlImages, $ttlRoot, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 1, 'expired five-minute entry is not reused'); file_put_contents($slot, '{'); $before = $GLOBALS['cacheRuns']; cacheRead($ttlImages, $ttlRoot, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 1, 'partial/corrupt write is a cache miss'); $saved = json_decode(file_get_contents($slot), true); $saved['result']['images'][0]['lines'][0]['confidence'] = 99; file_put_contents($slot, cacheSeal($saved, $settings['_cache_scope'])); $before = $GLOBALS['cacheRuns']; cacheRead($ttlImages, $ttlRoot, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 1, 'cached evidence must pass the original strict validator'); $saved = json_decode(file_get_contents($slot), true); $saved['result']['images'][0]['lines'][0]['text'] = 'CHANGED_BUT_VALID_SYNTHETIC_TEXT'; file_put_contents($slot, json_encode($saved)); $before = $GLOBALS['cacheRuns']; cacheRead($ttlImages, $ttlRoot, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 1, 'integrity seal rejects valid-schema text corruption'); $lock = fopen($slot, 'r+b'); flock($lock, LOCK_EX | LOCK_NB); $before = $GLOBALS['cacheRuns']; $start = microtime(true); cacheRead($ttlImages, $ttlRoot, $settings); cacheExpect($GLOBALS['cacheRuns'] === $before + 1 && microtime(true) - $start < 1, 'contended stable inode falls back without blocking or partial reads'); flock($lock, LOCK_UN); fclose($lock); unlink($ttlRoot . '/source.png'); $before = $GLOBALS['cacheRuns']; $missing = cacheRead($ttlImages, $ttlRoot, $settings); cacheExpect($missing['status'] === 'unavailable' && $GLOBALS['cacheRuns'] === $before, 'removed source cannot be resurrected from cached text'); mkdir($base . '/public/uploads', 0700, true); mkdir($base . '/upload-private', 0700); $source = $base . '/public/uploads/image.png'; file_put_contents($source, $png); $files = [['type' => 'image', 'url' => 'https://source.synthetic.test/uploads/image.png']]; $options = ['public_root' => $base . '/public', 'temp_root' => $base . '/upload-private', 'local_origin' => 'https://source.synthetic.test', 'reuse_uploaded_files' => true, 'ocr' => $settings]; $run = static function (string $user = 'synthetic-user', string $key = 'SYNTHETIC_KEY', string $endpoint = 'https://dify.synthetic.test/v1/chat-messages', ?array $override = null) use ($files, $options): array { return Uploader::prepare($endpoint, $key, $user, $files, 20, null, $override ?? $options); }; $before = $GLOBALS['cacheRuns']; $a = $run(); $b = $run(); cacheExpect($a['ok'] && $b['ok'] && $a['image_text_check'] === $b['image_text_check'] && $GLOBALS['cacheRuns'] === $before + 1, 'real uploader uses fresh private copies but reuses identical successful OCR'); foreach ([['other-user', 'SYNTHETIC_KEY', 'https://dify.synthetic.test/v1/chat-messages'], ['synthetic-user', 'OTHER_KEY', 'https://dify.synthetic.test/v1/chat-messages'], ['synthetic-user', 'SYNTHETIC_KEY', 'https://dify.synthetic.test/other/chat-messages']] as $scope) { $before = $GLOBALS['cacheRuns']; $result = $run(...$scope); cacheExpect($result['ok'] && $GLOBALS['cacheRuns'] === $before + 1, 'uploader owns user/credential/endpoint scope'); } $changedOptions = $options; $changedOptions['reuse_uploaded_files'] = false; $before = $GLOBALS['cacheRuns']; $run(override: $changedOptions); $run(override: $changedOptions); cacheExpect($GLOBALS['cacheRuns'] === $before + 2, 'existing reuse opt-out also disables OCR reuse despite forged inner scope'); $before = $GLOBALS['cacheRuns']; file_put_contents($source, $png . 'FRESH_AUTHORIZED_CONTENT'); $fresh = $run(); cacheExpect($fresh['ok'] && $GLOBALS['cacheRuns'] === $before + 1 && $GLOBALS['cacheObservedDigests'][0] === hash_file('sha256', $source), 'source replacement is freshly copied and recognized'); $changedOptions = $options; $changedOptions['local_origin'] = 'https://different.synthetic.test'; $before = $GLOBALS['cacheRuns']; $untrusted = $run(override: $changedOptions); cacheExpect(!$untrusted['ok'] && $untrusted['error_code'] === 'REQUIRED_FILES_SOURCE_UNTRUSTED' && $GLOBALS['cacheRuns'] === $before, 'cache does not bypass current source trust checks'); unlink($source); $missing = $run(); cacheExpect($missing['ok'] && $missing['files'] === [] && $missing['image_text_check']['images'] === [] && $GLOBALS['cacheRuns'] === $before, 'deleted source remains skipped and cached text is never submitted'); cacheExpect(count(glob($root . '/.clinical-ocr-reuse-v1/*.cache') ?: []) <= 64, 'fixed slot count bounds storage'); echo 'Clinical OCR reuse: ' . $GLOBALS['cacheAssertions'] . " offline assertions passed; no network or real OCR\n"; } finally { cacheTreeDelete($base, $base); } }