811 lines
58 KiB
PHP
811 lines
58 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service {
|
|
/** Simulate access denied deterministically on Windows without changing ACLs. */
|
|
function scandir(string $directory)
|
|
{
|
|
if (($GLOBALS['attachmentDeniedDirectory'] ?? null) === $directory) return false;
|
|
return \scandir($directory);
|
|
}
|
|
|
|
/** Never query real DNS: reproduce the unsafe proxy answer deterministically. */
|
|
function dns_get_record(string $host, int $type): array
|
|
{
|
|
$GLOBALS['attachmentNativeDnsCalls'][] = $host;
|
|
if ($host !== 'storage.synthetic.test') throw new \RuntimeException('Unexpected synthetic DNS host');
|
|
return [['ip' => '198.18.0.1']];
|
|
}
|
|
|
|
/** Weak keys observe lifetime without keeping a connection pool alive in the test. */
|
|
function attachmentTransportId(object $handle): int
|
|
{
|
|
$handles = $GLOBALS['attachmentTransportHandles'];
|
|
if (!isset($handles[$handle])) $handles[$handle] = ++$GLOBALS['attachmentTransportSequence'];
|
|
return $handles[$handle];
|
|
}
|
|
|
|
function curl_init()
|
|
{
|
|
$handle = \curl_init();
|
|
if ($handle !== false) $GLOBALS['attachmentTransportEvents'][] = ['kind' => 'easy_init', 'easy' => attachmentTransportId($handle)];
|
|
return $handle;
|
|
}
|
|
|
|
function curl_close($handle): void
|
|
{
|
|
$GLOBALS['attachmentTransportEvents'][] = ['kind' => 'easy_close', 'easy' => attachmentTransportId($handle)];
|
|
\curl_close($handle);
|
|
}
|
|
|
|
function curl_multi_init()
|
|
{
|
|
$multi = \curl_multi_init();
|
|
if ($multi !== false) $GLOBALS['attachmentTransportEvents'][] = ['kind' => 'pool_init', 'pool' => attachmentTransportId($multi)];
|
|
return $multi;
|
|
}
|
|
|
|
function curl_multi_add_handle($multi, $handle): int
|
|
{
|
|
$result = !empty($GLOBALS['attachmentFailMultiAdd']) ? CURLM_BAD_HANDLE : \curl_multi_add_handle($multi, $handle);
|
|
$GLOBALS['attachmentTransportEvents'][] = ['kind' => 'add', 'pool' => attachmentTransportId($multi),
|
|
'easy' => attachmentTransportId($handle), 'result' => $result];
|
|
return $result;
|
|
}
|
|
|
|
function curl_multi_remove_handle($multi, $handle): int
|
|
{
|
|
$result = \curl_multi_remove_handle($multi, $handle);
|
|
$GLOBALS['attachmentTransportEvents'][] = ['kind' => 'remove', 'pool' => attachmentTransportId($multi),
|
|
'easy' => attachmentTransportId($handle), 'result' => $result];
|
|
return $result;
|
|
}
|
|
|
|
function curl_multi_close($multi): void
|
|
{
|
|
$GLOBALS['attachmentTransportEvents'][] = ['kind' => 'pool_close', 'pool' => attachmentTransportId($multi)];
|
|
\curl_multi_close($multi);
|
|
}
|
|
|
|
/** Route synthetic public storage to loopback AFTER recording real security options. */
|
|
function curl_setopt_array($handle, array $options): bool
|
|
{
|
|
$original = $options;
|
|
$url = (string) ($options[CURLOPT_URL] ?? '');
|
|
if (str_starts_with($url, 'https://storage.synthetic.test/')) {
|
|
$options[CURLOPT_URL] = 'http://127.0.0.1:' . $GLOBALS['attachmentFixturePort'] . substr($url, strlen('https://storage.synthetic.test'));
|
|
unset($options[CURLOPT_RESOLVE]);
|
|
} elseif (!str_starts_with($url, 'http://127.0.0.1:' . $GLOBALS['attachmentFixturePort'] . '/')) {
|
|
throw new \RuntimeException('The synthetic test forbids external connections');
|
|
}
|
|
$GLOBALS['attachmentCurlOptions'][] = $original;
|
|
return \curl_setopt_array($handle, $options);
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
/** Config-file regression tests read only this synthetic environment map. */
|
|
function env(string $name, $default = null)
|
|
{
|
|
return $GLOBALS['attachmentSyntheticEnv'][$name] ?? $default;
|
|
}
|
|
|
|
// PHP's built-in HTTP server closes connections. This second fixture keeps a
|
|
// real HTTP/1.1 socket alive so tests verify libcurl reuse, not just pool counts.
|
|
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === '--keepalive-fixture') {
|
|
$fixtureRoot = (string) getenv('DIFY_ATTACHMENT_FIXTURE_ROOT');
|
|
$fixturePort = (int) ($argv[2] ?? 0);
|
|
$server = stream_socket_server('tcp://127.0.0.1:' . $fixturePort, $errno, $error);
|
|
if (!is_resource($server)) exit(2);
|
|
$connection = 0;
|
|
while (true) {
|
|
$client = @stream_socket_accept($server, 10);
|
|
if (!is_resource($client)) continue;
|
|
++$connection;
|
|
stream_set_timeout($client, 3);
|
|
while (!feof($client)) {
|
|
$requestLine = fgets($client);
|
|
if ($requestLine === false || trim($requestLine) === '') break;
|
|
$headers = [];
|
|
while (($line = fgets($client)) !== false && trim($line) !== '') {
|
|
$split = strpos($line, ':');
|
|
if ($split !== false) $headers[strtolower(substr($line, 0, $split))] = trim(substr($line, $split + 1));
|
|
}
|
|
$length = (int) ($headers['content-length'] ?? 0);
|
|
if ($length < 0 || $length > 1024 * 1024 || isset($headers['transfer-encoding'])) break;
|
|
$received = 0;
|
|
while ($received < $length && !feof($client)) {
|
|
$chunk = fread($client, min(65536, $length - $received));
|
|
if ($chunk === false || $chunk === '') break;
|
|
$received += strlen($chunk);
|
|
}
|
|
if ($received !== $length) break;
|
|
$upload = str_starts_with($requestLine, 'POST ');
|
|
file_put_contents($fixtureRoot . '/keepalive-requests.jsonl', json_encode([
|
|
'kind' => $upload ? 'upload' : 'download', 'connection' => $connection,
|
|
'authorization' => $headers['authorization'] ?? '',
|
|
]) . "\n", FILE_APPEND | LOCK_EX);
|
|
$body = $upload ? json_encode(['id' => bin2hex(random_bytes(16))])
|
|
: file_get_contents($fixtureRoot . '/public/uploads/first.png');
|
|
$response = "HTTP/1.1 " . ($upload ? '201 Created' : '200 OK') . "\r\n"
|
|
. 'Content-Type: ' . ($upload ? 'application/json' : 'image/png') . "\r\n"
|
|
. 'Content-Length: ' . strlen($body) . "\r\nConnection: keep-alive\r\n\r\n" . $body;
|
|
$sent = 0;
|
|
while ($sent < strlen($response)) {
|
|
$written = fwrite($client, substr($response, $sent));
|
|
if ($written === false || $written === 0) break;
|
|
$sent += $written;
|
|
}
|
|
if ($sent !== strlen($response)) break;
|
|
}
|
|
fclose($client);
|
|
}
|
|
}
|
|
|
|
// This same file is a loopback-only fixture router. It never loads application config.
|
|
if (PHP_SAPI === 'cli-server') {
|
|
$root = (string) getenv('DIFY_ATTACHMENT_FIXTURE_ROOT');
|
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
|
$append = static function (array $entry) use ($root): void {
|
|
file_put_contents($root . '/requests.jsonl', json_encode($entry) . "\n", FILE_APPEND | LOCK_EX);
|
|
};
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && str_ends_with($path, '/files/upload')) {
|
|
$upload = $_FILES['file'] ?? [];
|
|
$temporary = $upload['tmp_name'] ?? '';
|
|
$id = bin2hex(random_bytes(16));
|
|
$append(['kind' => 'upload', 'user' => $_POST['user'] ?? '', 'name' => $upload['name'] ?? '',
|
|
'mime' => $upload['type'] ?? '', 'size' => $upload['size'] ?? 0,
|
|
'hash' => is_file($temporary) ? hash_file('sha256', $temporary) : '', 'id' => $id,
|
|
'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? '', 'path' => $path]);
|
|
if (str_starts_with($path, '/slow-headers/')) usleep(1200000);
|
|
if (str_starts_with($path, '/budget/')) usleep(650000);
|
|
header('Content-Type: application/json');
|
|
if (str_starts_with($path, '/fail/')) { http_response_code(422); echo '{"error":"fixture-private-path-url-key-body"}'; return; }
|
|
if (str_starts_with($path, '/missing-upload/')) { http_response_code(404); echo '{"error":"fixture-private-path-url-key-body"}'; return; }
|
|
if (str_starts_with($path, '/fail-second/')) {
|
|
$entries = array_map(static fn (string $line): array => json_decode($line, true), file($root . '/requests.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
|
|
$uploads = array_filter($entries, static fn (array $entry): bool => ($entry['kind'] ?? '') === 'upload');
|
|
if (count($uploads) === 2) { http_response_code(422); echo '{"error":"fixture-private-path-url-key-body"}'; return; }
|
|
}
|
|
if (str_starts_with($path, '/bad-id/')) { http_response_code(201); echo '{"id":"invalid/id"}'; return; }
|
|
if (str_starts_with($path, '/large-response/')) { http_response_code(201); echo str_repeat('x', 300000); return; }
|
|
http_response_code(201);
|
|
if (str_starts_with($path, '/slow-body/')) { echo '{"id":"'; flush(); usleep(1200000); echo $id . '"}'; return; }
|
|
echo json_encode(['id' => $id]);
|
|
return;
|
|
}
|
|
$append(['kind' => 'download', 'path' => $path, 'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? '']);
|
|
if ($path === '/missing-object') { http_response_code(404); echo '<Error>fixture-private-path-url-key-body</Error>'; return; }
|
|
if ($path === '/gone-object') { http_response_code(410); return; }
|
|
if ($path === '/denied-object') { http_response_code(403); echo '<Error>fixture-private-path-url-key-body</Error>'; return; }
|
|
if ($path === '/broken-object') { http_response_code(500); echo '<Error>fixture-private-path-url-key-body</Error>'; return; }
|
|
if ($path === '/stalled-missing-object') { http_response_code(404); echo '<Error>'; flush(); usleep(1200000); echo '</Error>'; return; }
|
|
if ($path === '/redirect') { header('Location: http://169.254.169.254/latest/meta-data/'); http_response_code(302); echo 'redirect'; return; }
|
|
if ($path === '/oversize-header') { header('Content-Length: 2000000'); echo 'x'; return; }
|
|
if ($path === '/oversize-stream') { echo str_repeat('x', 30000); return; }
|
|
if ($path === '/slow-download') usleep(1200000);
|
|
if ($path === '/budget-download') usleep(650000);
|
|
header('Content-Type: image/png');
|
|
readfile($root . '/public/uploads/first.png');
|
|
return;
|
|
}
|
|
|
|
require dirname(__DIR__) . '/app/common/service/DifyRequiredAttachmentUpload.php';
|
|
use app\common\service\DifyRequiredAttachmentUpload;
|
|
|
|
function attachmentExpect(bool $ok, string $message): void
|
|
{
|
|
if (!$ok) throw new RuntimeException($message);
|
|
}
|
|
function attachmentError(array $result, string $code, string $message): void
|
|
{
|
|
attachmentExpect(empty($result['ok']) && ($result['error_code'] ?? '') === $code && !isset($result['files']),
|
|
$message . ' (' . ($result['error_code'] ?? 'no error') . ')');
|
|
attachmentExpect(!str_contains(json_encode($result), 'fixture-private-path-url-key-body'), 'upstream body never escapes');
|
|
}
|
|
function attachmentTransportClosed(array $events): void
|
|
{
|
|
$pools = []; $closedPools = []; $created = []; $closed = []; $added = []; $active = [];
|
|
foreach ($events as $event) {
|
|
$kind = $event['kind'];
|
|
if ($kind === 'pool_init') { $pools[] = $event['pool']; $active[$event['pool']] = []; }
|
|
if ($kind === 'easy_init') $created[] = $event['easy'];
|
|
if ($kind === 'easy_close') {
|
|
foreach ($active as $handles) attachmentExpect(!isset($handles[$event['easy']]), 'easy handle is removed before close');
|
|
$closed[] = $event['easy'];
|
|
}
|
|
if ($kind === 'add' && $event['result'] === CURLM_OK) {
|
|
attachmentExpect(!isset($added[$event['easy']]), 'every transfer uses a fresh easy handle');
|
|
$added[$event['easy']] = true;
|
|
$active[$event['pool']][$event['easy']] = true;
|
|
attachmentExpect(count($active[$event['pool']]) === 1, 'attachment transfers remain sequential');
|
|
}
|
|
if ($kind === 'remove') {
|
|
attachmentExpect($event['result'] === CURLM_OK && isset($active[$event['pool']][$event['easy']]), 'remove exactly the added easy handle');
|
|
unset($active[$event['pool']][$event['easy']]);
|
|
}
|
|
if ($kind === 'pool_close') {
|
|
attachmentExpect(($active[$event['pool']] ?? null) === [], 'pool closes after every easy handle is detached');
|
|
$closedPools[] = $event['pool'];
|
|
}
|
|
}
|
|
attachmentExpect(count($pools) <= 1 && $pools === $closedPools, 'one request-local pool is closed on every terminal path');
|
|
attachmentExpect($created === $closed, 'all transfer easy handles close on every terminal path');
|
|
}
|
|
function attachmentRemoveTree(string $path, string $workspace): void
|
|
{
|
|
$resolved = realpath($path); $root = realpath($workspace);
|
|
if ($resolved === false || $root === false || ($resolved !== $root && !str_starts_with(str_replace('\\', '/', $resolved), str_replace('\\', '/', $root) . '/'))) {
|
|
throw new RuntimeException('Refusing to clean outside synthetic test workspace');
|
|
}
|
|
foreach (new DirectoryIterator($resolved) as $item) {
|
|
if ($item->isDot()) continue;
|
|
if ($item->isDir() && !$item->isLink()) attachmentRemoveTree($item->getPathname(), $workspace);
|
|
else unlink($item->getPathname());
|
|
}
|
|
rmdir($resolved);
|
|
}
|
|
|
|
$root = sys_get_temp_dir() . '/dify-required-synthetic-' . bin2hex(random_bytes(8));
|
|
mkdir($root . '/public/uploads/nested', 0700, true);
|
|
mkdir($root . '/private', 0700);
|
|
mkdir($root . '/public/uploads-evil', 0700);
|
|
$image = imagecreatetruecolor(64, 64);
|
|
imagefill($image, 0, 0, imagecolorallocate($image, 50, 130, 180));
|
|
imagepng($image, $root . '/public/uploads/first.png');
|
|
imagefill($image, 0, 0, imagecolorallocate($image, 180, 130, 50));
|
|
imagepng($image, $root . '/public/uploads/nested/second.png');
|
|
imagedestroy($image);
|
|
file_put_contents($root . '/public/uploads/report.pdf', "%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\n%%EOF\n");
|
|
file_put_contents($root . '/public/uploads/audio.wav', 'RIFF' . pack('V', 38) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 16000, 2, 16) . 'data' . pack('V', 2) . "\0\0");
|
|
file_put_contents($root . '/public/uploads/large.txt', str_repeat('x', 30000));
|
|
file_put_contents($root . '/private/do-not-read.txt', 'synthetic-private-file');
|
|
file_put_contents($root . '/public/uploads-evil/escape.txt', 'synthetic-private-file');
|
|
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
|
|
attachmentExpect(is_resource($socket), 'allocate loopback fixture port');
|
|
$address = stream_socket_get_name($socket, false);
|
|
fclose($socket);
|
|
$port = (int) substr($address, strrpos($address, ':') + 1);
|
|
$attachmentFixturePort = $port;
|
|
$attachmentCurlOptions = [];
|
|
$attachmentNativeDnsCalls = [];
|
|
$attachmentTransportHandles = new WeakMap();
|
|
$attachmentTransportSequence = 0;
|
|
$attachmentTransportEvents = [];
|
|
$attachmentFailMultiAdd = false;
|
|
$process = proc_open([PHP_BINARY, '-S', '127.0.0.1:' . $port, __FILE__],
|
|
[['pipe', 'r'], ['file', $root . '/fixture-output.log', 'ab'], ['file', $root . '/fixture-error.log', 'ab']],
|
|
$pipes, $root, ['DIFY_ATTACHMENT_FIXTURE_ROOT' => $root,
|
|
'SystemRoot' => (string) getenv('SystemRoot'), 'TEMP' => (string) getenv('TEMP'), 'TMP' => (string) getenv('TMP')], ['bypass_shell' => true]);
|
|
try {
|
|
attachmentExpect(is_resource($process), 'start loopback fixture');
|
|
if (isset($pipes[0]) && is_resource($pipes[0])) fclose($pipes[0]);
|
|
$ready = false;
|
|
for ($attempt = 0; $attempt < 150; ++$attempt) {
|
|
$probe = @fsockopen('127.0.0.1', $port, $errno, $error, 0.02);
|
|
if (is_resource($probe)) { fclose($probe); $ready = true; break; }
|
|
usleep(20000);
|
|
}
|
|
attachmentExpect($ready, 'loopback fixture becomes ready');
|
|
$endpoint = 'http://127.0.0.1:' . $port . '/v1/chat-messages';
|
|
$options = ['local_origins' => ['https://site.synthetic.test', 'https://admin.synthetic.test'],
|
|
'storage_origins' => ['https://storage.synthetic.test'], 'public_root' => $root . '/public',
|
|
'temp_root' => $root . '/private/uploads', 'max_file_bytes' => 20000,
|
|
'resolve_host' => static fn (string $host): array => ['8.8.8.8', '2606:4700:4700::1111']];
|
|
$files = [
|
|
['type' => 'image', 'transfer_method' => 'remote_url', 'url' => 'https://site.synthetic.test/uploads/first.png'],
|
|
['type' => 'image', 'url' => 'https://admin.synthetic.test/uploads/nested/second.png'],
|
|
['type' => 'document', 'url' => 'https://site.synthetic.test/uploads/report.pdf'],
|
|
];
|
|
$requests = static function () use ($root): array {
|
|
if (!is_file($root . '/requests.jsonl')) return [];
|
|
return array_map(static fn (string $line): array => json_decode($line, true), file($root . '/requests.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
|
|
};
|
|
$clear = static function () use ($root, &$attachmentCurlOptions, &$attachmentNativeDnsCalls): void {
|
|
file_put_contents($root . '/requests.jsonl', ''); $attachmentCurlOptions = []; $attachmentNativeDnsCalls = [];
|
|
};
|
|
$clean = static function () use ($root): void {
|
|
attachmentExpect((glob($root . '/private/uploads/required-*') ?: []) === [], 'temporary files are removed after every terminal outcome');
|
|
};
|
|
$run = static function (array $input, array $extra = [], ?callable $abort = null, string $user = 'synthetic-user', ?string $target = null, int $timeout = 5) use ($endpoint, $options, &$attachmentTransportEvents, $attachmentTransportHandles): array {
|
|
$eventOffset = count($attachmentTransportEvents);
|
|
try {
|
|
return DifyRequiredAttachmentUpload::prepare($target ?? $endpoint, 'fixture-only-key', $user, $input, $timeout, $abort, array_replace($options, $extra));
|
|
} finally {
|
|
attachmentTransportClosed(array_slice($attachmentTransportEvents, $eventOffset));
|
|
attachmentExpect(count($attachmentTransportHandles) === 0, 'request retains no easy handles or pools after prepare returns');
|
|
}
|
|
};
|
|
|
|
$clear(); $events = [];
|
|
$result = $run(array_merge($files, [
|
|
['type' => 'video', 'url' => 'https://untrusted.invalid/record.mp4'],
|
|
['type' => 'document', 'url' => 'https://untrusted.invalid/record.MOV'],
|
|
['type' => 'image', 'url' => 'https://untrusted.invalid/opaque', 'mime_type' => 'video/webm'],
|
|
]), ['on_upload' => static function (array $event) use (&$events): void { $events[] = $event; }]);
|
|
attachmentExpect(!empty($result['ok']) && count($result['files']) === 3 && $result['attachment_transport'] === 'uploaded_files', 'two images and PDF all become local_file references');
|
|
attachmentExpect(array_column($result['files'], 'type') === ['image', 'image', 'document']
|
|
&& array_unique(array_column($result['files'], 'transfer_method')) === ['local_file'], 'types and attachment order preserved');
|
|
attachmentExpect($result['uploaded_indices'] === [1, 2, 3] && $result['skipped'] === [], 'unchanged uploads report original indices and no skipped attachments');
|
|
attachmentExpect(count($requests()) === 3 && array_unique(array_column($requests(), 'user')) === ['synthetic-user'], 'every multipart upload has the exact chat user');
|
|
attachmentExpect(array_column($requests(), 'hash') === [hash_file('sha256', $root . '/public/uploads/first.png'),
|
|
hash_file('sha256', $root . '/public/uploads/nested/second.png'), hash_file('sha256', $root . '/public/uploads/report.pdf')], 'multipart contains complete binary bytes');
|
|
attachmentExpect(count($events) === 6 && array_column($events, 'status') === ['uploading', 'uploaded', 'uploading', 'uploaded', 'uploading', 'uploaded'], 'safe one-based progress follows each actual upload');
|
|
foreach ($events as $event) attachmentExpect(array_keys($event) === ['index', 'total', 'status'] && $event['total'] === 6, 'progress contains safe original counts/status only');
|
|
foreach ($attachmentCurlOptions as $curlOptions) {
|
|
$temporary = $curlOptions[CURLOPT_POSTFIELDS]['file']->getFilename();
|
|
attachmentExpect(str_starts_with(str_replace('\\', '/', $temporary), str_replace('\\', '/', $root) . '/private/uploads/') && !is_file($temporary), 'CURLFile uses cleaned nonpublic snapshot');
|
|
attachmentExpect($curlOptions[CURLOPT_SSL_VERIFYPEER] === true && $curlOptions[CURLOPT_SSL_VERIFYHOST] === 2 && $curlOptions[CURLOPT_PROXY] === '', 'transport verifies TLS and ignores environment proxies');
|
|
}
|
|
$clean();
|
|
|
|
$keepaliveProcess = null;
|
|
$originalFixturePort = $attachmentFixturePort;
|
|
try {
|
|
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error);
|
|
attachmentExpect(is_resource($socket), 'allocate keep-alive fixture port');
|
|
$address = stream_socket_get_name($socket, false);
|
|
fclose($socket);
|
|
$keepalivePort = (int) substr($address, strrpos($address, ':') + 1);
|
|
$keepaliveProcess = proc_open([PHP_BINARY, __FILE__, '--keepalive-fixture', (string) $keepalivePort],
|
|
[['pipe', 'r'], ['file', $root . '/keepalive-output.log', 'ab'], ['file', $root . '/keepalive-error.log', 'ab']],
|
|
$keepalivePipes, $root, ['DIFY_ATTACHMENT_FIXTURE_ROOT' => $root,
|
|
'SystemRoot' => (string) getenv('SystemRoot'), 'TEMP' => (string) getenv('TEMP'), 'TMP' => (string) getenv('TMP')], ['bypass_shell' => true]);
|
|
attachmentExpect(is_resource($keepaliveProcess), 'start keep-alive fixture');
|
|
if (isset($keepalivePipes[0]) && is_resource($keepalivePipes[0])) fclose($keepalivePipes[0]);
|
|
$ready = false;
|
|
for ($attempt = 0; $attempt < 150; ++$attempt) {
|
|
$probe = @fsockopen('127.0.0.1', $keepalivePort, $errno, $error, 0.02);
|
|
if (is_resource($probe)) { fclose($probe); $ready = true; break; }
|
|
usleep(20000);
|
|
}
|
|
attachmentExpect($ready, 'keep-alive fixture becomes ready');
|
|
$attachmentFixturePort = $keepalivePort;
|
|
$keepaliveEndpoint = 'http://127.0.0.1:' . $keepalivePort . '/v1/chat-messages';
|
|
$eventOffset = count($attachmentTransportEvents);
|
|
$reused = $run([
|
|
['type' => 'image', 'url' => 'https://storage.synthetic.test/first.png'],
|
|
['type' => 'image', 'url' => 'https://storage.synthetic.test/second.png'],
|
|
], [], null, 'synthetic-user', $keepaliveEndpoint);
|
|
attachmentExpect(!empty($reused['ok']) && count($reused['files']) === 2, 'keep-alive path retains both complete image uploads');
|
|
$reuseEvents = array_slice($attachmentTransportEvents, $eventOffset);
|
|
$reusePools = array_values(array_filter($reuseEvents, static fn (array $event): bool => $event['kind'] === 'pool_init'));
|
|
attachmentExpect(count($reusePools) === 1
|
|
&& count(array_filter($reuseEvents, static fn (array $event): bool => $event['kind'] === 'easy_init')) === 4,
|
|
'two sequential downloads and uploads share one pool with four fresh easy handles');
|
|
$keepaliveRequests = array_map(static fn (string $line): array => json_decode($line, true),
|
|
file($root . '/keepalive-requests.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
|
|
attachmentExpect(array_column($keepaliveRequests, 'kind') === ['download', 'upload', 'download', 'upload']
|
|
&& count(array_unique(array_column($keepaliveRequests, 'connection'))) === 1,
|
|
'libcurl really reuses one HTTP connection across all four transfers');
|
|
attachmentExpect(array_column($keepaliveRequests, 'authorization') === ['', 'Bearer fixture-only-key', '', 'Bearer fixture-only-key'],
|
|
'fresh download handles never inherit upload authorization on a reused connection');
|
|
|
|
$eventOffset = count($attachmentTransportEvents);
|
|
$nextPreparation = $run(array_slice($files, 0, 2), [], null, 'synthetic-user', $keepaliveEndpoint);
|
|
attachmentExpect(!empty($nextPreparation['ok']), 'next preparation uploads independently');
|
|
$nextPools = array_values(array_filter(array_slice($attachmentTransportEvents, $eventOffset),
|
|
static fn (array $event): bool => $event['kind'] === 'pool_init'));
|
|
$keepaliveRequests = array_map(static fn (string $line): array => json_decode($line, true),
|
|
file($root . '/keepalive-requests.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
|
|
attachmentExpect(count($nextPools) === 1 && $nextPools[0]['pool'] !== $reusePools[0]['pool']
|
|
&& count($keepaliveRequests) === 6
|
|
&& $keepaliveRequests[4]['connection'] !== $keepaliveRequests[0]['connection']
|
|
&& $keepaliveRequests[4]['connection'] === $keepaliveRequests[5]['connection'],
|
|
'even same-user next preparation gets a new pool and connection, then reuses it locally');
|
|
$clean();
|
|
} finally {
|
|
$attachmentFixturePort = $originalFixturePort;
|
|
if (is_resource($keepaliveProcess)) { proc_terminate($keepaliveProcess); proc_close($keepaliveProcess); }
|
|
}
|
|
|
|
$clear();
|
|
$duplicates = $run([$files[0], $files[0]]);
|
|
attachmentExpect(!empty($duplicates['ok']) && count($duplicates['files']) === 2 && count($requests()) === 1
|
|
&& $duplicates['files'][0]['upload_file_id'] === $duplicates['files'][1]['upload_file_id'], 'same request reuses ID while preserving both references');
|
|
$otherUser = $run([$files[0]], [], null, 'another-synthetic-user');
|
|
attachmentExpect(!empty($otherUser['ok']) && count($requests()) === 2
|
|
&& $otherUser['files'][0]['upload_file_id'] !== $duplicates['files'][0]['upload_file_id']
|
|
&& $requests()[1]['user'] === 'another-synthetic-user', 'no upload IDs or cache cross user boundaries');
|
|
$sameUserNextRequest = $run([$files[0]]);
|
|
attachmentExpect(!empty($sameUserNextRequest['ok']) && count($requests()) === 3
|
|
&& $sameUserNextRequest['files'][0]['upload_file_id'] !== $duplicates['files'][0]['upload_file_id'], 'even same-user requests have no global ID cache');
|
|
$clean();
|
|
$clear();
|
|
$audio = $run([['type' => 'audio', 'url' => 'https://site.synthetic.test/uploads/audio.wav']]);
|
|
attachmentExpect(!empty($audio['ok']) && $audio['files'][0]['type'] === 'audio' && $requests()[0]['mime'] === 'audio/x-wav', 'audio bytes retain audio type');
|
|
$clean();
|
|
|
|
$clear();
|
|
$source = static fn (string $url): array => [['type' => 'document', 'url' => $url]];
|
|
foreach ([
|
|
'https://evil.synthetic.test/uploads/first.png',
|
|
'https://site.synthetic.test.evil.invalid/uploads/first.png',
|
|
'http://site.synthetic.test/uploads/first.png',
|
|
'https://site.synthetic.test:444/uploads/first.png',
|
|
] as $url) attachmentError($run($source($url)), 'REQUIRED_FILES_SOURCE_UNTRUSTED', 'exact origin prevents alias/scheme/port bypass');
|
|
foreach ([
|
|
'https://site.synthetic.test/uploads/../private/do-not-read.txt',
|
|
'https://site.synthetic.test/uploads/%2e%2e/private/do-not-read.txt',
|
|
'https://site.synthetic.test/uploads/%252e%252e/private/do-not-read.txt',
|
|
'https://site.synthetic.test/uploads/nested%2fsecond.png',
|
|
'https://site.synthetic.test/uploads/nested%5csecond.png',
|
|
'https://site.synthetic.test/uploads//first.png',
|
|
'https://site.synthetic.test/uploads/first.png::$DATA',
|
|
'https://site.synthetic.test/uploads/first.png.',
|
|
'https://site.synthetic.test/uploads/nested/missing/../../private.txt',
|
|
'https://site.synthetic.test/uploads/nested/missing/%2e%2e/private.txt',
|
|
'https://site.synthetic.test/uploads/nested/missing/con.txt',
|
|
'https://site.synthetic.test/uploads/nested/missing/%3Cinvalid%3E.png',
|
|
'https://site.synthetic.test/uploads/nested/missing/file%2A.png',
|
|
'https://site.synthetic.test/uploads-evil/escape.txt',
|
|
'https://site.synthetic.test/private/do-not-read.txt',
|
|
'https://site.synthetic.test@evil.invalid/uploads/first.png',
|
|
'https://site.synthetic.test/uploads/first.png#fragment',
|
|
'file:///private/do-not-read.txt',
|
|
'/uploads/first.png',
|
|
] as $url) attachmentError($run($source($url)), 'REQUIRED_FILES_INVALID', 'local path/URL bypass rejected before network');
|
|
$localMissingEvents = [];
|
|
$localMissing = $run($source('https://site.synthetic.test/uploads/missing.png'),
|
|
['on_upload' => static function (array $event) use (&$localMissingEvents): void { $localMissingEvents[] = $event; }]);
|
|
attachmentExpect(!empty($localMissing['ok']) && $localMissing['files'] === [] && $localMissing['uploaded_indices'] === []
|
|
&& $localMissing['skipped'] === [['index' => 1, 'kind' => 'document', 'reason' => 'not_found']], 'confirmed missing local file is skipped for a text-only response');
|
|
attachmentExpect($localMissingEvents === [['index' => 1, 'total' => 1, 'status' => 'skipped', 'reason' => 'not_found']], 'local preflight absence emits only safe skipped progress');
|
|
$missingParent = $run($source('https://site.synthetic.test/uploads/missing-parent/deeper/file.png'));
|
|
attachmentExpect(!empty($missingParent['ok']) && count($missingParent['skipped']) === 1, 'a safely absent parent establishes local absence without a download fallback');
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/missing.png'), ['skip_missing' => false]),
|
|
'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'server can explicitly require every local source');
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/nested')), 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'existing directories remain unreadable attachments rather than missing');
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/first.png/missing.png')),
|
|
'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'non-directory parent cannot establish missing-file status');
|
|
$attachmentDeniedDirectory = realpath($root . '/public/uploads');
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/inaccessible.png')),
|
|
'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'inaccessible directory listing cannot prove a local source is missing');
|
|
$attachmentDeniedDirectory = null;
|
|
attachmentExpect($requests() === [], 'origin/path failures never upload or download');
|
|
if (@symlink($root . '/private/do-not-read.txt', $root . '/public/uploads/link.txt')) {
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/link.txt')), 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'realpath rejects symlink escape');
|
|
}
|
|
if (@symlink($root . '/private', $root . '/public/uploads/outside-parent')) {
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/outside-parent/missing.png')),
|
|
'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'missing file below an escaping symlink remains unsafe');
|
|
}
|
|
if (@symlink($root . '/private/nonexistent.txt', $root . '/public/uploads/broken-link.txt')) {
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/broken-link.txt')),
|
|
'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'broken symlink is not a confirmed absent local file');
|
|
}
|
|
$clean();
|
|
|
|
$clear();
|
|
$localMixed = $run([$source('https://site.synthetic.test/uploads/missing.png')[0], $files[0],
|
|
$source('https://site.synthetic.test/uploads/nested/missing.png')[0], $files[2]]);
|
|
attachmentExpect(!empty($localMixed['ok']) && $localMixed['uploaded_indices'] === [2, 4]
|
|
&& array_column($localMixed['skipped'], 'index') === [1, 3]
|
|
&& array_column($requests(), 'kind') === ['upload', 'upload'], 'local missing-first and missing-middle sources preserve every remaining source without downloads');
|
|
$clean();
|
|
$clear();
|
|
$eventOffset = count($attachmentTransportEvents);
|
|
$allLocalMissing = $run([$source('https://site.synthetic.test/uploads/missing.png')[0],
|
|
$source('https://site.synthetic.test/uploads/nested/missing.png')[0]]);
|
|
attachmentExpect(!empty($allLocalMissing['ok']) && $allLocalMissing['files'] === []
|
|
&& $allLocalMissing['uploaded_indices'] === [] && array_column($allLocalMissing['skipped'], 'index') === [1, 2]
|
|
&& $requests() === [], 'all missing local files return a safe empty upload list for text-only chat');
|
|
attachmentExpect(array_column(array_slice($attachmentTransportEvents, $eventOffset), 'kind') === ['pool_init', 'pool_close'],
|
|
'all missing local sources close the request pool without creating any easy handle');
|
|
$videoEvents = [];
|
|
$videoMixed = $run([['type' => 'video', 'url' => 'https://untrusted.invalid/record.mp4'],
|
|
$source('https://site.synthetic.test/uploads/missing.png')[0], $files[0]],
|
|
['on_upload' => static function (array $event) use (&$videoEvents): void { $videoEvents[] = $event; }]);
|
|
attachmentExpect(!empty($videoMixed['ok']) && $videoMixed['uploaded_indices'] === [3]
|
|
&& array_column($videoMixed['skipped'], 'index') === [2]
|
|
&& array_column($videoEvents, 'index') === [2, 3, 3] && array_unique(array_column($videoEvents, 'total')) === [3],
|
|
'original input positions and total remain accurate when videos are excluded');
|
|
$clean();
|
|
$clear();
|
|
foreach ([['max_file_bytes' => 0], ['temp_root' => $root . '/public/unsafe-temp'], ['skip_missing' => 'true'],
|
|
['ca_file' => []], ['ca_file' => $root . '/missing-ca.pem'],
|
|
['resolved_hosts' => ['storage.synthetic.test' => ['127.0.0.1']]]] as $invalidOptions) {
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/missing.png'), $invalidOptions),
|
|
'CONFIG_INVALID', 'all-missing local input cannot bypass invalid server configuration');
|
|
}
|
|
attachmentError($run([$source('https://site.synthetic.test/uploads/missing.png')[0],
|
|
$source('https://site.synthetic.test/uploads/missing-parent/../private.txt')[0]]),
|
|
'REQUIRED_FILES_INVALID', 'earlier confirmed absence cannot bypass unsafe later path validation');
|
|
attachmentExpect($requests() === [], 'invalid configuration and unsafe paths open no transport even with missing sources');
|
|
$clean();
|
|
$clear();
|
|
$remote = [['type' => 'image', 'url' => 'https://storage.synthetic.test/download.png']];
|
|
$withCa = $run($remote, ['ca_file' => $root . '/public/uploads/report.pdf']);
|
|
attachmentExpect(!empty($withCa['ok']) && count($attachmentCurlOptions) === 2
|
|
&& $attachmentCurlOptions[0][CURLOPT_CAINFO] === $root . '/public/uploads/report.pdf'
|
|
&& $attachmentCurlOptions[1][CURLOPT_CAINFO] === $root . '/public/uploads/report.pdf'
|
|
&& $attachmentCurlOptions[0][CURLOPT_SSL_VERIFYPEER] === true
|
|
&& $attachmentCurlOptions[1][CURLOPT_SSL_VERIFYHOST] === 2,
|
|
'explicit server CA bundle applies to download and upload while certificate verification stays enabled');
|
|
$clear(); $clean();
|
|
$downloaded = $run($remote);
|
|
attachmentExpect(!empty($downloaded['ok']) && count($requests()) === 2 && $requests()[0]['kind'] === 'download'
|
|
&& $requests()[0]['authorization'] === '' && $requests()[1]['authorization'] === 'Bearer fixture-only-key', 'remote bytes upload without sharing Dify key with storage');
|
|
attachmentExpect($attachmentCurlOptions[0][CURLOPT_RESOLVE] === ['storage.synthetic.test:443:8.8.8.8']
|
|
&& $attachmentCurlOptions[0][CURLOPT_FOLLOWLOCATION] === false, 'validated DNS address is pinned and redirect following disabled');
|
|
$clear(); $dnsCalls = 0;
|
|
$rebound = $run($remote, ['resolve_host' => static function () use (&$dnsCalls): array { return [++$dnsCalls === 1 ? '8.8.8.8' : '127.0.0.1']; }]);
|
|
attachmentExpect(!empty($rebound['ok']) && $dnsCalls === 1
|
|
&& $attachmentCurlOptions[0][CURLOPT_RESOLVE] === ['storage.synthetic.test:443:8.8.8.8'], 'a rebound DNS answer cannot replace the pinned validated address');
|
|
$clean();
|
|
|
|
$clear(); $dnsCalls = 0;
|
|
$pinned = $run($remote, ['resolved_hosts' => ['Storage.Synthetic.Test' => ['8.8.8.8', '2606:4700:4700::1111']],
|
|
'resolve_host' => static function () use (&$dnsCalls): array { ++$dnsCalls; return ['198.18.0.1']; }]);
|
|
attachmentExpect(!empty($pinned['ok']) && $dnsCalls === 0 && $attachmentNativeDnsCalls === []
|
|
&& count($requests()) === 2, 'exact server pin bypasses both unsafe callable and native DNS');
|
|
attachmentExpect($attachmentCurlOptions[0][CURLOPT_RESOLVE] === ['storage.synthetic.test:443:8.8.8.8']
|
|
&& $attachmentCurlOptions[0][CURLOPT_URL] === $remote[0]['url']
|
|
&& $attachmentCurlOptions[0][CURLOPT_SSL_VERIFYPEER] === true
|
|
&& $attachmentCurlOptions[0][CURLOPT_SSL_VERIFYHOST] === 2
|
|
&& $attachmentCurlOptions[0][CURLOPT_PROXY] === ''
|
|
&& $attachmentCurlOptions[0][CURLOPT_FOLLOWLOCATION] === false,
|
|
'pins normalize exact hostname and preserve original URL/TLS hostname, no proxies or redirects');
|
|
attachmentExpect(!isset($attachmentCurlOptions[1][CURLOPT_RESOLVE]), 'storage pin never alters the upload endpoint');
|
|
$clean();
|
|
|
|
$clear();
|
|
$missingPinnedSource = $run(array_merge($remote, [['type' => 'image',
|
|
'url' => 'https://storage.synthetic.test/missing-object']]),
|
|
['resolved_hosts' => ['storage.synthetic.test' => ['8.8.8.8']], 'resolve_host' => null]);
|
|
attachmentExpect(!empty($missingPinnedSource['ok']) && count($missingPinnedSource['files']) === 1
|
|
&& $missingPinnedSource['uploaded_indices'] === [1]
|
|
&& $missingPinnedSource['skipped'] === [['index' => 2, 'kind' => 'image', 'reason' => 'not_found']],
|
|
'pinned confirmed missing source preserves the first existing upload');
|
|
attachmentExpect(count($requests()) === 3 && array_column($requests(), 'kind') === ['download', 'upload', 'download']
|
|
&& count($missingPinnedSource['files']) === 1, 'a second-file 404 makes no upload for the missing bytes');
|
|
attachmentExpect($attachmentNativeDnsCalls === [], 'pinned 404 never retries through native DNS');
|
|
$clean();
|
|
|
|
$missingRemote = ['type' => 'image', 'url' => 'https://storage.synthetic.test/missing-object'];
|
|
$goneRemote = ['type' => 'document', 'url' => 'https://storage.synthetic.test/gone-object'];
|
|
foreach ([[[$missingRemote, $files[0], $files[2]], [2, 3], [1]],
|
|
[[$files[0], $missingRemote, $files[2]], [1, 3], [2]],
|
|
[[$missingRemote, $goneRemote], [], [1, 2]]] as [$input, $uploadedIndices, $skippedIndices]) {
|
|
$clear(); $missingEvents = [];
|
|
$mixedMissing = $run($input, ['on_upload' => static function (array $event) use (&$missingEvents): void { $missingEvents[] = $event; }]);
|
|
attachmentExpect(!empty($mixedMissing['ok']) && $mixedMissing['uploaded_indices'] === $uploadedIndices
|
|
&& count($mixedMissing['files']) === count($uploadedIndices)
|
|
&& array_column($mixedMissing['skipped'], 'index') === $skippedIndices,
|
|
'missing-first, missing-middle and all-missing preserve original upload mapping');
|
|
foreach ($mixedMissing['skipped'] as $skip) attachmentExpect(array_keys($skip) === ['index', 'kind', 'reason']
|
|
&& $skip['kind'] === $input[$skip['index'] - 1]['type'] && $skip['reason'] === 'not_found', 'skip metadata contains safe index/kind/reason only');
|
|
foreach ($missingEvents as $event) attachmentExpect($event['total'] === count($input)
|
|
&& array_keys($event) === ($event['status'] === 'skipped' ? ['index', 'total', 'status', 'reason'] : ['index', 'total', 'status']),
|
|
'mixed missing progress preserves manifest total and exposes no source details');
|
|
$uploadedRequests = array_values(array_filter($requests(), static fn (array $entry): bool => $entry['kind'] === 'upload'));
|
|
foreach ($uploadedIndices as $position => $originalIndex) {
|
|
attachmentExpect($mixedMissing['files'][$position]['upload_file_id'] === $uploadedRequests[$position]['id']
|
|
&& $mixedMissing['files'][$position]['type'] === $input[$originalIndex - 1]['type'], 'remaining references match the correct original attachment bytes and type');
|
|
}
|
|
$clean();
|
|
}
|
|
$clear();
|
|
$duplicatesWithMissing = $run([$missingRemote, $files[0], $missingRemote, $files[0], $goneRemote]);
|
|
attachmentExpect(!empty($duplicatesWithMissing['ok']) && $duplicatesWithMissing['uploaded_indices'] === [2, 4]
|
|
&& array_column($duplicatesWithMissing['skipped'], 'index') === [1, 3, 5]
|
|
&& $duplicatesWithMissing['files'][0]['upload_file_id'] === $duplicatesWithMissing['files'][1]['upload_file_id']
|
|
&& array_column($requests(), 'kind') === ['download', 'upload', 'download'], 'duplicate missing sources are fetched once and every original position is retained deterministically');
|
|
$clean();
|
|
$clear();
|
|
attachmentError($run([$missingRemote], ['skip_missing' => false]), 'REQUIRED_FILES_DOWNLOAD_FAILED', 'server can explicitly retain strict remote missing-file policy');
|
|
foreach (['/denied-object', '/broken-object'] as $path) {
|
|
$clear();
|
|
attachmentError($run([['type' => 'image', 'url' => 'https://storage.synthetic.test' . $path]]),
|
|
'REQUIRED_FILES_DOWNLOAD_FAILED', '403 and 500 cannot be skipped as absent attachments');
|
|
attachmentExpect(array_column($requests(), 'kind') === ['download'], 'non-missing remote error is never uploaded');
|
|
$clean();
|
|
}
|
|
$clear(); $start = microtime(true);
|
|
attachmentError($run([['type' => 'image', 'url' => 'https://storage.synthetic.test/stalled-missing-object']], [], null,
|
|
'synthetic-user', null, 1), 'REQUIRED_FILES_TIMEOUT', '404 headers followed by a stalled transfer remain a timeout');
|
|
attachmentExpect(microtime(true) - $start < 1.4 && array_column($requests(), 'kind') === ['download'], 'incomplete 404 response is never accepted as confirmed missing');
|
|
$clean(); usleep(300000);
|
|
|
|
$clear();
|
|
$pinnedV6 = $run($remote, ['resolved_hosts' => ['storage.synthetic.test' => ['2606:4700:4700::1111']], 'resolve_host' => null]);
|
|
attachmentExpect(!empty($pinnedV6['ok']) && $attachmentNativeDnsCalls === []
|
|
&& $attachmentCurlOptions[0][CURLOPT_RESOLVE] === ['storage.synthetic.test:443:[2606:4700:4700::1111]'],
|
|
'validated global IPv6 pin uses bracketed CURLOPT_RESOLVE without DNS');
|
|
$clean();
|
|
|
|
$clear(); $dnsCalls = 0;
|
|
$unpinned = $run($remote, ['resolved_hosts' => ['other.synthetic.test' => ['1.1.1.1']],
|
|
'resolve_host' => static function () use (&$dnsCalls): array { ++$dnsCalls; return ['8.8.8.8']; }]);
|
|
attachmentExpect(!empty($unpinned['ok']) && $dnsCalls === 1 && $attachmentNativeDnsCalls === [],
|
|
'pin for another hostname does not replace the supported unpinned resolver');
|
|
$clean();
|
|
|
|
$clear();
|
|
attachmentError($run($remote, ['resolve_host' => null]), 'REQUIRED_FILES_SOURCE_UNTRUSTED',
|
|
'without a pin the unsafe native proxy-DNS range remains forbidden');
|
|
attachmentExpect($attachmentNativeDnsCalls === ['storage.synthetic.test'] && $requests() === []
|
|
&& $attachmentCurlOptions === [], 'unsafe native DNS opens no connection');
|
|
$clear();
|
|
attachmentError($run($remote, ['storage_origins' => [],
|
|
'resolved_hosts' => ['storage.synthetic.test' => ['8.8.8.8']]]), 'REQUIRED_FILES_SOURCE_UNTRUSTED',
|
|
'a DNS pin cannot grant access to an untrusted storage origin');
|
|
attachmentExpect($requests() === [] && $attachmentNativeDnsCalls === [], 'origin check precedes pinned download');
|
|
$clean();
|
|
|
|
foreach ([
|
|
null, false, 'not-a-map', [['8.8.8.8']],
|
|
['storage.synthetic.test' => []],
|
|
['storage.synthetic.test' => '8.8.8.8'],
|
|
['storage.synthetic.test' => ['address' => '8.8.8.8']],
|
|
['storage.synthetic.test' => [8]],
|
|
['storage.synthetic.test' => ['127.0.0.1']],
|
|
['storage.synthetic.test' => ['198.18.0.1']],
|
|
['storage.synthetic.test' => ['8.8.8.8', '169.254.169.254']],
|
|
['storage.synthetic.test' => ['2606:4700:4700::1111', '::1']],
|
|
['storage.synthetic.test' => [' 8.8.8.8']],
|
|
['storage.synthetic.test' => ['not-an-address']],
|
|
['https://storage.synthetic.test' => ['8.8.8.8']],
|
|
['storage.synthetic.test:443' => ['8.8.8.8']],
|
|
['storage.synthetic.test/path' => ['8.8.8.8']],
|
|
['*.synthetic.test' => ['8.8.8.8']],
|
|
['storage.synthetic.test.' => ['8.8.8.8']],
|
|
[' storage.synthetic.test' => ['8.8.8.8']],
|
|
['storage..synthetic.test' => ['8.8.8.8']],
|
|
['-storage.synthetic.test' => ['8.8.8.8']],
|
|
[str_repeat('a', 64) . '.synthetic.test' => ['8.8.8.8']],
|
|
['8.8.8.8' => ['1.1.1.1']],
|
|
['0x7f000001' => ['8.8.8.8']],
|
|
['storage.synthetic.test' => ['8.8.8.8'], 'Storage.Synthetic.Test' => ['1.1.1.1']],
|
|
] as $pins) {
|
|
$clear(); $dnsCalls = 0;
|
|
$invalidPin = $run($remote, ['resolved_hosts' => $pins,
|
|
'resolve_host' => static function () use (&$dnsCalls): array { ++$dnsCalls; return ['8.8.8.8']; }]);
|
|
attachmentError($invalidPin, 'CONFIG_INVALID', 'malformed/private/mixed/empty pins fail closed');
|
|
attachmentExpect($dnsCalls === 0 && $attachmentNativeDnsCalls === [] && $requests() === []
|
|
&& $attachmentCurlOptions === [], 'invalid pins never fall back to either DNS resolver or open transport');
|
|
attachmentExpect(!str_contains(json_encode($invalidPin), 'storage.synthetic.test')
|
|
&& !str_contains(json_encode($invalidPin), '8.8.8.8'), 'invalid pin response never discloses configured host/IP');
|
|
$clean();
|
|
}
|
|
|
|
// Exercise the real config decoder with synthetic INI text, never a .env file.
|
|
$attachmentSyntheticEnv = [];
|
|
$defaultConfig = require dirname(__DIR__) . '/config/prescription_ai.php';
|
|
attachmentExpect($defaultConfig['required_attachment_upload']['resolved_hosts'] === [], 'DNS pins default to an empty map');
|
|
$syntheticIni = "[PRESCRIPTION_AI]\nREQUIRED_ATTACHMENT_RESOLVED_HOSTS = {\"storage.synthetic.test\":[\"8.8.8.8\"]}\n";
|
|
$parsedIni = parse_ini_string($syntheticIni, true, INI_SCANNER_RAW);
|
|
$attachmentSyntheticEnv = ['prescription_ai.REQUIRED_ATTACHMENT_RESOLVED_HOSTS'
|
|
=> $parsedIni['PRESCRIPTION_AI']['REQUIRED_ATTACHMENT_RESOLVED_HOSTS']];
|
|
$pinConfig = require dirname(__DIR__) . '/config/prescription_ai.php';
|
|
$clear();
|
|
$configuredPin = $run($remote, ['resolved_hosts' => $pinConfig['required_attachment_upload']['resolved_hosts'], 'resolve_host' => null]);
|
|
attachmentExpect(!empty($configuredPin['ok']) && $attachmentNativeDnsCalls === []
|
|
&& $attachmentCurlOptions[0][CURLOPT_RESOLVE] === ['storage.synthetic.test:443:8.8.8.8'],
|
|
'documented unquoted JSON INI syntax decodes to a working exact pin');
|
|
$clean();
|
|
foreach (['[', 'null', '[]', 'true', '"not-a-map"', '{"storage.synthetic.test":{}}', '{"storage.synthetic.test":[]}'] as $json) {
|
|
$attachmentSyntheticEnv = ['prescription_ai.REQUIRED_ATTACHMENT_RESOLVED_HOSTS' => $json];
|
|
$invalidConfig = require dirname(__DIR__) . '/config/prescription_ai.php';
|
|
$clear();
|
|
attachmentError($run($remote, ['resolved_hosts' => $invalidConfig['required_attachment_upload']['resolved_hosts'],
|
|
'resolve_host' => null]), 'CONFIG_INVALID', 'invalid JSON/map environment configuration cannot disappear into default DNS');
|
|
attachmentExpect($attachmentNativeDnsCalls === [] && $requests() === [], 'invalid environment pin config never performs DNS/network fallback');
|
|
$clean();
|
|
}
|
|
$attachmentSyntheticEnv = [];
|
|
|
|
foreach (['127.0.0.1', '10.0.0.1', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.100.100.200',
|
|
'0.0.0.0', '224.0.0.1', '::1', 'fc00::1', 'fe80::1', '::ffff:127.0.0.1', '64:ff9b::7f00:1', '2002:7f00:1::', '2001:db8::1'] as $ip) {
|
|
$clear();
|
|
attachmentError($run($remote, ['resolve_host' => static fn (): array => [$ip]]), 'REQUIRED_FILES_SOURCE_UNTRUSTED', 'private/metadata/transition DNS addresses rejected');
|
|
attachmentExpect($requests() === [] && $attachmentCurlOptions === [], 'unsafe DNS never opens a connection');
|
|
}
|
|
$clear();
|
|
attachmentError($run($remote, ['resolve_host' => static fn (): array => ['8.8.8.8', '127.0.0.1']]), 'REQUIRED_FILES_SOURCE_UNTRUSTED', 'mixed public/private DNS is rejected in full');
|
|
attachmentError($run($remote, ['resolve_host' => static fn (): array => []]), 'REQUIRED_FILES_DOWNLOAD_FAILED', 'unresolvable storage fails explicitly');
|
|
foreach (['https://storage.synthetic.test/redirect', 'https://storage.synthetic.test/oversize-header', 'https://storage.synthetic.test/oversize-stream'] as $url) {
|
|
$clear();
|
|
attachmentError($run($source($url)), str_ends_with($url, '/redirect') ? 'REQUIRED_FILES_DOWNLOAD_FAILED' : 'REQUIRED_FILES_TOO_LARGE', 'redirect/oversize downloads fail without dropping mandatory file');
|
|
attachmentExpect(count($requests()) === 1 && $requests()[0]['kind'] === 'download', 'redirect is never followed and bad download is never uploaded');
|
|
$clean();
|
|
}
|
|
$clear();
|
|
attachmentError($run($source('https://site.synthetic.test/uploads/large.txt')), 'REQUIRED_FILES_TOO_LARGE', 'local file copy has the same byte limit');
|
|
attachmentExpect($requests() === [], 'oversize local file never uploads');
|
|
$clean();
|
|
|
|
foreach (['fail', 'missing-upload', 'bad-id', 'large-response'] as $mode) {
|
|
$clear(); $failedEvents = [];
|
|
$failed = $run($files, ['on_upload' => static function (array $event) use (&$failedEvents): void { $failedEvents[] = $event; }], null, 'synthetic-user',
|
|
'http://127.0.0.1:' . $port . '/' . $mode . '/v1/chat-messages');
|
|
attachmentError($failed, 'REQUIRED_FILES_UPLOAD_FAILED', 'provider upload error never returns a partial attachment list');
|
|
attachmentExpect(count($requests()) === 1 && $failedEvents[count($failedEvents) - 1]['status'] === 'failed', 'upload failure stops before a model request and marks safe progress failed');
|
|
$clean();
|
|
}
|
|
$clear();
|
|
$midway = $run($files, [], null, 'synthetic-user', 'http://127.0.0.1:' . $port . '/fail-second/v1/chat-messages');
|
|
attachmentError($midway, 'REQUIRED_FILES_UPLOAD_FAILED', 'failure after a successful upload still returns no partial files');
|
|
attachmentExpect(count($requests()) === 2 && $midway['failed_index'] === 2 && $midway['attachment_count'] === 3, 'failure identifies safe index and never skips ahead to remaining attachment');
|
|
$clean();
|
|
$clear();
|
|
$failedAfterSkip = $run([$missingRemote, $files[0], $files[2]], [], null,
|
|
'synthetic-user', 'http://127.0.0.1:' . $port . '/fail/v1/chat-messages');
|
|
attachmentError($failedAfterSkip, 'REQUIRED_FILES_UPLOAD_FAILED', 'upload error after a confirmed absence still fails without partial files');
|
|
attachmentExpect($failedAfterSkip['skipped'] === [['index' => 1, 'kind' => 'image', 'reason' => 'not_found']]
|
|
&& $failedAfterSkip['failed_index'] === 2 && $failedAfterSkip['attachment_count'] === 3,
|
|
'fatal response preserves safe skipped metadata and the actual failed index');
|
|
$clean();
|
|
$clear();
|
|
attachmentError($run($files, [], static fn (): bool => true), 'AI_ASSISTANT_CANCELLED', 'cancellation before preparation opens no transport');
|
|
attachmentExpect($requests() === [], 'early cancellation has no network side effects');
|
|
$cancelDuringCopy = false;
|
|
attachmentError($run([$files[0]], ['on_upload' => static function () use (&$cancelDuringCopy): void { $cancelDuringCopy = true; }],
|
|
static function () use (&$cancelDuringCopy): bool { return $cancelDuringCopy; }), 'AI_ASSISTANT_CANCELLED', 'cancellation during local copy cleans its new temporary file');
|
|
attachmentExpect($requests() === [], 'cancelled local preparation never uploads');
|
|
$clean();
|
|
$clear(); $cancelAfterSkip = false; $cancelledSkipEvents = [];
|
|
$cancelledAfterSkip = $run([$missingRemote, $files[0]], ['on_upload' => static function (array $event) use (&$cancelAfterSkip, &$cancelledSkipEvents): void {
|
|
$cancelledSkipEvents[] = $event;
|
|
if ($event['status'] === 'skipped') $cancelAfterSkip = true;
|
|
}], static function () use (&$cancelAfterSkip): bool { return $cancelAfterSkip; });
|
|
attachmentError($cancelledAfterSkip, 'AI_ASSISTANT_CANCELLED', 'cancellation immediately after skipped progress cannot continue into remaining uploads');
|
|
attachmentExpect(array_column($requests(), 'kind') === ['download']
|
|
&& $cancelledAfterSkip['skipped'] === [['index' => 1, 'kind' => 'image', 'reason' => 'not_found']]
|
|
&& array_column($cancelledSkipEvents, 'status') === ['uploading', 'skipped'],
|
|
'cancelled request retains known skipped source and opens no subsequent upload');
|
|
$clean();
|
|
foreach (['slow-headers', 'slow-body'] as $mode) {
|
|
$clear(); $start = microtime(true);
|
|
$cancelled = $run([$files[0]], [], static fn (): bool => microtime(true) - $start > 0.15, 'synthetic-user',
|
|
'http://127.0.0.1:' . $port . '/' . $mode . '/v1/chat-messages');
|
|
attachmentError($cancelled, 'AI_ASSISTANT_CANCELLED', 'cancellation interrupts upload response ' . $mode);
|
|
attachmentExpect(microtime(true) - $start < 0.7, 'curl_multi observes cancellation while waiting for headers/body');
|
|
$clean(); usleep(1250000);
|
|
}
|
|
$clear(); $start = microtime(true);
|
|
attachmentError($run($source('https://storage.synthetic.test/slow-download'), [], static fn (): bool => microtime(true) - $start > 0.15),
|
|
'AI_ASSISTANT_CANCELLED', 'cancellation interrupts remote download header wait');
|
|
attachmentExpect(microtime(true) - $start < 0.7, 'download cancellation does not wait for its full timeout');
|
|
$clean(); usleep(1250000);
|
|
$clear(); $start = microtime(true);
|
|
$budget = $run(array_slice($files, 0, 2), [], null, 'synthetic-user', 'http://127.0.0.1:' . $port . '/budget/v1/chat-messages', 1);
|
|
attachmentError($budget, 'REQUIRED_FILES_TIMEOUT', 'multiple uploads share one deadline');
|
|
attachmentExpect(microtime(true) - $start < 1.4 && count($requests()) === 2, 'second upload receives only remaining request budget');
|
|
$clean(); usleep(700000);
|
|
$clear(); $start = microtime(true);
|
|
$combinedBudget = $run($source('https://storage.synthetic.test/budget-download'), [], null, 'synthetic-user',
|
|
'http://127.0.0.1:' . $port . '/budget/v1/chat-messages', 1);
|
|
attachmentError($combinedBudget, 'REQUIRED_FILES_TIMEOUT', 'download and subsequent upload consume the same deadline');
|
|
attachmentExpect(microtime(true) - $start < 1.4 && count($requests()) === 2, 'upload does not receive a fresh budget after downloading');
|
|
$clean(); usleep(700000);
|
|
$clear();
|
|
$eventOffset = count($attachmentTransportEvents);
|
|
$emptyPreparation = $run([]);
|
|
attachmentExpect(!empty($emptyPreparation['ok']) && $emptyPreparation['files'] === []
|
|
&& count($attachmentTransportEvents) === $eventOffset, 'empty manifest needs no connection pool or transfer handles');
|
|
$attachmentFailMultiAdd = true;
|
|
try {
|
|
attachmentError($run([$files[0]]), 'REQUIRED_FILES_PREPARE_FAILED', 'failed pool attachment still closes its fresh easy handle and pool');
|
|
attachmentExpect($requests() === [], 'failed multi add opens no HTTP request');
|
|
} finally { $attachmentFailMultiAdd = false; }
|
|
$clean();
|
|
$clear();
|
|
attachmentError($run([$files[0]], ['on_upload' => static function (array $event): void {
|
|
if ($event['status'] === 'uploaded') throw new RuntimeException('fixture-private-path-url-key-body');
|
|
}]), 'REQUIRED_FILES_PREPARE_FAILED', 'observer failure after upload still closes idle pooled connections and exposes no partial IDs');
|
|
attachmentExpect(array_column($requests(), 'kind') === ['upload'], 'observer failure does not retry or continue uploads');
|
|
$clean();
|
|
$clear();
|
|
attachmentError($run([$files[0]], ['temp_root' => $root . '/public/unsafe-temp']), 'CONFIG_INVALID', 'temporary files cannot be created in public');
|
|
attachmentExpect($requests() === [], 'unsafe temp configuration opens no connection');
|
|
attachmentError($run([['type' => 'image', 'transfer_method' => 'local_file', 'url' => $files[0]['url']]]), 'REQUIRED_FILES_INVALID', 'callers cannot inject upload IDs from other requests');
|
|
$clean();
|
|
echo "Dify required attachment upload: binary multipart, confirmed-missing skips and original indices, origins/paths/DNS/pinning/redirects/limits, request-local connection reuse/isolation, cancellation/deadline, cleanup: OK\n";
|
|
} finally {
|
|
if (is_resource($process)) { proc_terminate($process); proc_close($process); }
|
|
attachmentRemoveTree($root, $root);
|
|
}
|
|
}
|