Skip to content

Commit 2aab5b2

Browse files
fix(parser): stop swallowing validation errors
loadFromString() wrapped its whole body in `catch (\Exception)` and rethrew everything as KmlParserException::failedToParse(). A validation failure raised by KmlValidator therefore reached the caller as a generic parse error, so "Invalid longitude value: 181" and "malformed XML" were indistinguishable by type. Validation exceptions now propagate untouched and malformed XML surfaces as invalidXml(), which until now was unreachable because the catch block below it re-wrapped it immediately. The content was also parsed twice, once by the validator and once by the parser, doubling the work and the peak memory of every load. The validator gained validateDocument(), which takes an already parsed document, and the parser builds the SimpleXMLElement once and hands it over. validate() keeps its string signature and delegates. Both classes flipped libxml_use_internal_errors(true) on and never restored it, changing libxml error handling for the rest of the application. The previous value is now saved and restored in a finally, on the success and failure paths alike. failedToParse() is no longer thrown and is marked deprecated rather than removed, so callers referencing it keep working for one cycle.
1 parent d768d0b commit 2aab5b2

5 files changed

Lines changed: 134 additions & 39 deletions

File tree

src/Exceptions/KmlParserException.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ public static function invalidXml(string $message): self
1919
return new self("XML parsing error: {$message}");
2020
}
2121

22+
/**
23+
* @deprecated Nothing throws this any more. Malformed XML now surfaces as
24+
* invalidXml(), and a validation failure keeps its own
25+
* KmlException instead of being wrapped. Kept for one cycle so
26+
* callers referencing it do not break; slated for removal in
27+
* the next major.
28+
*/
2229
public static function failedToParse(string $message): self
2330
{
2431
return new self("Failed to parse KML content: {$message}");

src/KmlParser.php

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,25 +45,23 @@ public function loadFromFile(string $path): self
4545
*/
4646
public function loadFromString(string $content): self
4747
{
48-
try {
49-
$this->validator->validate($content);
50-
libxml_use_internal_errors(true);
51-
$this->xml = new SimpleXMLElement($content);
52-
53-
$errors = libxml_get_errors();
54-
if ($errors) {
55-
$errorMessage = $errors[0]->message;
56-
libxml_clear_errors();
57-
throw KmlParserException::invalidXml($errorMessage);
58-
}
48+
$previous = libxml_use_internal_errors(true);
5949

60-
$this->xml->registerXPathNamespace('kml', $this->namespace);
61-
62-
return $this;
63-
} catch (\Exception $e) {
50+
try {
51+
$xml = new SimpleXMLElement($content);
52+
} catch (Exception $e) {
53+
throw KmlParserException::invalidXml($e->getMessage());
54+
} finally {
6455
libxml_clear_errors();
65-
throw KmlParserException::failedToParse($e->getMessage());
56+
libxml_use_internal_errors($previous);
6657
}
58+
59+
$this->validator->validateDocument($xml);
60+
61+
$this->xml = $xml;
62+
$this->xml->registerXPathNamespace('kml', $this->namespace);
63+
64+
return $this;
6765
}
6866

6967
/**

src/Validators/KmlValidator.php

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,52 @@ class KmlValidator
1212

1313
protected SimpleXMLElement $xml;
1414

15+
/**
16+
* Parse and validate raw KML content.
17+
*
18+
* @throws KmlException
19+
*/
1520
public function validate(string $content): void
1621
{
17-
libxml_use_internal_errors(true);
22+
$previous = libxml_use_internal_errors(true);
1823

1924
try {
20-
$this->xml = new SimpleXMLElement($content);
21-
22-
$namespaces = $this->xml->getDocNamespaces();
23-
if (! isset($namespaces['']) || $namespaces[''] !== $this->namespace) {
24-
throw new KmlException('Invalid or missing KML namespace');
25-
}
26-
27-
$this->xml->registerXPathNamespace('kml', $this->namespace);
28-
29-
if (empty($this->xml->Document)) {
30-
throw new KmlException('Missing required element: Document');
31-
}
32-
33-
$placemarks = $this->xml->xpath('//kml:Placemark');
34-
if (! empty($placemarks)) {
35-
foreach ($placemarks as $placemark) {
36-
$this->validatePlacemark($placemark);
37-
}
38-
}
39-
} catch (KmlException $e) {
40-
throw $e;
25+
$xml = new SimpleXMLElement($content);
4126
} catch (\Exception $e) {
4227
throw new KmlException('Invalid KML content: '.$e->getMessage());
4328
} finally {
4429
libxml_clear_errors();
30+
libxml_use_internal_errors($previous);
31+
}
32+
33+
$this->validateDocument($xml);
34+
}
35+
36+
/**
37+
* Validate an already parsed KML document.
38+
*
39+
* Callers that have parsed the document themselves should use this instead
40+
* of validate(), so the content is not parsed twice.
41+
*
42+
* @throws KmlException
43+
*/
44+
public function validateDocument(SimpleXMLElement $xml): void
45+
{
46+
$this->xml = $xml;
47+
48+
$namespaces = $xml->getDocNamespaces();
49+
if (! isset($namespaces['']) || $namespaces[''] !== $this->namespace) {
50+
throw new KmlException('Invalid or missing KML namespace');
51+
}
52+
53+
$xml->registerXPathNamespace('kml', $this->namespace);
54+
55+
if (empty($xml->Document)) {
56+
throw new KmlException('Missing required element: Document');
57+
}
58+
59+
foreach ($xml->xpath('//kml:Placemark') ?: [] as $placemark) {
60+
$this->validatePlacemark($placemark);
4561
}
4662
}
4763

tests/ExceptionsTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
$parser = new KmlParser;
2222

2323
$parser->loadFromString('invalid xml content');
24-
})->throws(KmlParserException::class, 'Failed to parse KML content');
24+
})->throws(KmlParserException::class, 'XML parsing error');
2525

