Files
2026-11-04 09:37:20 +02:00

26 lines
2.3 KiB
PHP

<?php
namespace Tempo\Finance\Composition;
/** Verify against a caller-pinned manifest BEFORE loading executable package files. */
final class Bundle
{
public static function verify(string $root,string $expected):array
{
$root=realpath($root)?:throw new \LogicException('FINANCE_BUNDLE_MISSING');$manifest=$root.'/manifest.json';
if(!preg_match('/^[a-f0-9]{64}$/D',$expected)||!is_file($manifest)||is_link($manifest)||!hash_equals($expected,hash_file('sha256',$manifest)))throw new \LogicException('FINANCE_BUNDLE_MANIFEST_MISMATCH');
$m=json_decode(file_get_contents($manifest),true,64,JSON_THROW_ON_ERROR);
if(($m['contract']??null)!=='COMMON-FINANCE-BUNDLE-v1'||!is_array($m['files']??null)||count($m['files'])<3)throw new \LogicException('FINANCE_BUNDLE_CONTRACT');
$expectedFiles=['manifest.json'];$seen=[];
foreach($m['files'] as $entry){
$name=$entry['file']??'';
if(!is_string($name)||!preg_match('~^(Bundle\.php|(?:payables|general-ledger)/(?:bootstrap\.php|README\.md|(?:src|ui)/[A-Za-z0-9_-]+\.php))$~D',$name)||isset($seen[$name]))throw new \LogicException('FINANCE_BUNDLE_PATH');
$seen[$name]=true;$file=$root.'/'.$name;$actual=realpath($file);
if(!$actual||!str_starts_with($actual,$root.DIRECTORY_SEPARATOR)||is_link($file)||!is_file($actual)||!hash_equals($entry['sha256']??'',hash_file('sha256',$actual)))throw new \LogicException('FINANCE_BUNDLE_FILE_MISMATCH');
$expectedFiles[]=$name;
}
$actualFiles=[];foreach(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root,\FilesystemIterator::SKIP_DOTS)) as $file){if($file->isLink())throw new \LogicException('FINANCE_BUNDLE_SYMLINK');if($file->isFile())$actualFiles[]=str_replace('\\','/',substr($file->getPathname(),strlen($root)+1));}
sort($actualFiles);sort($expectedFiles);if($actualFiles!==$expectedFiles)throw new \LogicException('FINANCE_BUNDLE_EXTRA_OR_MISSING_FILE');
foreach(['Bundle.php','payables/bootstrap.php','general-ledger/bootstrap.php'] as $required)if(!isset($seen[$required]))throw new \LogicException('FINANCE_BUNDLE_INCOMPLETE');
return ['payables'=>$root.'/payables','ledger'=>$root.'/general-ledger','files'=>count($seen),'manifest_sha256'=>$expected];
}
}