|
10 | 10 | use SimpleSAML\XML\Exception\RuntimeException; |
11 | 11 | use SimpleSAML\XMLSchema\Exception\SchemaViolationException; |
12 | 12 |
|
| 13 | +use function array_filter; |
13 | 14 | use function array_unique; |
| 15 | +use function array_values; |
14 | 16 | use function defined; |
15 | 17 | use function file_exists; |
16 | 18 | use function implode; |
|
28 | 30 | trait SchemaValidatableElementTrait |
29 | 31 | { |
30 | 32 | /** |
31 | | - * Validate the given DOMDocument against the schema set for this element |
| 33 | + * Validate the given Dom\Document against the schema set for this element |
32 | 34 | * |
33 | 35 | * @param \Dom\Document $document |
34 | 36 | * @param string|null $schemaFile |
35 | 37 | * |
36 | 38 | * @throws \SimpleSAML\XML\Exception\IOException |
37 | 39 | * @throws \SimpleSAML\XMLSchema\Exception\SchemaViolationException |
| 40 | + * @return \Dom\Document |
38 | 41 | */ |
39 | 42 | public static function schemaValidate(Dom\Document $document, ?string $schemaFile = null): Dom\Document |
40 | 43 | { |
41 | | - $internalErrors = libxml_use_internal_errors(true); |
42 | | - libxml_clear_errors(); |
| 44 | + $previousUseInternalErrors = libxml_use_internal_errors(true); |
43 | 45 |
|
44 | | - if ($schemaFile === null) { |
45 | | - $schemaFile = self::getSchemaFile(); |
46 | | - } |
| 46 | + try { |
| 47 | + libxml_clear_errors(); |
| 48 | + |
| 49 | + $schemaFile ??= self::getSchemaFile(); |
| 50 | + |
| 51 | + if (!$document instanceof Dom\XMLDocument) { |
| 52 | + throw new \LogicException('Schema validation requires an instance of Dom\\XMLDocument.'); |
| 53 | + } |
| 54 | + |
| 55 | + $root = $document->documentElement; |
| 56 | + if ($root === null) { |
| 57 | + throw new SchemaViolationException('The document has no document element.'); |
| 58 | + } |
| 59 | + |
| 60 | + /** |
| 61 | + * Dom\Document serialization: |
| 62 | + * - We need the exact serialized root bytes for the legacy validator. |
| 63 | + * - PHPStan may not know about Dom\Document::saveXml() depending on stubs/runtime. |
| 64 | + * |
| 65 | + * @phpstan-ignore-next-line |
| 66 | + */ |
| 67 | + $xmlRoot = $document->saveXml($root); |
| 68 | + if ($xmlRoot === false || trim($xmlRoot) === '') { |
| 69 | + throw new SchemaViolationException('Could not serialize XML root for schema validation.'); |
| 70 | + } |
47 | 71 |
|
48 | | - // Must suppress the warnings here in order to throw them as an error below. |
49 | | - $result = @$document->schemaValidate($schemaFile); |
| 72 | + // 1) Legacy validation (authoritative for now) |
| 73 | + $legacyResult = self::schemaValidateWithLegacyDom($xmlRoot, $schemaFile); |
50 | 74 |
|
51 | | - if ($result === false) { |
52 | | - $msgs = []; |
53 | | - foreach (libxml_get_errors() as $err) { |
54 | | - $msgs[] = trim($err->message) . ' on line ' . $err->line; |
| 75 | + if ($legacyResult['ok'] === false) { |
| 76 | + throw new SchemaViolationException(sprintf( |
| 77 | + "XML schema validation errors:\n - %s", |
| 78 | + implode("\n - ", $legacyResult['errors'] ?: ['no libxml errors reported']), |
| 79 | + )); |
55 | 80 | } |
56 | 81 |
|
| 82 | + // 2) New validation (temporarily disabled) |
| 83 | + // |
| 84 | + // NOTE: Dom\Document::schemaValidate() can produce false negatives in some cases. |
| 85 | + // See: https://github.com/php/php-src/issues/22219 |
| 86 | + // |
| 87 | + // $domResult = self::schemaValidateWithDomDocument($document, $schemaFile); |
| 88 | + // if ($domResult['ok'] === false) { |
| 89 | + // throw new SchemaViolationException(sprintf( |
| 90 | + // "XML schema validation errors:\n - %s", |
| 91 | + // implode("\n - ", $domResult['errors'] ?: ['no libxml errors reported']), |
| 92 | + // )); |
| 93 | + // } |
| 94 | + |
| 95 | + return $document; |
| 96 | + } finally { |
| 97 | + libxml_clear_errors(); |
| 98 | + libxml_use_internal_errors($previousUseInternalErrors); |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + |
| 103 | + /** |
| 104 | + * Validate a Dom\Document instance using a provided XML schema file. |
| 105 | + * |
| 106 | + * @param \Dom\Document $document The document to validate. |
| 107 | + * @param string $schemaFile The XML schema file to validate against. |
| 108 | + * |
| 109 | + * @return array{ |
| 110 | + * ok: bool, |
| 111 | + * errors: list<string> |
| 112 | + * } |
| 113 | + */ |
| 114 | + private static function schemaValidateWithDomDocument(Dom\Document $document, string $schemaFile): array |
| 115 | + { |
| 116 | + libxml_clear_errors(); |
| 117 | + |
| 118 | + // Must suppress warnings here in order to throw them as an error below. |
| 119 | + $ok = @$document->schemaValidate($schemaFile); |
| 120 | + |
| 121 | + return [ |
| 122 | + 'ok' => $ok, |
| 123 | + 'errors' => $ok ? [] : self::schemaCollectLibxmlErrors('no libxml errors reported'), |
| 124 | + ]; |
| 125 | + } |
| 126 | + |
| 127 | + |
| 128 | + /** |
| 129 | + * Validate a serialized XML root element using legacy DOMDocument techniques. |
| 130 | + * |
| 131 | + * @param string $xmlRoot The serialized root XML element to validate. |
| 132 | + * @param string $schemaFile The XML schema file to validate against. |
| 133 | + * |
| 134 | + * @return array{ |
| 135 | + * ok: bool, |
| 136 | + * errors: list<string> |
| 137 | + * } |
| 138 | + */ |
| 139 | + private static function schemaValidateWithLegacyDom(string $xmlRoot, string $schemaFile): array |
| 140 | + { |
| 141 | + $legacy = new \DOMDocument('1.0', 'UTF-8'); |
| 142 | + $legacy->preserveWhiteSpace = true; |
| 143 | + $legacy->formatOutput = false; |
| 144 | + |
| 145 | + libxml_clear_errors(); |
| 146 | + $loaded = $legacy->loadXML($xmlRoot, LIBXML_NONET); |
| 147 | + |
| 148 | + if ($loaded === false) { |
| 149 | + $parseErrors = self::schemaCollectLibxmlErrors('Unknown XML parse error.'); |
| 150 | + |
57 | 151 | throw new SchemaViolationException(sprintf( |
58 | | - "XML schema validation errors:\n - %s", |
59 | | - implode("\n - ", array_unique($msgs)), |
| 152 | + "Could not parse serialized XML for schema validation.\n - %s", |
| 153 | + implode("\n - ", $parseErrors), |
60 | 154 | )); |
61 | 155 | } |
62 | 156 |
|
63 | | - libxml_use_internal_errors($internalErrors); |
64 | 157 | libxml_clear_errors(); |
| 158 | + $ok = $legacy->schemaValidate($schemaFile); |
| 159 | + |
| 160 | + return [ |
| 161 | + 'ok' => $ok, |
| 162 | + 'errors' => $ok ? [] : self::schemaCollectLibxmlErrors('no libxml errors reported'), |
| 163 | + ]; |
| 164 | + } |
| 165 | + |
| 166 | + |
| 167 | + /** |
| 168 | + * Collect and format errors reported by libxml during XML processing. |
| 169 | + * |
| 170 | + * @param string $fallback Fallback message if no errors are reported. |
| 171 | + * |
| 172 | + * @return list<string> A list of error messages. |
| 173 | + */ |
| 174 | + private static function schemaCollectLibxmlErrors(string $fallback): array |
| 175 | + { |
| 176 | + $msgs = []; |
| 177 | + foreach (libxml_get_errors() as $err) { |
| 178 | + $message = trim($err->message); |
| 179 | + $line = $err->line; |
| 180 | + $msgs[] = $line > 0 ? sprintf('%s on line %d', $message, $line) : $message; |
| 181 | + } |
| 182 | + |
| 183 | + $msgs = array_values(array_unique(array_filter($msgs))); |
65 | 184 |
|
66 | | - return $document; |
| 185 | + return $msgs === [] ? [$fallback] : $msgs; |
67 | 186 | } |
68 | 187 |
|
69 | 188 |
|
|
0 commit comments