diff --git a/composer.json b/composer.json
index a9ef29c..2748b64 100644
--- a/composer.json
+++ b/composer.json
@@ -15,6 +15,7 @@
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^3.3",
"easycorp/easyadmin-bundle": "^4.27",
+ "fakerphp/faker": "^1.24",
"league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29",
"league/oauth2-server-bundle": "^1.0",
diff --git a/composer.lock b/composer.lock
index 8436b5e..3bd2880 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "e3fae85e0cff82354aeffcb409015eec",
+ "content-hash": "ff6bf7b0734ba021721679b2c94873a8",
"packages": [
{
"name": "carbonphp/carbon-doctrine-types",
@@ -1578,6 +1578,69 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
+ {
+ "name": "fakerphp/faker",
+ "version": "v1.24.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/FakerPHP/Faker.git",
+ "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+ "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "psr/container": "^1.0 || ^2.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "conflict": {
+ "fzaninotto/faker": "*"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.4.1",
+ "doctrine/persistence": "^1.3 || ^2.0",
+ "ext-intl": "*",
+ "phpunit/phpunit": "^9.5.26",
+ "symfony/phpunit-bridge": "^5.4.16"
+ },
+ "suggest": {
+ "doctrine/orm": "Required to use Faker\\ORM\\Doctrine",
+ "ext-curl": "Required by Faker\\Provider\\Image to download images.",
+ "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.",
+ "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.",
+ "ext-mbstring": "Required for multibyte Unicode string functionality."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Faker\\": "src/Faker/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "François Zaninotto"
+ }
+ ],
+ "description": "Faker is a PHP library that generates fake data for you.",
+ "keywords": [
+ "data",
+ "faker",
+ "fixtures"
+ ],
+ "support": {
+ "issues": "https://github.com/FakerPHP/Faker/issues",
+ "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
+ },
+ "time": "2024-11-21T13:46:39+00:00"
+ },
{
"name": "lcobucci/clock",
"version": "3.5.0",
diff --git a/src/Command/BpnXmlAnonymizeCommand.php b/src/Command/BpnXmlAnonymizeCommand.php
new file mode 100644
index 0000000..6ce6ab6
--- /dev/null
+++ b/src/Command/BpnXmlAnonymizeCommand.php
@@ -0,0 +1,158 @@
+addArgument('infile', InputArgument::REQUIRED, 'Path to the input XML file')
+ ->addArgument('outfile', InputArgument::OPTIONAL, 'Path to the output XML file (defaults to infile)')
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $inputFile = (string) $input->getArgument('infile');
+ $outputFile = $input->getArgument('outfile');
+
+ if (false === is_file($inputFile)) {
+ $io->error(sprintf('Input file not found: %s', $inputFile));
+
+ return Command::FAILURE;
+ }
+
+ if (false === is_readable($inputFile)) {
+ $io->error(sprintf('Input file is not readable: %s', $inputFile));
+
+ return Command::FAILURE;
+ }
+
+ $targetFile = null === $outputFile ? $inputFile : (string) $outputFile;
+ $this->xmlAnonymizer->resetState();
+
+ $processingResult = $this->anonymizeFile($io, $inputFile, $targetFile, false);
+ if (Command::SUCCESS !== $processingResult) {
+ return $processingResult;
+ }
+
+ $pairedInputFile = $this->derivePairedFilename($inputFile);
+ if (null !== $pairedInputFile && true === is_file($pairedInputFile)) {
+ if (false === is_readable($pairedInputFile)) {
+ $io->error(sprintf('Paired file is not readable: %s', $pairedInputFile));
+
+ return Command::FAILURE;
+ }
+
+ $pairedTargetFile = $this->derivePairedOutputFilename($targetFile, $inputFile, $pairedInputFile);
+ if (null === $pairedTargetFile) {
+ $io->error(sprintf(
+ 'Unable to derive paired output filename from: %s',
+ $targetFile
+ ));
+
+ return Command::FAILURE;
+ }
+
+ $processingResult = $this->anonymizeFile($io, $pairedInputFile, $pairedTargetFile, true);
+ if (Command::SUCCESS !== $processingResult) {
+ return $processingResult;
+ }
+
+ $io->note(sprintf('Paired file anonymized: %s -> %s', $pairedInputFile, $pairedTargetFile));
+ }
+
+ $io->success(sprintf(
+ 'Anonymized %d persons from %s to %s',
+ $this->xmlAnonymizer->getLastIdentityCount(),
+ $inputFile,
+ $targetFile,
+ ));
+
+ return Command::SUCCESS;
+ }
+
+ private function anonymizeFile(SymfonyStyle $io, string $inputFile, string $targetFile, bool $keepState): int
+ {
+ $xml = file_get_contents($inputFile);
+ if (false === $xml) {
+ $io->error(sprintf('Unable to read input file: %s', $inputFile));
+
+ return Command::FAILURE;
+ }
+
+ try {
+ $anonymizedXml = $this->xmlAnonymizer->anonymize($xml, false === $keepState);
+ } catch (\InvalidArgumentException|\RuntimeException $exception) {
+ $io->error(sprintf('Failed to anonymize %s: %s', $inputFile, $exception->getMessage()));
+
+ return Command::FAILURE;
+ }
+
+ $writeResult = file_put_contents($targetFile, $anonymizedXml);
+ if (false === $writeResult) {
+ $io->error(sprintf('Unable to write output file: %s', $targetFile));
+
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+
+ private function derivePairedFilename(string $filename): ?string
+ {
+ if (str_ends_with($filename, '_request.xml')) {
+ return substr($filename, 0, -12).'_response.xml';
+ }
+
+ if (str_ends_with($filename, '_response.xml')) {
+ return substr($filename, 0, -13).'_request.xml';
+ }
+
+ return null;
+ }
+
+ private function derivePairedOutputFilename(string $outputFile, string $inputFile, string $pairedInputFile): ?string
+ {
+ if (str_ends_with($inputFile, '_request.xml') && str_ends_with($pairedInputFile, '_response.xml')) {
+ if (false === str_ends_with($outputFile, '_request.xml')) {
+ return null;
+ }
+
+ return substr($outputFile, 0, -12).'_response.xml';
+ }
+
+ if (str_ends_with($inputFile, '_response.xml') && str_ends_with($pairedInputFile, '_request.xml')) {
+ if (false === str_ends_with($outputFile, '_response.xml')) {
+ return null;
+ }
+
+ return substr($outputFile, 0, -13).'_request.xml';
+ }
+
+ return null;
+ }
+}
diff --git a/src/Service/BpnXmlAnonymizer.php b/src/Service/BpnXmlAnonymizer.php
new file mode 100644
index 0000000..586ea5a
--- /dev/null
+++ b/src/Service/BpnXmlAnonymizer.php
@@ -0,0 +1,301 @@
+
+ */
+ private array $identities = [];
+
+ private int $fallbackIdentityCounter = 0;
+
+ public function __construct()
+ {
+ $this->faker = Factory::create('de_DE');
+ }
+
+ public function anonymize(string $xml, bool $resetState = true): string
+ {
+ if (true === $resetState) {
+ $this->resetState();
+ }
+
+ $document = new \DOMDocument();
+ $document->preserveWhiteSpace = true;
+ $document->formatOutput = false;
+
+ if (false === @$document->loadXML($xml)) {
+ throw new \InvalidArgumentException('Invalid XML input.');
+ }
+
+ $xpath = new \DOMXPath($document);
+ $personNodes = $xpath->query('//anmelder | //teilnehmerliste/teilnehmer | //kundennamen/kunde');
+
+ if (false === $personNodes) {
+ throw new \RuntimeException('Unable to query person nodes.');
+ }
+
+ foreach ($personNodes as $index => $personNode) {
+ if (false === $personNode instanceof \DOMElement) {
+ continue;
+ }
+
+ $personId = $this->resolvePersonId($personNode, (int) $index);
+ $identity = $this->getOrCreateIdentity($personId);
+
+ $this->replaceNameFields($personNode, $identity);
+ $this->replaceEmailFields($personNode, $identity);
+ $this->replaceAddressFields($personNode, $identity);
+ }
+
+ $result = $document->saveXML();
+ if (false === $result) {
+ throw new \RuntimeException('Unable to serialize anonymized XML.');
+ }
+
+ return $result;
+ }
+
+ public function getLastIdentityCount(): int
+ {
+ return \count($this->identities);
+ }
+
+ public function resetState(): void
+ {
+ $this->identities = [];
+ $this->fallbackIdentityCounter = 0;
+ }
+
+ private function resolvePersonId(\DOMElement $personNode, int $nodeIndex): string
+ {
+ foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) {
+ $child = $this->getDirectChild($personNode, $tagName);
+ if (null !== $child) {
+ $value = trim($child->textContent);
+ if ('' !== $value) {
+ return $value;
+ }
+ }
+ }
+
+ $fingerprintKey = $this->buildFingerprintKey($personNode);
+ if (null !== $fingerprintKey) {
+ return $fingerprintKey;
+ }
+
+ if (true === $personNode->hasAttribute('id')) {
+ $attributeValue = trim((string) $personNode->getAttribute('id'));
+ if ('' !== $attributeValue) {
+ return sprintf('attr-id:%s', $attributeValue);
+ }
+ }
+
+ ++$this->fallbackIdentityCounter;
+
+ return sprintf('fallback:%d:%d', $nodeIndex, $this->fallbackIdentityCounter);
+ }
+
+ private function buildFingerprintKey(\DOMElement $personNode): ?string
+ {
+ $nameValues = [];
+ $nameNode = $this->getDirectChild($personNode, 'name');
+ if (null !== $nameNode) {
+ $nameValues[] = trim($nameNode->textContent);
+ }
+ if (true === $personNode->hasAttribute('name')) {
+ $nameValues[] = trim((string) $personNode->getAttribute('name'));
+ }
+
+ $firstNameNode = $this->getDirectChild($personNode, 'vorname');
+ if (null !== $firstNameNode) {
+ $nameValues[] = trim($firstNameNode->textContent);
+ }
+ if (true === $personNode->hasAttribute('vorname')) {
+ $nameValues[] = trim((string) $personNode->getAttribute('vorname'));
+ }
+
+ $communicationNode = $this->getDirectChild($personNode, 'kommunikation');
+ if (null !== $communicationNode) {
+ $emailNode = $this->getDirectChild($communicationNode, 'email');
+ if (null !== $emailNode) {
+ $nameValues[] = trim($emailNode->textContent);
+ }
+ }
+
+ $addressNode = $this->getDirectChild($personNode, 'anschrift');
+ if (null !== $addressNode) {
+ foreach (['strasse', 'plz', 'ort'] as $fieldName) {
+ $fieldNode = $this->getDirectChild($addressNode, $fieldName);
+ if (null !== $fieldNode) {
+ $nameValues[] = trim($fieldNode->textContent);
+ }
+ }
+ }
+
+ $normalizedParts = [];
+ foreach ($nameValues as $value) {
+ if ('' === $value) {
+ continue;
+ }
+
+ $normalizedParts[] = mb_strtolower($value);
+ }
+
+ if (0 === \count($normalizedParts)) {
+ return null;
+ }
+
+ return sprintf('fp:%s', md5(implode('|', $normalizedParts)));
+ }
+
+ /**
+ * @param string $personId
+ *
+ * @return array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string}
+ */
+ private function getOrCreateIdentity(string $personId): array
+ {
+ if (true === isset($this->identities[$personId])) {
+ return $this->identities[$personId];
+ }
+
+ $firstName = $this->faker->firstName();
+ $lastName = $this->faker->lastName();
+
+ $street = sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber());
+ $postalCode = $this->faker->postcode();
+ $city = $this->faker->city();
+ $mobilePhone = $this->faker->numerify('01#########');
+
+ $emailLocal = sprintf('%s.%s', $this->slug($firstName), $this->slug($lastName));
+ $email = sprintf('%s@%s', $emailLocal, self::SAFE_EMAIL_DOMAIN);
+
+ $this->identities[$personId] = [
+ 'firstName' => $firstName,
+ 'lastName' => $lastName,
+ 'street' => $street,
+ 'postalCode' => $postalCode,
+ 'city' => $city,
+ 'email' => strtolower($email),
+ 'mobilePhone' => $mobilePhone,
+ ];
+
+ return $this->identities[$personId];
+ }
+
+ /**
+ * @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
+ */
+ private function replaceNameFields(\DOMElement $personNode, array $identity): void
+ {
+ $nameNode = $this->getDirectChild($personNode, 'name');
+ if (null !== $nameNode) {
+ $nameNode->nodeValue = $identity['lastName'];
+ }
+
+ $firstNameNode = $this->getDirectChild($personNode, 'vorname');
+ if (null !== $firstNameNode) {
+ $firstNameNode->nodeValue = $identity['firstName'];
+ }
+
+ if (true === $personNode->hasAttribute('name')) {
+ $personNode->setAttribute('name', $identity['lastName']);
+ }
+
+ if (true === $personNode->hasAttribute('vorname')) {
+ $personNode->setAttribute('vorname', $identity['firstName']);
+ }
+ }
+
+ /**
+ * @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
+ */
+ private function replaceEmailFields(\DOMElement $personNode, array $identity): void
+ {
+ $communicationNode = $this->getDirectChild($personNode, 'kommunikation');
+ if (null === $communicationNode) {
+ return;
+ }
+
+ $emailNode = $this->getDirectChild($communicationNode, 'email');
+ if (null !== $emailNode) {
+ $emailNode->nodeValue = $identity['email'];
+ }
+
+ $mobileNode = $this->getDirectChild($communicationNode, 'telefonmobil');
+ if (null !== $mobileNode) {
+ $mobileNode->nodeValue = $identity['mobilePhone'];
+ }
+ }
+
+ /**
+ * @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
+ */
+ private function replaceAddressFields(\DOMElement $personNode, array $identity): void
+ {
+ $addressNode = $this->getDirectChild($personNode, 'anschrift');
+ if (null === $addressNode) {
+ return;
+ }
+
+ $streetNode = $this->getDirectChild($addressNode, 'strasse');
+ if (null !== $streetNode) {
+ $streetNode->nodeValue = $identity['street'];
+ }
+
+ $postalCodeNode = $this->getDirectChild($addressNode, 'plz');
+ if (null !== $postalCodeNode) {
+ $postalCodeNode->nodeValue = $identity['postalCode'];
+ }
+
+ $cityNode = $this->getDirectChild($addressNode, 'ort');
+ if (null !== $cityNode) {
+ $cityNode->nodeValue = $identity['city'];
+ }
+ }
+
+ private function getDirectChild(\DOMElement $parent, string $name): ?\DOMElement
+ {
+ foreach ($parent->childNodes as $childNode) {
+ if (false === $childNode instanceof \DOMElement) {
+ continue;
+ }
+
+ if ($childNode->tagName === $name) {
+ return $childNode;
+ }
+ }
+
+ return null;
+ }
+
+ private function slug(string $value): string
+ {
+ $normalizedValue = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
+ if (false === $normalizedValue) {
+ $normalizedValue = $value;
+ }
+
+ $normalizedValue = strtolower($normalizedValue);
+ $normalizedValue = preg_replace('/[^a-z0-9]+/', '.', $normalizedValue);
+ $normalizedValue = trim((string) $normalizedValue, '.');
+
+ if ('' === $normalizedValue) {
+ return 'user';
+ }
+
+ return $normalizedValue;
+ }
+}
diff --git a/tests/Service/BpnXmlAnonymizerTest.php b/tests/Service/BpnXmlAnonymizerTest.php
new file mode 100644
index 0000000..70190c0
--- /dev/null
+++ b/tests/Service/BpnXmlAnonymizerTest.php
@@ -0,0 +1,220 @@
+
+
+
+ Schlegelmilch
+ Mandy
+
+ Am Jägersteig 19
+ 40724
+ Hilden
+
+
+ mandy.schlegelmilch@ep-reisen.de
+ 02212722760
+
+ 255752
+
+
+
+ Schlegelmilch
+ Mandy
+
+ Am Jägersteig 19
+ 40724
+ Hilden
+
+
+ mandy.schlegelmilch@ep-reisen.de
+ 02212722760
+
+ 255752
+
+
+ Danielzik
+ Leon
+
+ leondanielzik@gmail.com
+
+ 306886
+
+
+
+XML;
+
+ $anonymizer = new BpnXmlAnonymizer();
+ $result = $anonymizer->anonymize($xml);
+
+ $document = new \DOMDocument();
+ $this->assertTrue($document->loadXML($result));
+
+ $xpath = new \DOMXPath($document);
+
+ $applicantName = trim((string) $xpath->evaluate('string(//anmelder/name)'));
+ $participantOneName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/name)'));
+ $participantOneEmail = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/kommunikation/email)'));
+ $applicantMobilePhone = trim((string) $xpath->evaluate('string(//anmelder/kommunikation/telefonmobil)'));
+ $participantOneMobilePhone = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/kommunikation/telefonmobil)'));
+ $participantTwoName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="2"]/name)'));
+
+ $this->assertNotSame('Schlegelmilch', $applicantName);
+ $this->assertSame($applicantName, $participantOneName);
+ $this->assertStringEndsWith('@example.com', $participantOneEmail);
+ $this->assertNotSame('02212722760', $participantOneMobilePhone);
+ $this->assertSame($applicantMobilePhone, $participantOneMobilePhone);
+ $this->assertNotSame($participantOneName, $participantTwoName);
+ $this->assertSame(2, $anonymizer->getLastIdentityCount());
+ }
+
+ public function testAnonymizeReplacesKundeAttributes(): void
+ {
+ $xml = <<<'XML'
+
+
+
+
+
+
+
+
+
+
+
+XML;
+
+ $anonymizer = new BpnXmlAnonymizer();
+ $result = $anonymizer->anonymize($xml);
+
+ $document = new \DOMDocument();
+ $this->assertTrue($document->loadXML($result));
+
+ $xpath = new \DOMXPath($document);
+
+ $firstCustomerLastName = trim((string) $xpath->evaluate('string(//kunde[@id="1"]/@name)'));
+ $firstCustomerFirstName = trim((string) $xpath->evaluate('string(//kunde[@id="1"]/@vorname)'));
+
+ $this->assertNotSame('Fromme', $firstCustomerLastName);
+ $this->assertNotSame('Björn', $firstCustomerFirstName);
+ }
+
+ public function testAnonymizeThrowsOnInvalidXml(): void
+ {
+ $anonymizer = new BpnXmlAnonymizer();
+
+ $this->expectException(\InvalidArgumentException::class);
+
+ $anonymizer->anonymize('
+
+
+ Schlegelmilch
+ Mandy
+
+ Am Jägersteig 19
+ 40724
+ Hilden
+
+
+ mandy.schlegelmilch@ep-reisen.de
+
+
+
+
+ Schlegelmilch
+ Mandy
+
+ Am Jägersteig 19
+ 40724
+ Hilden
+
+
+ mandy.schlegelmilch@ep-reisen.de
+
+
+
+
+XML;
+
+ $anonymizer = new BpnXmlAnonymizer();
+ $result = $anonymizer->anonymize($xml);
+
+ $document = new \DOMDocument();
+ $this->assertTrue($document->loadXML($result));
+
+ $xpath = new \DOMXPath($document);
+
+ $applicantName = trim((string) $xpath->evaluate('string(//anmelder/name)'));
+ $participantName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/name)'));
+
+ $this->assertSame($applicantName, $participantName);
+ $this->assertSame(1, $anonymizer->getLastIdentityCount());
+ }
+
+ public function testAnonymizeCanReuseIdentityAcrossMultipleXmlDocuments(): void
+ {
+ $requestXml = <<<'XML'
+
+
+
+ Schlegelmilch
+ Mandy
+
+ mandy.schlegelmilch@ep-reisen.de
+
+ 255752
+
+
+XML;
+
+ $responseXml = <<<'XML'
+
+
+
+
+ Schlegelmilch
+ Mandy
+
+ mandy.schlegelmilch@ep-reisen.de
+
+ 255752
+
+
+
+XML;
+
+ $anonymizer = new BpnXmlAnonymizer();
+ $anonymizedRequest = $anonymizer->anonymize($requestXml, true);
+ $anonymizedResponse = $anonymizer->anonymize($responseXml, false);
+
+ $requestDocument = new \DOMDocument();
+ $this->assertTrue($requestDocument->loadXML($anonymizedRequest));
+ $requestXpath = new \DOMXPath($requestDocument);
+ $requestName = trim((string) $requestXpath->evaluate('string(//anmelder/name)'));
+
+ $responseDocument = new \DOMDocument();
+ $this->assertTrue($responseDocument->loadXML($anonymizedResponse));
+ $responseXpath = new \DOMXPath($responseDocument);
+ $responseName = trim((string) $responseXpath->evaluate('string(//teilnehmer[@id="1"]/name)'));
+
+ $this->assertSame($requestName, $responseName);
+ $this->assertSame(1, $anonymizer->getLastIdentityCount());
+ }
+}