Fix/356 shadow copy - #428
Conversation
python-xmlsec hands lxml's raw libxml2 node pointers straight to xmlsec1. That only works when lxml and xmlsec link the same libxml2 at runtime; when they differ (e.g. lxml's bundled libxml2 vs a system/homebrew one), mixing the two libraries' nodes corrupts memory and segfaults. Rework the template functions to run each xmlsec call on a private "shadow" copy of the element: PyXmlSec_LxmlShadowBegin serializes the element with lxml's own libxml2 and re-parses the bytes with ours, the xmlsec call mutates that copy, and PyXmlSec_LxmlShadowEnd reflects the change back into the live lxml tree. Only bytes ever cross the boundary, never node pointers. Converting a function is four lines (Begin / the unchanged xmlsec call on shadow.root / End) with no per-function callback or context struct. End detects what the call did generically, by tagging pre-existing nodes through the libxml2 _private field, and covers the whole xmlSecTmpl* family: - plain adds graft the new subtree at the position xmlsec chose, - calls that create intermediate ancestors (add_transform's <Transforms>) graft the topmost new node and return the inner one, - find-or-create calls (ensure_key_info) return the existing live node and mirror any attributes set on it, instead of duplicating it. Reflection dumps the whole mutated copy, not just the new node, so ancestor-declared namespaces and xmlsec's "\n" formatting siblings survive the round-trip and signatures stay byte-identical; the one text slot xmlsec may touch before the new node (parent text / previous sibling tail) is mirrored explicitly. Child indices count exactly the node types lxml exposes as children, so comments/PIs in templates don't skew paths. add_reference, add_transform and ensure_key_info - one per reflect shape - are converted; the rest of template.c is mechanical follow-up. ds.c, enc.c and tree.c still pass raw nodes, so the import-time version guard stays, now with a PYXMLSEC_SKIP_VERSION_CHECK opt-out used to exercise the shadow paths under a mismatch. Validated under a real 2.14<->2.15 libxml2 mismatch: full suite green (288 passed) including the per-test leak detector, plus a 10k-iteration loop over the three converted functions with no crash, no RSS growth and byte-identical output. See developer.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
developer.md explains why the shadow copy exists and how the reflection works; converting-functions.md is the operational companion: pick a function, classify the xmlSecTmpl* call against the shapes the reflect covers (including the int-returning and detached-create shapes that need extra care), apply the mechanical binding edit, and the test / mismatch-validation checklist — including the tests/base.py leak detector gotcha. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The problem, the shadow-copy idea, what the change consists of, why this design replaced the first (op/ctx) attempt, validation results, and what remains. Entry point to developer.md (design detail) and converting-functions.md (rollout how-to). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The import guard only lets matched libxml2 versions run, and for them the old direct behavior — xmlsec mutating lxml's nodes — is safe; that is what shipped for years. Yet every converted function paid the full shadow round-trip (serialize with lxml, re-parse, dump, re-parse with lxml) even in that case, four serializations per call for zero safety benefit. That is noise for the small xmlSecTmpl* trees, but would become a real regression when the pattern reaches sign/encrypt on whole documents. Make Begin/End dual-path, decided once at import: on matched versions Begin aliases the live _c_node (no copy) and End just wraps the node xmlsec returned, machine-identical to the pre-shadow code; the shadow round-trip activates only under a mismatch — or when PYXMLSEC_FORCE_SHADOW is set, which CI now uses to run the suite a second time so the shadow path stays exercised on matched builds. Call sites cannot tell the difference, and every function converted later inherits both paths. While in there, resolve lxml.etree's tostring/fromstring once at module init instead of importing lxml.etree on every shadow crossing. Benchmark (matched static build, create + add_reference + add_transform + ensure_key_info per iteration): fast path 8.6us vs shadow 72.3us, ~8x. Validated three ways, with byte-identical template output across the two paths: full suite on the fast path and on PYXMLSEC_FORCE_SHADOW=1 (matched static build, 300 passed / 6 skipped each), and full suite under a real 2.14 vs 2.15 mismatch (288 passed, 6 skipped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Roll the shadow pattern out beyond templates so no binding hands an lxml node to xmlsec anymore (fast path unchanged on matched libxml2): - template.c: all remaining functions, incl. the create shape (BeginNewDoc/EndNewDoc builds the detached template in a private doc) and the status-int C14N helper (FindFresh locates the created node). - tree.c: finders map results back by path (EndFind, None on not-found; find_parent shadows the whole tree); add_ids records id-attribute specs instead of writing lxml's ID hash with our libxml2. - ds.c: sign/verify run on a whole-document copy (BeginDoc) with the recorded IDs replayed so #id references resolve; sign reflects all mutation sites (DigestValue/SignatureValue/KeyInfo) via ReflectAll, verify just discards the copy. - enc.c: encrypt_binary/encrypt_uri reflect the mutated template; encrypt_xml/decrypt re-parse everything into one copy and reflect the replacement through lxml (element, content, or returned bytes). Replacing the document root cannot be expressed through lxml's API and raises a clear error on the shadow path. ReflectAll is two-phase (prefetch payloads from the re-parsed copy in its final state, then apply to the live tree in document order) because each graft moves a node out of the copy and would invalidate the indices later sites resolve through. Two template tests now attach the created template before asserting liveness: a shadow-created template lives in its own document until grafted, as lxml cannot express the raw path's "detached node inside an existing document". Validated under a real 2.14<->2.15 mismatch (full suite, 10k-iteration sign/verify/encrypt/decrypt loop, flat RSS, byte-identical output) and on a matched static wheel with and without PYXMLSEC_FORCE_SHADOW. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review pass over the shadow-copy branch. The design stands; the helper layer had grown two reflection engines (End for single-site template calls, ReflectAll for multi-site sign/encrypt) plus five single-use entry points. Collapse them into one engine so every binding uses the same few lines: - One reflection walk: fresh nodes are grafted at their child index, and every parent that gained a node has its text slots (.text and the children's .tail) synced wholesale from the re-parsed copy. The sync replaces the old per-element "mirror" heuristic and also covers text the call removed (encrypt Type=Content), which no fresh-node scan can see. - End maps the result node back after reflecting: grafted, or found (attributes synced; a renamed prefix swaps in the copy's version). It serves every Begin flavour, so EndNewDoc and EndReplace go away. - Reflect(shadow, rv, error) ends status-returning calls in one line (sign, encrypt_binary, encrypt_uri, C14N inclusive namespaces); FindFresh is gone. - BeginDoc replays the registered IDs itself; ReplayIds and DumpCopy become static. Header: 15 -> 10 functions. - Our re-parse uses XML_PARSE_HUGE and a cached huge_tree lxml parser, so a CipherValue above libxml2's 10 MB text-node limit reflects (12 MB encrypt_binary/decrypt round trip verified); path depth 256. - The enc.c shadow bodies clear XMLSEC_ENC_RETURN_REPLACED_NODE explicitly: xmlsec must free replaced nodes with our libxml2 before the copy is discarded (it already did, implicitly). Also fixes a pre-existing crash on the raw path, found by the new tests: encrypt_xml with Type=Content on text or mixed content put text nodes into xmlsec's replaced-node list, and PyXmlSec_ClearReplacedNodes handed them to lxml's elementFactory; lxml frees the text siblings that follow an element when its proxy is released, so the next list entry was freed under our feet. Each node is now severed from the chain before release and non-element nodes are freed directly. Tests: decrypt of Type=Content with whitespace around EncryptedData and with mixed content, register_id sign/verify round trip, prefix rename on a live KeyInfo. Docs consolidated into developer.md (356-summary.md and converting-functions.md removed). Validated: real 2.14.6/2.15.3 mismatch 292 passed / 6 skipped, 10k loop with flat RSS and byte-identical output; matched static wheel 304 / 6 on both the fast path and PYXMLSEC_FORCE_SHADOW=1.
An audit of every binding that accepts an lxml element (30 of them) confirmed that all of them run their xmlsec call on a shadow copy when the shadow mode is on: 26 through the Begin/End helpers, and four (register_id, add_ids, encrypt_xml, decrypt) through an explicit IsActive() switch whose raw body only runs on matched libxml2. Nothing was left to convert, but the invariant was enforced by review alone: on matched libraries a binding that bypassed the switch would still pass the test suite. tests/test_shadow_audit.py now scans src/*.c and fails when a function that takes an lxml element neither calls a PyXmlSec_LxmlShadowBegin* helper nor is one of the listed dual-body functions, when a dual-body function touches a raw node before consulting IsActive(), or when ->_c_node / ->_c_doc appears outside the allowlisted functions (the dual bodies, the helpers' fast-path branches and the ID registry). Two tests prove the checker flags synthetic bad code and one guards the scanner against a broken regex. developer.md states the rules.
There was a problem hiding this comment.
🟡 Changes recommended
ID replay can resolve unintended nodes, live registrations can be evicted, and shadow mode introduces root-operation API regressions.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Introduces shadow copies to isolate lxml nodes from ABI-incompatible libxml2 instances.
Changes:
- Adds shadow creation, reflection, and ID registration infrastructure.
- Routes template, tree, signature, and encryption operations through shadows.
- Adds documentation, regression tests, source auditing, and forced-shadow CI runs.
File summaries
| File | Description |
|---|---|
src/lxml.c |
Implements shadow copying, reflection, and ID replay. |
src/lxml.h |
Declares the shadow API. |
src/template.c |
Converts template operations to shadows. |
src/tree.c |
Converts tree searches and ID registration. |
src/ds.c |
Converts signing and verification. |
src/enc.c |
Converts encryption and decryption. |
tests/test_templates.py |
Tests reflected template mutations. |
tests/test_shadow_audit.py |
Audits shadow invariants. |
tests/test_enc.py |
Tests reflected content decryption. |
tests/test_ds.py |
Tests registered-ID signing. |
developer.md |
Documents the architecture and limitations. |
.github/workflows/macosx.yml |
Adds forced-shadow testing. |
.github/workflows/linuxbrew.yml |
Adds forced-shadow testing. |
.github/scripts/manylinux_build_and_test.sh |
Adds forced-shadow wheel testing. |
Review details
Suppressed comments (2)
src/lxml.c:573
- Replaying an ID spec from the document root changes the scope of both APIs:
register_id(node, ...)is supposed to register only that node, andadd_ids(node, ...)only that subtree. Here, an earlier element elsewhere in the document with the same attribute/value can winxmlGetID, so a#idsignature may resolve or verify against a node the caller never registered. Store the registered node/subtree path with each spec and replay only within that scope, preserving duplicate-ID errors.
// Registers every attribute named `name` (under `ns` when given) in the copy
// as an XML ID — a superset of the fast path's registrations (single node for
// register_id, subtree for add_ids), which is the safe direction: it mirrors
// what xmlSecAddIDs does from the root.
static void PyXmlSec_LxmlShadowApplyIdSpec(xmlDocPtr doc, xmlNodePtr n, const xmlChar* name, const xmlChar* ns) {
src/enc.c:592
- A root
EncryptedDataproduced withType=Elementdecrypts successfully on the existing path but is unconditionally rejected in shadow mode. The replacement is an lxml-owned parsed element and can be installed with the document tree's_setroot()API; otherwise decrypt behavior unexpectedly depends on how libxml2 was linked.
if (parent == Py_None) {
// Decryption replaced the document root; lxml's API offers no way to
// swap a document's root element, so this cannot be reflected (the
// live tree is untouched). Re-parse the document into a wrapper or
// decrypt a non-root EncryptedData instead.
PyXmlSec_LxmlShadowDiscard(&shadow);
PyErr_SetString(PyXmlSec_Error,
"decrypting the document root is not supported when lxml and xmlsec use different libxml2 libraries");
- Files reviewed: 14/14 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The shadow path refused to encrypt the document root with Type=Element and to decrypt a root EncryptedData, since lxml offers no way to swap a document's root element (_ElementTree._setroot only rebinds that one Python object). The reflection now morphs the live root element in place into the re-parsed replacement through lxml's public API: it is emptied, stripped of its namespace declarations, given exactly the replacement's (a temporary child pins the default namespace, which the tag setter never declares and cleanup_namespaces would drop as unused), then renamed and refilled. Both shadow bodies in enc.c return the node itself for a root, matching the raw path's contract of returning the new root. A root replaced by anything but a single element is refused with xmlsec.Error.
The shadow's register_id validated the attribute with node.get(), which only finds an unqualified attribute, while the fast path's xmlHasProp() matches by local name whatever the namespace. A valid register_id(node, "Id") for a namespaced Id could therefore raise "missing attribute." in shadow mode only. Without id_ns the lookup now scans the element's attribute names and compares local names, as xmlHasProp does — and as the ID replay onto the copy already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shadow's id registry held no reference to the documents it recorded, so entries could only be validated by a stored _c_doc address and the dict was capped at 4096 to bound growth from dead documents. At the cap it deleted an arbitrary entry, which could be a live document's: a long-running process that keeps an early signed document and registers ids for 4096 later ones silently stopped resolving that document's #id references. An entry now keeps a strong reference to its _Document. The key, the document's address, can then never go stale — the entry itself keeps that address occupied — so the _c_doc check is gone; and liveness becomes decidable without weak references, which lxml's classes refuse: when the registry holds the only reference to a document, nothing can present that document to a binding again, so the entry is dead. Every new registration first drops those entries, which bounds both the registry and the documents it pins by the documents still in use. No cap, and nothing reachable is ever evicted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit matched raw source lines, so a comment naming PyXmlSec_LxmlShadowBegin or ->_c_node — the comments that explain those very crossings — counted as the code itself. A binding could lose its Begin call and still pass the invariant because a comment beside it mentioned one, and a raw access inside an error string would have been reported as a real one. Comments and string/char literal bodies are now blanked (lines and columns preserved) before either rule is applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scan enforced the shadow invariants by pattern-matching src/*.c, which made it a parser of C that it never was: a comment or literal read as code until the previous commit, and any body it failed to delimit was attributed to its neighbour. The invariants stay — developer.md now states them as a review rule, with the grep that lists every raw crossing — but they are no longer asserted by the test suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
ID replay introduces a security-sensitive resolution error, while reflection and node cleanup contain correctness and memory-safety defects.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 4
- Review effort level: Balanced
The shadow's id registry recorded only the attribute name and namespace, so the replay registered every matching attribute of the private copy. That is not a harmless superset: an element the caller never registered, sharing the id value and coming first, claims the value at xmlGetID and the intended element is skipped — a "#id" signature reference then resolves to content the caller never vouched for. The registry now keeps the registered elements themselves, and the replay applies each spec at the copy's counterpart of its element: that node alone for register_id, its subtree for add_ids (the scope xmlSecAddIDs walks). Liveness stays decidable without weak references — every element proxy holds a reference to its document, so an entry is dead when the document's reference count is exactly what the registry holds and nothing else holds its elements. Two other faithfulness gaps in the copy, from the same review: - the whole-document and subtree parses dropped the source document's base URI; they now carry docinfo.URL, so relative references resolve where they did before the copy. - the reflection recorded a text-slot sync only for a parent that gained a fresh node, so a call that *removed* a node and left nothing fresh behind (an EncryptedData decrypting to empty content) went unnoticed. Each pre-existing node is now tagged with its child count, and a parent whose tagged children changed is synced too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
for more information, see https://pre-commit.ci
… into fix/356-shadow-copy
There was a problem hiding this comment.
🟡 Changes recommended
Shadow parsing, ID-registry lifetime, and partial-registration paths contain unresolved correctness issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 5
- Review effort level: Balanced
) Five findings from the review of the shadow-copy work: - A subtree of a document that declares entities could not be copied at all: `tostring(element)` emits `&name;` without the internal subset that declares it, so every subtree `Begin` — the public finders included — failed with "cannot make a private copy of the element" on a tree parsed with `resolve_entities=False`. Begin now copies the whole document when there is an internal subset and cuts the copy back to the element, so the declarations travel with the references. - The reflect parser expanded entity references, so the re-parse of the copy had a different child structure than the copy the sites were collected from and the child-index paths addressed the wrong nodes (IndexError on a graft past an `_Entity` sibling). It now parses with `resolve_entities=False`. - `Begin` leaked the private document when tagging failed: callers read a negative result as "no shadow to discard". It now discards through one failure path, as BeginDoc already did. - The id registry's liveness test assumed every registered proxy still references the entry's document. lxml lets an element be adopted into another tree, and the resulting offset either pinned the old entry forever (50k adoptions: 24 -> 71 MiB, now flat) or, when an unrelated reference made up the difference, pruned a registration for a document still in use. The expected count now comes from each proxy's current owner, and re-registering an adopted node vacates its old slot. - `add_ids` recorded each name before validating the rest, so `add_ids(node, ['ID', 1])` raised but left `ID` registered — where the fast path validates the whole list before touching the document. The names are materialized and validated first, and the recording is now all-or-nothing. Regression tests cover the four reachable ones; all four fail on the pre-fix build. Suite: 304 passed under the mismatch, 316 on the matched wheel plain and with PYXMLSEC_FORCE_SHADOW=1.
There was a problem hiding this comment.
🟡 Changes recommended
Shadow reflection misses existing text updates, attached templates diverge between modes, and duplicate IDs can resolve to the wrong element.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/lxml.c:984
- The shadow replay does not implement the fast path's duplicate-ID check. If two nodes with the same value are registered (for example,
register_id(decoy, 'ID')followed byregister_id(real, 'ID')), the fast path raisesduplicated id., but replay silently keeps the firstxmlGetID()entry and signs/verifies against it. That mode-dependent behavior can redirect a#idreference to the wrong element. Make replay fail when the existing ID belongs to a different attribute and propagate that failure fromBeginDoc.
if (xmlGetID(doc, value) == NULL) {
xmlAddID(NULL, doc, value, attr);
}
- Files reviewed: 14/14 changed files
- Comments generated: 2
- Review effort level: Balanced
Two findings from the review of the shadow path. `encrypt_xml` encrypts a copy of the template, so a template attached in the target's own document stayed where it was and the result gained a second, empty <EncryptedData/> — a tree shape that depended on which libxml2 the extension is linked against. The raw path hands the template node itself to xmlsec, which moves it. The live template is now unlinked after the reflection when it is still under the live document root, with its tail text re-homed onto the previous sibling (or the parent's text) the way libxml2's xmlReplaceNode leaves it. The mutation detector recorded a sync for a parent that gained a fresh node or lost a tagged one, but not for a text node rewritten in place. Writing a value into an element goes through xmlNodeSetContent, which frees the old text node and parses a fresh one, so re-signing over an existing DigestValue was already caught; appending to a text node (xmlNodeAddContent onto a trailing text child) is not. Each tag now carries an FNV-1a fingerprint of a text node's content, so the invariant holds without depending on that libxml2 internal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Shadow ID replay can silently resolve duplicate ID values to the wrong element.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/enc.c:616
- The successful shadow reflection path for
encrypt_uriis not covered. The existingtest_encrypt_uricallsctx.encrypt_binary(...), so only the new failure path reachesencrypt_uri; a regression that fails to copy the generatedCipherValueback would pass the suite even underPYXMLSEC_FORCE_SHADOW. Update that test to invokeencrypt_uriand assert the reflected value.
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
One finding from the review of the shadow path. `register_id` raises "duplicated id." on the raw path when the id value it is asked to register is already registered for another attribute, and under the shadow it silently recorded the spec instead: the replay's first-wins rule then skipped it, so the call succeeded while the caller's `#id` reference resolved to whichever element claimed the value first — an earlier registration, a DTD-declared id attribute or an xml:id. The check is back at the call that makes the registration, where the fast path has it, rather than at the replay: raising from a later sign/verify would report the collision from the wrong call and then from every later call on that document. `xmlGetID(doc, value) != attr` is assembled from the two places a registration can live under the shadow — what lxml's own parse declared, read back through XPath's id(), which crosses the library boundary as strings and elements only, and what earlier register_id or add_ids calls recorded in the registry. Re-registering the same attribute stays the no-op the fast path performs, and add_ids keeps xmlSecAddIDs' own first-wins semantics, which never raise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Shadow mode still has ID-identity parity bugs, external-DTD handling gaps, and unbounded recursive traversals.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
src/lxml.c:1017
- Element identity is not enough to identify the attribute already registered as an ID. For
<A xml:id="dup" ID="dup"/>,register_id(A, "ID")must raiseduplicated id.becausexmlGetID()points to the distinctxml:idattribute; this check sees the same element and accepts the registration, after which replay silently leavesxml:idas the winner. Compare the requested attribute's identity, not only its owning element.
// lxml hands out one proxy per node, so identity is node identity.
taken = match != (PyObject*)element;
src/lxml.c:1071
- This treats attributes with the same local name as the same attribute even when
id_nsselects different namespaces. Registering{urn:a}Id="dup"and then{urn:b}Id="dup"on one element is accepted here as an idempotent registration, while the fast path sees distinctxmlAttrPtrs and raisesduplicated id.; replay then silently keeps the first attribute. Include the selected namespace/attribute identity in theminecomparison.
// The same attribute of the same element: registering it again is
// the no-op the fast path performs, whoever recorded it first. The
// names alone decide it, as xmlHasProp's own matching does.
mine = strcmp(spec_name, name) == 0;
- Files reviewed: 14/14 changed files
- Comments generated: 2
- Review effort level: Balanced
Two findings from the review of the shadow path. The walks over the private copy — CountNodes, TagNodes, CollectSites — recurse once per level of nesting and were bounded only by what the parse that produced the copy accepted. libxml2 2.14 and later cap a parse at 2048 levels even under XML_PARSE_HUGE, so on those the copy can never be deeper; older libxml2 lifts its cap entirely under HUGE, and lxml can build and serialize a tree far deeper than it would parse, so a document built element by element reached the walks with no ceiling at all and overran the C stack (checked against 2.9.13: SIGSEGV at 400000 levels on the main thread, at 20000 on a 512 KB thread stack). The walks now carry a depth and refuse a document nested deeper than 2048 levels, the same ceiling modern libxml2 enforces, so the failure is clean and identical across libxml2 versions. CollectSites is checked as well as Mark: the call being reflected can graft subtrees of its own into the copy. The copy was parsed without loading any external DTD subset, so the ID attributes such a DTD types were untyped in it and a #id reference over one failed to resolve where the raw path signs — the serialization keeps only the DOCTYPE reference to the declarations. The copy is now parsed with XML_PARSE_DTDLOAD exactly when lxml itself loaded an external subset (docinfo.externalDTD), fetching the same local file the document names, resolved against the base URI the copy already carries, with the network still off. Never DTDATTR: libxml2 fills in defaulted attributes only under that flag, and the copy has to stay what lxml serialized. Suite: 314 passed / 6 skipped under the mismatch, 326 on the matched wheel plain and with PYXMLSEC_FORCE_SHADOW=1; the external-DTD test fails on the pre-fix build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Shadow mode still has ID-collision parity bugs, loses external-DTD ID typing, and can overflow the C stack on deeply nested trees.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
src/lxml.c:355
- The shadow parser accepts
huge_treeinputs, but this recursive count (and the later recursiveTagNodes/CollectSiteswalks) has no depth guard. On libxml2 versions whereXML_PARSE_HUGEremoves the nesting cap, a deeply nested programmatically built tree can overflow the C stack before the 256-step path code gets a chance to reject it. Make these traversals iterative or enforce a depth limit before every recursive descent.
static int PyXmlSec_LxmlShadowCountNodes(xmlNodePtr node) {
int count = 0;
for (; node != NULL; node = node->next) {
count += 1 + PyXmlSec_LxmlShadowCountNodes(node->children);
}
src/lxml.c:251
- This parse never loads an external DTD subset. If the source was parsed with an external DTD that declares an ID attribute, serialization keeps the SYSTEM doctype but the private document loses the ID typing, so
#idsign/verify can succeed on the fast path and fail in shadow mode. Carry whether lxml loaded an external subset and parse the copy withXML_PARSE_DTDLOADwhile retaining the source URL andXML_PARSE_NONET, or otherwise replay those declared IDs.
#define PYXMLSEC_SHADOW_PARSE_OPTIONS (XML_PARSE_NONET | XML_PARSE_HUGE)
src/lxml.c:1071
- This treats equal local names as the same attribute even when
register_idsupplied an explicit namespace. If one element hasa:Id="dup"andb:Id="dup", registering theaattribute and then thebattribute is accepted here as an idempotent registration, whereas the fast path selects two distinct attributes withxmlHasNsPropand raisesduplicated id.. Pass the requested namespace into this check and require namespace equality when it is explicit.
// The same attribute of the same element: registering it again is
// the no-op the fast path performs, whoever recorded it first. The
// names alone decide it, as xmlHasProp's own matching does.
mine = strcmp(spec_name, name) == 0;
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
register_id's shadow body decided "the attribute being registered is already the declared one" from the *element* XPath id() returned. An element can carry the value twice: <N xml:id="dup" ID="dup"/> answers N whichever attribute is asked about, so register_id(N, 'ID') recorded a spec where the fast path registers ID, finds xml:id holding the value and raises "duplicated id.". The registry half had the same shape, by local name only: a spec for a:Id and a call for Id are two attributes, and the second cannot win the lookup either. Both halves now compare attributes. PyXmlSec_LxmlAttrValue became AttrFind, which also reports the lxml key of the attribute xmlHasProp/xmlHasNsProp would pick, and a registry spec is resolved through it on the node it was recorded for. For the declared half, a match on the element itself settles nothing when two of its attributes carry the value, and no lxml API names the declared attribute — id() names elements, and an ATTLIST without an ELEMENT leaves lxml's DTD objects empty. It is named instead by copying the document the way a whole-document shadow copies it (same base URL, same subsets) and reading that copy's own id hash. Only a value already declared for the element, on an element carrying it twice, pays for that copy. Nine collision shapes now answer identically on the raw path, the forced shadow and the mismatch build: xml:id beside ID, a DTD-declared ID beside a twin attribute (both directions), and namespaced/ unqualified registry pairs in both orders. Tests in tests/test_ds.py: the two "rejects" cases fail on the pre-fix build; the two "accepts" cases guard the no-op against an over-eager check. Validation: 318 passed / 6 skipped on the mismatch build, also at PYXMLSEC_TEST_ITERATIONS=50; 330 / 6 on the matched static wheel, plain and with PYXMLSEC_FORCE_SHADOW=1; a 10k loop of no-op plus refused registration over the DTD document with RSS 23.3 -> 24.7 MiB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sdist job fails on `test_sign_and_verify_with_an_id_an_external_dtd_ declares`, added with the external-subset fix in 3a50b04 and never yet through CI. Ubuntu 22.04 ships libxmlsec1 1.2.33 with the XXE patch backported, and that xmlsec installs its no-XXE external entity loader globally at xmlSecInit — so importing xmlsec refuses lxml its own `load_dtd=True` parse, well before any shadow exists. libxml2 is matched in that job (lxml is built with --no-binary), so the raw path is what runs: no declaration is made, `#ext` resolves to nothing and the sign fails. The test's premise, not its subject, is what the environment removes. Both tests that need a loaded subset now go through `parse_with_external_dtd`, which skips when `docinfo.externalDTD` comes back None — the same signal `PyXmlSec_LxmlDocumentSubsets` reads to decide whether the copy should load one. Verified by hiding tests/data/id_attr.dtd: 4 skips, no failures. 318 passed / 6 skipped on the mismatch build; 330 / 6 on the matched static wheel, plain and with PYXMLSEC_FORCE_SHADOW=1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No description provided.