feat(v4): consolidate audited data recovery economic replay and optional archival

This commit is contained in:
ENELIX Agent
2026-10-03 10:40:18 +00:00
parent 6c91d6bcc6
commit 8eb9688942
26 changed files with 1747 additions and 38 deletions
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
/** Lossless archival of fully acknowledged OLD outbox days. No device/API calls. */
final class NetzfahrplanV4Datenarchiv
{
public static function run(string $directory, array $cursor, int $now): array
{
$day=$cursor['day']??'';
if ($day==='') return ['archived'=>0];
if (!is_string($day)||!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)
||!is_int($cursor['offset']??null)||$cursor['offset']<0
||is_link($directory)||realpath($directory)!==$directory) {
throw new \RuntimeException('Invalid archive cursor/directory');
}
$cutoff='raw-'.gmdate('Ymd',$now-7*86400).'.jsonl';
$archive=$directory.'/archive';
if (is_link($archive)) throw new \RuntimeException('Archive symlink refused');
if (!is_dir($archive)&&!mkdir($archive,0700)) throw new \RuntimeException('Archive directory unavailable');
if ((fileperms($archive)&0007)!==0) throw new \RuntimeException('Archive must remain private');
$lockPath=$directory.'/.writer.lock';
if (is_link($lockPath)) throw new \RuntimeException('Writer lock symlink refused');
$lock=fopen($lockPath,'c+b');
if ($lock===false) throw new \RuntimeException('Archive lock unavailable');
$done=[];
try {
if (!flock($lock,LOCK_EX|LOCK_NB)) return ['archived'=>0,'status'=>'writer_busy'];
foreach (glob($directory.'/raw-*.jsonl')?:[] as $file) {
$name=basename($file);
if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||$name>=$day||$name>=$cutoff) continue;
if (is_link($file)||realpath($file)!==$file||!is_file($file)) throw new \RuntimeException('Unexpected journal');
$before=stat($file);
if (!$before||$before['size']>67108864) throw new \RuntimeException('Archive input bounds');
$digest=hash_file('sha256',$file);
if (!is_string($digest)) throw new \RuntimeException('Cannot hash journal');
$target=$archive.'/'.$name.'.'.$digest.'.gz';
if (is_link($target)) throw new \RuntimeException('Archive target symlink');
if (!is_file($target)) {
$tmp=tempnam($archive,'.pending-');
if ($tmp===false) throw new \RuntimeException('Cannot create archive');
try {
chmod($tmp,0600);$in=fopen($file,'rb');$gz=gzopen($tmp,'wb6');
if ($in===false||$gz===false) {if(is_resource($in))fclose($in);if(is_resource($gz))gzclose($gz);throw new \RuntimeException('Cannot open archive streams');}
try {
while (!feof($in)) {
$block=fread($in,65536);
if ($block===false) throw new \RuntimeException('Archive read failed');
$offset=0;
while($offset<strlen($block)) {$n=gzwrite($gz,substr($block,$offset));if($n===false||$n===0)throw new \RuntimeException('Archive write failed');$offset+=$n;}
}
} finally {fclose($in);gzclose($gz);}
$sync=fopen($tmp,'r+b');
if ($sync===false) throw new \RuntimeException('Cannot sync archive');
try {if(!fsync($sync))throw new \RuntimeException('Archive sync failed');} finally {fclose($sync);}
if (!rename($tmp,$target)) throw new \RuntimeException('Archive rename failed');
} finally {if(is_file($tmp))unlink($tmp);}
}
$gz=gzopen($target,'rb');if($gz===false)throw new \RuntimeException('Cannot verify archive');
$hash=hash_init('sha256');$bytes=0;
try {while(!gzeof($gz)){$block=gzread($gz,65536);if($block===false)throw new \RuntimeException('Archive verification read failed');$bytes+=strlen($block);if($bytes>$before['size'])throw new \RuntimeException('Oversized decompressed archive');hash_update($hash,$block);}}
finally {gzclose($gz);}
clearstatcache(true,$file);$after=stat($file);
if ($bytes!==$before['size']||hash_final($hash)!==$digest||!$after
||$after['size']!==$before['size']||$after['ino']!==$before['ino']||$after['mtime']!==$before['mtime']
||hash_file('sha256',$file)!==$digest) throw new \RuntimeException('Archive mismatch; original retained');
$directoryHandle=fopen($archive,'r');
if ($directoryHandle===false) throw new \RuntimeException('Cannot sync archive directory');
try {if(!fsync($directoryHandle))throw new \RuntimeException('Archive directory sync failed');} finally {fclose($directoryHandle);}
if (!unlink($file)) throw new \RuntimeException('Verified original could not be retired');
$done[]=$name;
if (count($done)>=2) break;
}
} finally {flock($lock,LOCK_UN);fclose($lock);}
return ['archived'=>count($done),'compressedOriginalsRetained'=>true,'unacknowledgedDaysTouched'=>false];
}
}