= 8.4. Aucun broker, worker, secret réel ou appel externe. * Usage : php rotation_messenger.php.txt /chemin/sources-symfony * Préparation : python3 preparer_exemple.py /chemin/sources-symfony */ use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Exception\InvalidMessageSignatureException; use Symfony\Component\Messenger\Exception\MessageDecodingFailedException; use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; use Symfony\Component\Messenger\Transport\Serialization\SigningSerializer; if (PHP_VERSION_ID < 80400 || !isset($argv[1])) { throw new RuntimeException('PHP >= 8.4 et chemin des sources requis.'); } $root = realpath($argv[1]); if ($root === false) { throw new RuntimeException('Sources absentes.'); } spl_autoload_register(static function (string $class) use ($root): void { $prefix = 'Symfony\\Component\\Messenger\\'; if (str_starts_with($class, $prefix)) { $path = $root.'/'.str_replace('\\', '/', substr($class, strlen($prefix))).'.php'; if (is_file($path)) { require $path; } } }); final readonly class FictiveQuote { public function __construct(public string $reference, public int $amountCents) {} } final class FictiveJsonSerializer implements SerializerInterface { public int $decodeCalls = 0; public function encode(Envelope $envelope): array { $quote = $envelope->getMessage(); if (!$quote instanceof FictiveQuote) { throw new LogicException('Le banc ne sérialise que son devis fictif.'); } return ['body' => json_encode(['reference' => $quote->reference, 'amountCents' => $quote->amountCents], JSON_THROW_ON_ERROR), 'headers' => ['type' => FictiveQuote::class]]; } public function decode(array $encodedEnvelope): Envelope { ++$this->decodeCalls; // Jamais d'instanciation dynamique à partir d'un nom de classe reçu. if (($encodedEnvelope['headers']['type'] ?? '') !== FictiveQuote::class) { throw new LogicException('Type inattendu.'); } $data = json_decode($encodedEnvelope['body'], true, 512, JSON_THROW_ON_ERROR); return new Envelope(new FictiveQuote($data['reference'], $data['amountCents'])); } } $checks = 0; function check(bool $ok, string $label): void { global $checks; if (!$ok) { throw new RuntimeException('Échec : '.$label); } ++$checks; } // Valeurs publiques pour ce seul exercice. Ne jamais les utiliser en production. $a = 'DEMO-ONLY-5042-A-NOT-A-PRODUCTION-SECRET'; $b = 'DEMO-ONLY-5042-B-NOT-A-PRODUCTION-SECRET'; $c = 'DEMO-ONLY-5042-C-NOT-A-PRODUCTION-SECRET'; $rings = ['A' => [$a], 'A+B' => [$a, $b], 'B+A' => [$b, $a], 'B' => [$b]]; $inners = $serializers = []; foreach ($rings as $name => $ring) { $inners[$name] = new FictiveJsonSerializer(); $serializers[$name] = new SigningSerializer($inners[$name], $ring); } $quote = new Envelope(new FictiveQuote('DEVIS-FICTIF-42', 12345)); $messages = ['A' => $serializers['A']->encode($quote), 'B' => $serializers['B']->encode($quote)]; check($serializers['A+B']->encode($quote) === $messages['A'], 'La première clé signe : A+B'); check($serializers['B+A']->encode($quote) === $messages['B'], 'La première clé signe : B+A'); check($messages['A']['body'] === $messages['B']['body'], 'Rotation sans modifier le devis'); check($messages['A']['headers']['Body-Sign'] !== $messages['B']['headers']['Body-Sign'], 'Signatures distinctes'); $expected = ['A' => ['A' => true, 'B' => false], 'A+B' => ['A' => true, 'B' => true], 'B+A' => ['A' => true, 'B' => true], 'B' => ['A' => false, 'B' => true]]; $matrix = []; foreach ($serializers as $consumer => $serializer) { foreach ($messages as $producer => $encoded) { $before = $inners[$consumer]->decodeCalls; $decoded = $serializer->decode($encoded); $accepted = $decoded->getMessage() instanceof FictiveQuote; check($accepted === $expected[$consumer][$producer], 'Matrice '.$consumer.'/'.$producer); check($inners[$consumer]->decodeCalls - $before === (int) $accepted, 'Refus avant décodage '.$consumer.'/'.$producer); $matrix[$consumer][$producer] = $accepted ? 'accepted' : 'refused'; } } // Lire un ancien message avec les deux clés, puis le réencoder avec B en tête. // Ceci éprouve le composant, pas un retry réel du worker ou du transport d'échec. $oldDecoded = $serializers['B+A']->decode($messages['A']); $reencoded = $serializers['B+A']->encode($oldDecoded); check($reencoded === $messages['B'], 'Réencodage avec B'); check($serializers['B']->decode($reencoded)->getMessage() instanceof FictiveQuote, 'Reprise compatible après retrait de A'); check($serializers['A']->decode($reencoded)->getMessage() instanceof MessageDecodingFailedException, 'Rollback A seul incompatible'); $unsigned = (new FictiveJsonSerializer())->encode($quote); $bodyChanged = $messages['A']; $bodyChanged['body'] = str_replace('12345', '99999', $bodyChanged['body']); $typeChanged = $messages['A']; $typeChanged['headers']['type'] = 'AnotherFictiveType'; $unknownKey = (new SigningSerializer(new FictiveJsonSerializer(), $c))->encode($quote); // Forme historique corps seul : A est connue, mais ce format est refusé en mode sign-all. $bodyOnly = $unsigned; $bodyOnly['headers']['Body-Sign'] = hash_hmac('sha256', $bodyOnly['body'], $a); $bodyOnly['headers']['Sign-Algo'] = 'sha256'; $refusals = []; foreach (['unsigned' => $unsigned, 'changed-body' => $bodyChanged, 'changed-type' => $typeChanged, 'unknown-key' => $unknownKey, 'body-only-old-format' => $bodyOnly] as $name => $encoded) { $before = $inners['B+A']->decodeCalls; $error = $serializers['B+A']->decode($encoded)->getMessage(); check($error instanceof MessageDecodingFailedException, 'Enveloppe d’échec '.$name); check($error->getPrevious() instanceof InvalidMessageSignatureException, 'Cause de signature '.$name); check($inners['B+A']->decodeCalls === $before, 'Contenu non décodé '.$name); $refusals[] = $name; } $reordered = $messages['A']; $reordered['headers'] = array_reverse($reordered['headers'], true); check($serializers['B+A']->decode($reordered)->getMessage() instanceof FictiveQuote, 'Ordre des en-têtes sans effet'); $before = $inners['B+A']->decodeCalls; $serializers['B+A']->decode($messages['B']); $serializers['B+A']->decode($messages['B']); check($inners['B+A']->decodeCalls - $before === 2, 'Signature sans déduplication'); check(str_contains($messages['A']['body'], 'DEVIS-FICTIF-42'), 'Signature sans chiffrement'); // Inventaire fictif de cohortes, externe au composant. Une file principale vide // ne dit rien des tâches différées et de celles en échec. $backlog = ['ready' => [], 'delayed' => [$messages['A']], 'failed' => [$messages['A']]]; check(count($backlog['ready']) === 0, 'File prête vide'); $unreadable = 0; foreach ($backlog as $items) { foreach ($items as $encoded) { $unreadable += (int) ($serializers['B']->decode($encoded)->getMessage() instanceof MessageDecodingFailedException); } } check($unreadable === 2, 'Retrait prématuré bloque deux cohortes'); check(18 * 7 === 126, 'Coût fictif : 18 dossiers × 7 minutes'); echo json_encode(['status' => 'PASS', 'checks' => $checks, 'php' => PHP_VERSION, 'symfonyCommit' => '561f0af17ceae4fc2900ffbda8e6bbf379fe9626', 'matrix' => $matrix, 'refusals' => $refusals, 'remainingUnreadableIfAWithdrawn' => $unreadable, 'noBroker' => true, 'noHandler' => true, 'noExternalCalls' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)."\n";