feat(v4): consolidate audited data recovery economic replay and optional archival
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user