netgescon-day0/app/Services/FattureElettroniche/P7mExtractor.php

288 lines
11 KiB
PHP

<?php
namespace App\Services\FattureElettroniche;
use RuntimeException;
use Symfony\Component\Process\Process;
class P7mExtractor
{
public function extractXmlFromP7m(string $p7mPath): string
{
if (! is_file($p7mPath)) {
throw new RuntimeException("File non trovato: {$p7mPath}");
}
$tmpOut = tempnam(sys_get_temp_dir(), 'fatturapa_');
if (! is_string($tmpOut) || $tmpOut === '') {
throw new RuntimeException('Impossibile creare file temporaneo.');
}
$tmpInputs = [];
// Molti .p7m (SdI/AdE) sono CMS SignedData (DER) e NON S/MIME.
// Usiamo `openssl cms` come prima scelta, poi fallback su `openssl smime`.
// Nota: alcuni OpenSSL gestiscono meglio l'autodetect senza -inform.
$inputCandidates = [$p7mPath];
$raw = @file_get_contents($p7mPath);
if (is_string($raw) && $raw !== '') {
// Caso 1: file testuale base64 senza header (capita con alcuni export)
$trim = trim($raw);
$isText = ! str_contains(substr($raw, 0, 2048), "\0");
$looksLikePem = str_contains($trim, '-----BEGIN');
$looksLikeMime = stripos($raw, 'content-type:') !== false || stripos($raw, 'mime-version:') !== false;
if ($isText && ! $looksLikePem) {
$b64 = null;
if ($looksLikeMime) {
// Estrai blocco base64 dal body MIME (best effort)
$lines = preg_split('/\R/', $raw) ?: [];
$collect = [];
$inBody = false;
foreach ($lines as $line) {
$line = rtrim((string) $line);
if (! $inBody) {
if ($line === '') {
$inBody = true;
}
continue;
}
if (str_starts_with($line, '--')) {
continue;
}
if (str_contains($line, ':')) {
continue;
}
$l = trim($line);
if ($l === '') {
continue;
}
if (preg_match('/^[A-Za-z0-9+\/]+=*$/', $l)) {
$collect[] = $l;
}
}
if ($collect) {
$b64 = implode('', $collect);
}
} else {
$compact = preg_replace('/\s+/', '', $trim) ?? '';
if ($compact !== '' && preg_match('/^[A-Za-z0-9+\/]+=*$/', $compact)) {
$b64 = $compact;
}
}
if (is_string($b64) && $b64 !== '') {
$decoded = base64_decode($b64, true);
if (is_string($decoded) && $decoded !== '') {
$tmpDer = tempnam(sys_get_temp_dir(), 'p7mder_');
if (is_string($tmpDer) && $tmpDer !== '') {
@file_put_contents($tmpDer, $decoded);
$tmpInputs[] = $tmpDer;
$inputCandidates[] = $tmpDer;
}
}
}
}
}
$tries = [];
foreach (array_values(array_unique($inputCandidates)) as $in) {
// CMS
$tries[] = ['openssl', 'cms', '-verify', '-inform', 'DER', '-in', $in, '-noverify', '-out', $tmpOut];
$tries[] = ['openssl', 'cms', '-verify', '-inform', 'PEM', '-in', $in, '-noverify', '-out', $tmpOut];
$tries[] = ['openssl', 'cms', '-verify', '-in', $in, '-noverify', '-out', $tmpOut];
// S/MIME autodetect (molto utile per file MIME con base64)
$tries[] = ['openssl', 'smime', '-verify', '-in', $in, '-noverify', '-out', $tmpOut];
$tries[] = ['openssl', 'smime', '-verify', '-inform', 'DER', '-in', $in, '-noverify', '-out', $tmpOut];
$tries[] = ['openssl', 'smime', '-verify', '-inform', 'PEM', '-in', $in, '-noverify', '-out', $tmpOut];
}
$attemptErrors = [];
try {
foreach ($tries as $cmd) {
$process = new Process($cmd);
$process->setTimeout(30);
$process->run();
if ($process->isSuccessful() && is_file($tmpOut) && filesize($tmpOut) > 0) {
$xml = file_get_contents($tmpOut);
$xml = is_string($xml) ? trim($xml) : '';
$xml = preg_replace('/^\xEF\xBB\xBF/', '', $xml) ?? $xml;
if ($xml === '') {
$attemptErrors[] = implode(' ', $cmd) . ' => XML estratto vuoto';
continue;
}
// Alcuni tool possono produrre preamboli/MIME: estraiamo dall'inizio del tag se presente.
$pos = false;
// Troviamo SOLO il root element (non Header/Body), con prefisso opzionale.
if (preg_match('/<\s*(?:[a-z0-9_\-]+:)?FatturaElettronica\b/i', $xml, $m, PREG_OFFSET_CAPTURE)) {
$pos = $m[0][1] ?? false;
}
if ($pos !== false && is_int($pos) && $pos > 0) {
$xml = substr($xml, $pos);
$xml = is_string($xml) ? trim($xml) : '';
}
if ($this->looksLikeCompleteFatturaXml($xml)) {
return $xml;
}
// Alcuni fallback (es. scansione DER) possono trovare solo un frammento iniziale.
// Se manca la chiusura del root, non accettiamo l'output: continuiamo a provare altri comandi.
if (preg_match('/<\s*(?:[a-z0-9_\-]+:)?FatturaElettronica\b/i', $xml)) {
$attemptErrors[] = implode(' ', $cmd) . ' => XML estratto contiene il root ma sembra incompleto (manca la chiusura di FatturaElettronica). Len=' . strlen($xml);
continue;
}
$snippet = substr($xml, 0, 200);
$snippet = is_string($snippet) ? preg_replace('/\s+/', ' ', $snippet) : '';
$attemptErrors[] = implode(' ', $cmd) . ' => Contenuto estratto non sembra FatturaPA XML. Snippet: ' . (string) $snippet;
continue;
}
$err = trim((string) ($process->getErrorOutput() ?: $process->getOutput()));
$attemptErrors[] = implode(' ', $cmd) . ' => ' . ($err !== '' ? $err : 'errore sconosciuto');
}
// Fallback finale (senza openssl): alcuni P7M SdI sono DER PKCS#7 SignedData
// e contengono l'XML come OCTET STRING. In questi casi possiamo estrarre in modo deterministico.
$fallbackXml = $this->tryExtractXmlFromDerPkcs7($p7mPath);
if (is_string($fallbackXml) && trim($fallbackXml) !== '') {
return $fallbackXml;
}
$last = null;
if ($attemptErrors) {
for ($i = count($attemptErrors) - 1; $i >= 0; $i--) {
$e = (string) $attemptErrors[$i];
if (str_contains($e, 'XML estratto') || str_contains($e, 'Contenuto estratto')) {
$last = $e;
break;
}
}
if (! $last) {
$last = (string) end($attemptErrors);
}
}
throw new RuntimeException('Impossibile estrarre XML dal P7M' . ($last ? ": {$last}" : '.'));
} finally {
try {
@unlink($tmpOut);
} catch (\Throwable) {
// ignore
}
foreach ($tmpInputs as $p) {
try {
@unlink($p);
} catch (\Throwable) {
// ignore
}
}
}
}
private function tryExtractXmlFromDerPkcs7(string $path): ?string
{
$bin = @file_get_contents($path);
if (! is_string($bin) || $bin === '') {
return null;
}
$len = strlen($bin);
for ($i = 0; $i < $len - 4; $i++) {
// OCTET STRING tag
if (ord($bin[$i]) !== 0x04) {
continue;
}
$off = $i + 1;
[$contentLen, $contentOff] = $this->readDerLength($bin, $off);
if ($contentLen === null || $contentOff === null) {
continue;
}
if ($contentOff + $contentLen > $len || $contentLen < 20) {
continue;
}
$payload = substr($bin, $contentOff, $contentLen);
if (! is_string($payload) || $payload === '') {
continue;
}
// Trim BOM if present
if (str_starts_with($payload, "\xEF\xBB\xBF")) {
$payload = substr($payload, 3);
}
$probe = ltrim($payload);
// Quick signature check
if (! (str_starts_with($probe, '<?xml') || preg_match('/^<\s*(?:[a-z0-9_\-]+:)?FatturaElettronica\b/i', $probe))) {
continue;
}
$xml = trim($payload);
// Ensure it really contains the root element
if ($this->looksLikeCompleteFatturaXml($xml)) {
return $xml;
}
}
return null;
}
private function looksLikeCompleteFatturaXml(string $xml): bool
{
if (! preg_match('/<\s*(?:(?<p>[a-z0-9_\-]+):)?FatturaElettronica\b/i', $xml, $m)) {
return false;
}
$prefix = $m['p'] ?? null;
if (is_string($prefix) && $prefix !== '') {
return preg_match('/<\s*\/\s*' . preg_quote($prefix, '/') . ':FatturaElettronica\s*>/i', $xml) === 1;
}
// Root senza prefisso: chiusura classica.
return preg_match('/<\s*\/\s*FatturaElettronica\s*>/i', $xml) === 1;
}
/**
* @return array{0:int|null,1:int|null} [length, offsetAfterLength]
*/
private function readDerLength(string $bin, int $offset): array
{
$total = strlen($bin);
if ($offset >= $total) {
return [null, null];
}
$b = ord($bin[$offset]);
$offset++;
if ($b < 0x80) {
return [$b, $offset];
}
$n = $b & 0x7F;
if ($n === 0 || $n > 4) {
return [null, null];
}
if ($offset + $n > $total) {
return [null, null];
}
$l = 0;
for ($i = 0; $i < $n; $i++) {
$l = ($l << 8) | ord($bin[$offset + $i]);
}
return [$l, $offset + $n];
}
}