288 lines
11 KiB
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];
|
|
}
|
|
}
|