diff --git a/admin/src/Transport.php b/admin/src/Transport.php index ecbef65..d7a5e74 100644 --- a/admin/src/Transport.php +++ b/admin/src/Transport.php @@ -29,11 +29,15 @@ final class Transport $ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code); // Сжат только доверенный helper из поставки; payload остаётся JSON-данными. $entry="\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);"; - $command=self::inlineCommand($code,$entry,base64_encode(json_encode($payload,JSON_THROW_ON_ERROR))); + $jsonPayload=json_encode($payload,JSON_THROW_ON_ERROR); + // Размер base64 проверяется ДО inlineCommand/escapeshellarg: для ZIP PHP + // завершает процесс при аргументе >2 МиБ раньше переключения на inbox. + $argumentLength=4*intdiv(strlen($jsonPayload)+2,3); + $command=$argumentLength>self::COMMAND_LIMIT?null:self::inlineCommand($code,$entry,base64_encode($jsonPayload)); $inbox=null;$sftp=null; try { // Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется. - if(strlen($command)>self::COMMAND_LIMIT) { + if($command===null||strlen($command)>self::COMMAND_LIMIT) { if(($payload['op']??null)==='test')throw new Problem('SSH bootstrap превышает безопасный предел команды; сократите helper или путь подключения.'); $parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16); $candidate=$private.'/inbox-'.bin2hex(random_bytes(16)); @@ -41,7 +45,7 @@ final class Transport // не должен бесконечно пытаться доставить сам себя через inbox. $command=self::inboxCommand($code,$candidate); $this->call(['op'=>'test']);$sftp=$this->sftp();$inbox=$candidate; - if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR))||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.'); + if(!$sftp->put($inbox,$jsonPayload)||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.'); } $timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout); $output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null; diff --git a/admin/tests/transport-command-limit.php b/admin/tests/transport-command-limit.php index 15b8c44..b33b055 100644 --- a/admin/tests/transport-command-limit.php +++ b/admin/tests/transport-command-limit.php @@ -70,6 +70,26 @@ namespace { SSH2::$withoutZlib=true; try{$transport->test();throw new RuntimeException('missing zlib accepted');}catch(Problem $error){ensure(str_contains($error->getMessage(),'zlib'),'missing gzinflate returns clear structured error');} SSH2::$withoutZlib=false; + // Настоящие 90 изображений: два слоя base64 дают аргумент заметно больше PHP shell-limit 2 MiB. + $sourceSet=dirname(__DIR__,2).'/1';$files=[]; + foreach(array_keys(\PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $filename){$bytes=file_get_contents($sourceSet.'/'.$filename);$files[$filename]=['data'=>base64_encode($bytes),'sha256'=>hash('sha256',$bytes)];} + $hugePayload=['op'=>'set-write','number'=>3,'files'=>$files,'expected_manifest'=>null]; + ensure(count($files)===90&&strlen(base64_encode(json_encode($hugePayload,JSON_THROW_ON_ERROR)))>2097152,'real 90-image set-write payload exceeds native 2 MiB shell argument limit'); + $uploadsBeforeHuge=count(SFTP::$uploads);$configBeforeHuge=$transport->readConfig(); + $transport->call($hugePayload); + ensure(\PrelandAdmin\Remote::manifest($site.'/3')===\PrelandAdmin\Remote::manifest($sourceSet),'multi-MiB set-write activates all 90 real files with exact hashes'); + ensure(count(SFTP::$uploads)===$uploadsBeforeHuge+1,'multi-MiB set-write uses exactly one SFTP inbox'); + $hugeInbox=SFTP::$uploads[array_key_last(SFTP::$uploads)]; + ensure(!file_exists($hugeInbox),'multi-MiB set-write inbox removed after actual RPC'); + ensure(max(array_map('strlen',SSH2::$commands))<=7000,'multi-MiB set-write final exec stays below bootstrap limit'); + ensure($transport->readConfig()['version']===$configBeforeHuge['version'],'image upload preserves config and the 2 MiB config contract'); + // Обрыв до исполнения большой команды не должен трогать опубликованный набор; inbox удаляется. + $hugePayload['expected_manifest']=\PrelandAdmin\Remote::manifest($site.'/3');$keptManifest=$hugePayload['expected_manifest'];SSH2::$dropInbox=true; + try{$transport->call($hugePayload);throw new RuntimeException('dropped multi-MiB set-write accepted');} + catch(Problem $error){ensure($error->status===504,'interrupted multi-MiB set-write remains unknown, not false success');} + finally{SSH2::$dropInbox=false;} + ensure(\PrelandAdmin\Remote::manifest($site.'/3')===$keptManifest,'interrupted multi-MiB exec preserves previously active set'); + ensure(!file_exists(SFTP::$uploads[array_key_last(SFTP::$uploads)]),'interrupted multi-MiB set-write cleans private inbox'); } finally { $iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST); foreach($iterator as $file)$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());rmdir($fixture);