This commit is contained in:
Your Name
2026-10-30 13:41:35 +08:00
parent 28ba9b97f2
commit e2fc30e1f7
69 changed files with 8702 additions and 252 deletions
+248
View File
@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
// Real bridge, cache, source preparation and flock; synthetic files and fake OCR/cURL only.
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function proc_open($command, $descriptors, &$pipes, $cwd, $environment, $options) {
$GLOBALS['cacheRuns']++;
rewind($descriptors[0]); $request = json_decode(stream_get_contents($descriptors[0]), true);
$GLOBALS['cacheObservedDigests'] = array_map(static fn (array $row): string => 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); }
}