Skip to content

Commit cb54b2b

Browse files
committed
fix(xml): XmlDocument::Load(filename) rejects an undeclared entity too (#2361)
#2082 rejects an undeclared entity reference by scanning the RAW text, because tinyxml2 decodes the five predefined entities during parsing and "&nope;" and "&nope;" are indistinguishable afterwards. LoadXml(string) has the raw text and ran the check; Load(filename) handed the path straight to tinyxml2::LoadFile and never held the bytes, so it did not. So the identical document got two answers depending on which door it came through -- and the file door's answer LOST DATA. Accepting the reference was recoverable; reinterpreting it as literal text AND re-escaping it meant that loading and saving a document changed the document, with no diagnostic anywhere. Load now reads the file itself and parses the buffer, which is what tinyxml2::LoadFile does internally anyway (read whole file, call Parse). The read is BINARY: a text-mode read on Windows would collapse CRLF and move the offsets the scanner walks. THE FAILURE PATH DELIBERATELY STILL GOES THROUGH LoadFile. It is what produces XML_ERROR_FILE_NOT_FOUND / FILE_COULD_NOT_BE_OPENED / FILE_READ_ERROR and the ErrorStr() this exception message has always carried. Reproducing those categories from an ifstream failure would be inventing diagnostics rather than keeping them, so when the read fails the code lets tinyxml2 categorise its own failure and throws the unchanged message. TWO MUTATIONS ARE NOT MUTATIONS, and are recorded rather than counted as passes. * Text mode instead of binary is a no-op ON THIS PLATFORM. Linux makes the two modes identical, so the mutation is unobservable here by construction; the flag is a correctness measure for a platform the gate does not run on. * Parse(c_str()) without the length is SEMANTICALLY EQUIVALENT, and that was verified by probe rather than assumed. Three NUL placements were measured through both doors -- NUL mid-document, NUL after a complete document, and NUL followed by more markup containing an undeclared entity -- and all six results agree, because tinyxml2 stops at the NUL either way. The explicit length is kept because it is the clearer spelling, not because a test defends it. The probe binaries were deleted afterwards, per the build-resource policy. Three real mutations caught: drop the entity check from Load; treat an unreadable file as empty content; ReadWholeFile keeps only the first line. Two cases added (Xml 507 -> 509), including the door-equivalence property itself and an invariance case covering a legal document, a missing file, malformed content, and the undeclared-PREFIX check (#2083) that always ran at this door. Downstream, measured per SA-2 condition 5: neither cna nor mobile-eggbert references XmlDocument outside their audit notes -- zero live sites in both. Gate: 17,308 run, 17,308 passed, 0 failed, 0 skipped across 38 executables, GREEN. docs/Migration-XmlDocumentLoadEntityCheck.md
1 parent 331984a commit cb54b2b

5 files changed

Lines changed: 199 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `XmlDocument::Load(filename)` rejects an undeclared entity too (ticket #2361)
5+
6+
*2026-08-18.* `XmlDocument::Load` now runs the same undeclared-entity check that
7+
`XmlDocument::LoadXml` has run since #2082. A document loaded **from a file** used to accept
8+
`&nope;` and then silently rewrite it as `&amp;nope;` on save.
9+
10+
Landed under `docs/StandingApprovals.md` SA-5. No signature, layout or `noexcept` change.
11+
12+
---
13+
14+
## 1. What changed
15+
16+
| Call | Was | Is |
17+
|---|---|---|
18+
| `Load(path)` on a file containing `&nope;` | accepted; saved back as `&amp;nope;` | `XmlException` |
19+
| `LoadXml("<r>&nope;</r>")` | `XmlException` | `XmlException` (unchanged, since #2082) |
20+
| `Load(path)` on any legal document || **unchanged** |
21+
| `Load(path)` on a missing file | `XmlException` | `XmlException`, same text |
22+
| `Load(path)` on malformed content | `XmlException` | `XmlException`, same text |
23+
| `Load(path)` on an undeclared **prefix** | `XmlException` | unchanged — #2083 always ran here |
24+
25+
**The rewrite is the half that lost data.** Accepting the reference was recoverable; reinterpreting
26+
it as literal text *and re-escaping it* meant loading and saving a document changed the document.
27+
28+
## 2. Why the check could not run here before
29+
30+
`ThrowIfUndeclaredEntityReference` scans the **raw** text, because tinyxml2 decodes the five
31+
predefined entities during parsing — after which `&amp;nope;` and `&nope;` are indistinguishable.
32+
`LoadXml(std::string)` has the raw text. `Load(filename)` handed the path straight to
33+
`tinyxml2::XMLDocument::LoadFile` and never held the bytes.
34+
35+
`Load` now reads the file itself and parses the buffer, which is what `LoadFile` does internally
36+
anyway (read whole file, call `Parse`).
37+
38+
## 3. The failure path deliberately still goes through `LoadFile`
39+
40+
`LoadFile` is what produces `XML_ERROR_FILE_NOT_FOUND`, `XML_ERROR_FILE_COULD_NOT_BE_OPENED` and
41+
`XML_ERROR_FILE_READ_ERROR`, and the `ErrorStr()` this exception message has always carried.
42+
Reproducing those categories from an `ifstream` failure would be **inventing diagnostics** rather
43+
than keeping them, so when the read fails the code lets tinyxml2 categorise its own failure and
44+
throws the unchanged message.
45+
46+
The file is read in **binary** mode: a text-mode read on Windows would collapse CRLF and move the
47+
offsets the scanner walks.
48+
49+
## 4. Two mutations that are not mutations
50+
51+
Recorded rather than counted as passes.
52+
53+
**Text mode instead of binary** is a no-op *on this platform*. On Linux the two modes are
54+
identical, so the mutation is unobservable here by construction; the flag is a correctness measure
55+
for a platform the gate does not run on.
56+
57+
**`Parse(c_str())` without the length** is semantically equivalent, and that was verified by probe
58+
rather than assumed. Three NUL placements were measured through both doors:
59+
60+
| Document | `LoadXml` | `Load` |
61+
|---|---|---|
62+
| `<r>a\0b</r>` — NUL mid-document | throws | throws |
63+
| `<r>a</r>\0junk` — NUL after a complete document | accepted, `<r>a</r>` | accepted, `<r>a</r>` |
64+
| `<r>a</r>\0<x>&nope;</x>` | throws | throws |
65+
66+
The two doors agree in every shape, with and without the explicit length, because tinyxml2 stops
67+
at the NUL either way. The explicit length is kept because it is the clearer spelling, not because
68+
a test defends it. (The probe binaries were deleted afterwards, per the build-resource policy.)
69+
70+
| Real mutation | Caught |
71+
|---|---|
72+
| Drop the entity check from `Load` (the pre-#2361 code) ||
73+
| Treat an unreadable file as empty content | ✅ (3 tests) |
74+
| `ReadWholeFile` keeps only the first line ||
75+
76+
## 5. To migrate
77+
78+
A file containing an undeclared entity reference was never valid XML — .NET rejects it with
79+
`XmlException("Reference to undeclared entity '{0}'.")` (`XmlTextReaderImpl.cs:3829`). Declare the
80+
entity in a `<!DOCTYPE>` internal subset, or escape the ampersand as `&amp;`.
81+
82+
## 6. Downstream, measured
83+
84+
Per SA-2 condition 5: neither `cna` nor `mobile-eggbert` references `XmlDocument` outside their
85+
audit notes — **zero live sites in both**. Neither repository was modified.

modules/xml/src/System/Xml/XmlDocument.cpp

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Copyright (c) Robert Vokac and contributors
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#include "System/Xml/XmlDocument.hpp"
5+
#include <sstream>
6+
#include <fstream>
57

68
#include <cctype>
79
#include <vector>
@@ -97,6 +99,26 @@ namespace System::Xml {
9799
* This ticket is about the **undeclared** case, where .NET throws and this port
98100
* silently rewrote the document's own text.
99101
*/
102+
/**
103+
* @brief Reads @p filename whole, in binary, into @p contents.
104+
*
105+
* Ticket #2361. `XmlDocument::Load` needs the raw bytes so that
106+
* ThrowIfUndeclaredEntityReference can run at that door too, and binary mode matters:
107+
* a text-mode read on Windows would collapse CRLF and change offsets the scanner walks.
108+
*
109+
* @return @c false if the file cannot be opened or read; the caller then lets tinyxml2
110+
* categorise the failure so the exception text is unchanged.
111+
*/
112+
bool ReadWholeFile(const std::string& filename, std::string& contents) {
113+
std::ifstream in(filename, std::ios::binary);
114+
if (!in) return false;
115+
std::ostringstream buffer;
116+
buffer << in.rdbuf();
117+
if (in.bad()) return false;
118+
contents = buffer.str();
119+
return true;
120+
}
121+
100122
void ThrowIfUndeclaredEntityReference(const std::string& xml) {
101123
static const char* const predefined[] = {"amp", "lt", "gt", "quot", "apos"};
102124
const std::vector<std::string> declaredNames = DeclaredEntityNames(xml);
@@ -548,13 +570,31 @@ namespace System::Xml {
548570

549571
void XmlDocument::Load(const std::string& filename) {
550572
nodeCache_.clear();
551-
if (doc_.LoadFile(filename.c_str()) != tinyxml2::XML_SUCCESS)
573+
574+
// #2361 (2026-08-18) closed the asymmetry #2082 left here. The entity check needs the
575+
// RAW text -- tinyxml2 decodes the five predefined entities during parsing, after which
576+
// "&amp;nope;" and "&nope;" are indistinguishable -- and this door used to hand the path
577+
// straight to LoadFile without ever holding the bytes. So a document loaded FROM A FILE
578+
// accepted an undeclared entity and then silently rewrote it on save, while the identical
579+
// text through LoadXml was rejected. One door, two answers.
580+
//
581+
// This reads the file itself and then parses the buffer, which is what tinyxml2::LoadFile
582+
// does internally anyway (read whole file, call Parse). The FAILURE path deliberately
583+
// still goes through LoadFile: it is the thing that produces XML_ERROR_FILE_NOT_FOUND /
584+
// FILE_COULD_NOT_BE_OPENED / FILE_READ_ERROR and the ErrorStr() this message has always
585+
// carried, and reproducing those categories by hand would be inventing diagnostics rather
586+
// than keeping them.
587+
std::string contents;
588+
if (!ReadWholeFile(filename, contents)) {
589+
(void)doc_.LoadFile(filename.c_str()); // let tinyxml2 categorise its own failure
590+
throw XmlException("XmlDocument::Load: failed to load '" + filename + "': " +
591+
(doc_.ErrorStr() ? doc_.ErrorStr() : "unknown error"));
592+
}
593+
ThrowIfUndeclaredEntityReference(contents); // #2082, now at BOTH doors
594+
if (doc_.Parse(contents.c_str(), contents.size()) != tinyxml2::XML_SUCCESS)
552595
throw XmlException("XmlDocument::Load: failed to load '" + filename + "': " +
553596
(doc_.ErrorStr() ? doc_.ErrorStr() : "unknown error"));
554-
// #2083. The entity check (#2082) cannot run here: it needs the RAW text, and this door
555-
// hands the file straight to tinyxml2 without ever holding it. That asymmetry between
556-
// the two doors is recorded rather than hidden -- see ticket #2361.
557-
ThrowIfUndeclaredPrefix(doc_.RootElement(), {});
597+
ThrowIfUndeclaredPrefix(doc_.RootElement(), {}); // #2083, always ran here
558598
// tinyxml2::XMLDocument::LoadFile() clears and frees every previously-allocated node
559599
// (including detachedHolder_ from the constructor, or a prior Load/LoadXml call) before
560600
// parsing; recreate it now, or IsDetached()/getParentNodeProperty() etc. would compare

modules/xml/tests/System/Xml/XmlContractPinTests.cpp

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
#include <gtest/gtest.h>
2121

2222
#include <cstddef>
23+
#include <filesystem>
24+
#include <fstream>
2325
#include <memory>
2426
#include <optional>
2527
#include <string>
@@ -255,6 +257,72 @@ TEST(XmlContractPinTests, Fix2082_WhereAnAmpersandIsNotMarkupIsSkipped) {
255257
EXPECT_NO_THROW(d.LoadXml("<?pi &nope; ?><r/>"));
256258
}
257259

260+
// ===========================================================================
261+
// #2361 RESOLVED — the entity check now runs at BOTH load doors
262+
// ===========================================================================
263+
//
264+
// #2082 could only reach LoadXml(string), because the check needs the RAW text and Load(filename)
265+
// handed the path straight to tinyxml2::LoadFile without ever holding the bytes. So the identical
266+
// document was rejected through one door and accepted through the other -- and then silently
267+
// rewritten on save, which is the half that loses data.
268+
269+
namespace {
270+
// Writes @p xml to a temporary file and returns the path. Kept file-local because only these two
271+
// cases need a file at all; the repository's TMPDIR is redirected to build-tmp/ by the gate.
272+
std::string writeTempXml(const char* stem, const std::string& xml) {
273+
const std::filesystem::path path =
274+
std::filesystem::temp_directory_path() / (std::string("sr2361_") + stem + ".xml");
275+
std::ofstream out(path, std::ios::binary);
276+
out << xml;
277+
out.close();
278+
return path.string();
279+
}
280+
} // namespace
281+
282+
TEST(XmlContractPinTests, Fix2361_LoadFromAFileRejectsAnUndeclaredEntityToo) {
283+
const std::string path = writeTempXml("undeclared", "<r>&nope;</r>");
284+
XmlDocument d;
285+
EXPECT_THROW(d.Load(path), System::Xml::XmlException);
286+
287+
// The door-equivalence property, which is the actual finding: the same text must get the same
288+
// answer through both doors. Before #2361, this file loaded cleanly and then saved itself as
289+
// "<r>&amp;nope;</r>" -- the document's own text changed with no diagnostic anywhere.
290+
XmlDocument viaString;
291+
EXPECT_THROW(viaString.LoadXml("<r>&nope;</r>"), System::Xml::XmlException);
292+
std::filesystem::remove(path);
293+
}
294+
295+
TEST(XmlContractPinTests, Fix2361_LoadFromAFileKeepsEveryOtherAnswerItHad) {
296+
// The invariance rows. Reading the bytes here rather than letting tinyxml2 read them must not
297+
// change what a legal document does, and must not change what a MISSING one does.
298+
const std::string path = writeTempXml(
299+
"legal",
300+
"<!DOCTYPE r [<!ENTITY greeting \"hello\">]>"
301+
"<p:r xmlns:p='urn:x'><!-- &nope; --><![CDATA[&nope;]]>&greeting;&amp;&#65;</p:r>");
302+
XmlDocument d;
303+
ASSERT_NO_THROW(d.Load(path));
304+
EXPECT_NE(d.getDocumentElementProperty(), nullptr);
305+
306+
// A file that does not exist still reports the same failure, because the failure path
307+
// deliberately still runs through tinyxml2 so it keeps categorising its own errors.
308+
XmlDocument missing;
309+
EXPECT_THROW(missing.Load(path + ".no-such-file"), System::Xml::XmlException);
310+
311+
// ...and so does a file whose CONTENT is malformed, which is a different failure again.
312+
const std::string bad = writeTempXml("malformed", "<r><unclosed></r>");
313+
XmlDocument m;
314+
EXPECT_THROW(m.Load(bad), System::Xml::XmlException);
315+
316+
// The undeclared-PREFIX check (#2083) always ran at this door and still does.
317+
const std::string prefixed = writeTempXml("prefix", "<p:r/>");
318+
XmlDocument pfx;
319+
EXPECT_THROW(pfx.Load(prefixed), System::Xml::XmlException);
320+
321+
std::filesystem::remove(path);
322+
std::filesystem::remove(bad);
323+
std::filesystem::remove(prefixed);
324+
}
325+
258326
// ===========================================================================
259327
// #2083 RESOLVED — an undeclared namespace prefix is rejected
260328
// ===========================================================================

plan.sqlite3

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)