2626
it('throws exception when KMZ file not found', function () {
2727
$extractor = new KmzExtractor;

tests/ParsingErrorsTest.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
use PlinCode\KmlParser\Exceptions\KmlException;
4+
use PlinCode\KmlParser\Exceptions\KmlParserException;
5+
use PlinCode\KmlParser\KmlParser;
6+
use PlinCode\KmlParser\Validators\KmlValidator;
7+
8+
function kmlWithLatitude(string $latitude): string
9+
{
10+
return <<<XML
11+
<?xml version="1.0" encoding="UTF-8"?>
12+
<kml xmlns="http://www.opengis.net/kml/2.2">
13+
<Document>
14+
<Placemark>
15+
<Point>
16+
<coordinates>7.7300965,{$latitude},0</coordinates>
17+
</Point>
18+
</Placemark>
19+
</Document>
20+
</kml>
21+
XML;
22+
}
23+
24+
it('surfaces a validation failure with its own type and message', function () {
25+
try {
26+
(new KmlParser)->loadFromString(kmlWithLatitude('91'));
27+
} catch (KmlException $e) {
28+
expect($e)->not->toBeInstanceOf(KmlParserException::class)
29+
->and($e->getMessage())->toBe('Invalid latitude value: 91');
30+
31+
return;
32+
}
33+
34+
$this->fail('No exception was thrown.');
35+
});
36+
37+
it('reports malformed XML as a parsing error', function () {
38+
try {
39+
(new KmlParser)->loadFromString('<kml><unclosed>');
40+
} catch (KmlParserException $e) {
41+
expect($e->getMessage())->toStartWith('XML parsing error: ');
42+
43+
return;
44+
}
45+
46+
$this->fail('No exception was thrown.');
47+
});
48+
49+
it('restores the libxml error handling mode after a successful load', function () {
50+
$before = libxml_use_internal_errors(false);
51+
52+
(new KmlParser)->loadFromString(kmlWithLatitude('45.8635629'));
53+
54+
expect(libxml_use_internal_errors($before))->toBeFalse();
55+
});
56+
57+
it('restores the libxml error handling mode after a failed load', function () {
58+
$before = libxml_use_internal_errors(false);
59+
60+
try {
61+
(new KmlParser)->loadFromString('<kml><unclosed>');
62+
} catch (KmlParserException) {
63+
// The state has to be restored on the failure path too.
64+
}
65+
66+
expect(libxml_use_internal_errors($before))->toBeFalse();
67+
});
68+
69+
it('validates an already parsed document without reparsing it', function () {
70+
$xml = new SimpleXMLElement(kmlWithLatitude('91'));
71+
72+
expect(fn () => (new KmlValidator)->validateDocument($xml))
73+
->toThrow(KmlException::class, 'Invalid latitude value: 91');
74+
});

0 commit comments

Comments
 (0)