Skip to content

Commit b86e624

Browse files
mxaminclaude
andcommitted
Copy a subtree its document lost as an unlinked node (#356)
PyXmlSec_LxmlLivePathTo stops where getparent() returns None, but an element removed from its tree is not a document root: lxml leaves such a subtree pointing at the document it left, whose getroottree() still serializes that document — now without the subtree in it. BeginDoc therefore copied a document that does not hold the element, recorded depth 0, and handed xmlsec the copy's *root* instead. Signing a template taken out of sign1-in.xml (its URI="" reference then digests the document without the signature in it, which is the point of taking it out) succeeds on the raw path and failed with Error(1, 'failed to sign') under the shadow; find_parent was worse, answering for the wrong tree with no error at all. The copy now reproduces the shape the raw path works on. The document is copied as before, for the references, and the removed subtree is copied into it as an unlinked node beside its tree (shadow.unlinked): that node is what shadow.root / shadow.element and every path map between, what the marking tags, what the reflection re-parses (xmlSaveTree of the subtree — the document dump does not hold it) and what Discard frees, since the document does not own it. Registered ids are replayed once per live top, the subtree's own and the document's, because a #id reference from the subtree into the document it left resolves on the raw path too. Such a node cannot be replaced on either path — libxml2 needs a parent to put the replacement in. Begin's whole-document detour for internal-subset documents had the same blind spot (the dump cannot hold the subtree, and the path re-rooted the wrong node); it now takes that detour only when the element really hangs under the document's root. Also: test_encrypt_uri never called encrypt_uri — it called encrypt_binary with the file:// URI — so the shadow path of encrypt_uri had no successful test at all. Verified byte-identical to the raw path for sign+verify of a removed template, a removed ancestor, a #id reference into the document it left, find_parent in both directions, decrypt, and an encrypt/decrypt Type=Content round trip. Five new tests, each failing on the shadow path before the fix and passing on the raw path. 323 passed / 6 skipped on the mismatch build (also at PYXMLSEC_TEST_ITERATIONS=50), 335 / 6 on the matched static wheel plain and with PYXMLSEC_FORCE_SHADOW=1; a 10k removed-template sign+verify loop holds RSS at 24.3 → 26.1 MiB with a stable digest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ea7dace commit b86e624

7 files changed

Lines changed: 242 additions & 20 deletions

File tree

developer.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,20 @@ caller gets a new proxy object.
160160
`index`), serializes `element.getroottree()` — comments/PIs outside the root
161161
and the internal DTD subset survive — and hands back the copy's counterpart;
162162
`shadow.element` becomes the live *root*, which is where the reflection maps
163-
paths onto. `BeginNewDoc` creates an empty private document; `End` roots the
163+
paths onto.
164+
165+
An element **removed from its document** is a shape of its own: lxml leaves
166+
such a subtree pointing at the document it left, and so does the raw path —
167+
xmlsec works on a node outside the tree whose `doc` still answers its `#id`
168+
references (a template taken out of a document and signed with `URI=""`
169+
digests that document, without itself in it). The copy holds both: the
170+
document is copied as always, and the removed subtree is copied into it as an
171+
*unlinked* node beside its tree (`shadow.unlinked`), which is then what
172+
`shadow.root` / `shadow.element` and every path map between. Registered IDs
173+
are replayed twice, once for each of the two live tops. Such a node cannot be
174+
replaced (libxml2 needs a parent to put the replacement in), on either path.
175+
176+
`BeginNewDoc` creates an empty private document; `End` roots the
164177
detached result there, dumps it and returns it as a new detached lxml element
165178
(in a document of its own until grafted; lxml moves it when the caller
166179
appends it, like the raw path's detached node).
@@ -303,9 +316,9 @@ All invisible to the documented API:
303316
- a subtree of a document that declares entities in its internal subset
304317
(`resolve_entities=False`) is copied by copying the whole document and
305318
cutting it back to the element, since the subtree's `&name;` references
306-
need their declarations; `encrypt_xml` templates are still serialized on
307-
their own, so a *template* carrying unresolved entity references is not
308-
supported (signing and encryption of such a document are refused on both
319+
need their declarations; `encrypt_xml` templates and subtrees removed from
320+
their document are still serialized on their own, so a *template* — or a
321+
removed subtree — carrying unresolved entity references is not supported (signing and encryption of such a document are refused on both
309322
paths anyway — libxml2's c14n rejects entity-reference nodes);
310323
- operations that replace the **document root** (encrypting the root element
311324
with `Type=Element`, decrypting a root `EncryptedData`) morph the live root

src/lxml.c

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <libxml/xmlmemory.h>
1818
#include <libxml/parser.h>
1919
#include <libxml/dict.h>
20+
#include <libxml/xmlsave.h>
2021

2122
#include <stdint.h>
2223

@@ -435,18 +436,26 @@ static int PyXmlSec_LxmlShadowTagNodes(PyXmlSec_LxmlShadow* shadow, xmlNodePtr n
435436
// exception set.
436437
static int PyXmlSec_LxmlShadowMark(PyXmlSec_LxmlShadow* shadow) {
437438
int count = PyXmlSec_LxmlShadowCountNodes(shadow->doc->children, 0);
439+
// The unlinked subtree is in the document but not in its tree, so the
440+
// walk over the tree does not reach it; it is pre-existing all the same.
441+
int unlinked = shadow->unlinked ? PyXmlSec_LxmlShadowCountNodes(shadow->root, 0) : 0;
442+
int next;
438443

439-
if (count < 0) {
444+
if (count < 0 || unlinked < 0) {
440445
PyErr_SetString(PyXmlSec_InternalError, "the document is nested too deeply.");
441446
return -1;
442447
}
448+
count += unlinked;
443449
shadow->tags = (PyXmlSec_LxmlShadowTag*)PyMem_Malloc((count > 0 ? count : 1) * sizeof(*shadow->tags));
444450
if (shadow->tags == NULL) {
445451
PyErr_NoMemory();
446452
return -1;
447453
}
448454
shadow->ntags = count;
449-
PyXmlSec_LxmlShadowTagNodes(shadow, shadow->doc->children, 0);
455+
next = PyXmlSec_LxmlShadowTagNodes(shadow, shadow->doc->children, 0);
456+
if (shadow->unlinked) {
457+
PyXmlSec_LxmlShadowTagNodes(shadow, shadow->root, next);
458+
}
450459
return 0;
451460
}
452461

@@ -593,6 +602,33 @@ static PyObject* PyXmlSec_LxmlShadowDumpCopy(PyXmlSec_LxmlShadow* shadow) {
593602
xmlChar* dump = NULL;
594603
int dump_size = 0;
595604

605+
if (shadow->unlinked) {
606+
// The paths the reflection carries are relative to the unlinked
607+
// subtree, so that is what has to be re-parsed — the document dump
608+
// does not hold it.
609+
xmlBufferPtr buffer = xmlBufferCreate();
610+
xmlSaveCtxtPtr save = buffer != NULL ? xmlSaveToBuffer(buffer, "UTF-8", XML_SAVE_NO_DECL) : NULL;
611+
int failed = 1;
612+
613+
if (save != NULL) {
614+
failed = xmlSaveTree(save, shadow->root) < 0;
615+
failed = xmlSaveClose(save) < 0 || failed;
616+
}
617+
if (!failed) {
618+
bytes = PyBytes_FromStringAndSize((const char*)xmlBufferContent(buffer), (Py_ssize_t)xmlBufferLength(buffer));
619+
if (bytes != NULL) {
620+
result = PyXmlSec_LxmlElementFromBytes(bytes);
621+
}
622+
Py_XDECREF(bytes);
623+
} else {
624+
PyErr_SetString(PyXmlSec_InternalError, "cannot serialize the private copy.");
625+
}
626+
if (buffer != NULL) {
627+
xmlBufferFree(buffer);
628+
}
629+
return result;
630+
}
631+
596632
xmlDocDumpMemory(shadow->doc, &dump, &dump_size);
597633
if (dump == NULL || dump_size <= 0) {
598634
PyErr_SetString(PyXmlSec_InternalError, "cannot serialize the private copy.");
@@ -695,13 +731,15 @@ int PyXmlSec_LxmlShadowBegin(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElementPt
695731
int depth = 0;
696732
int dtd = 0;
697733
int extdtd = 0;
734+
int whole = 0;
698735

699736
shadow->element = element;
700737
shadow->owned = NULL;
701738
shadow->doc = NULL;
702739
shadow->root = NULL;
703740
shadow->tags = NULL;
704741
shadow->ntags = 0;
742+
shadow->unlinked = 0;
705743

706744
// Fast path: lxml links the same libxml2 (the import guard passed), so
707745
// xmlsec can mutate lxml's nodes directly and no copy is needed; End sees
@@ -725,12 +763,22 @@ int PyXmlSec_LxmlShadowBegin(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElementPt
725763
// in the document's internal subset — serializing the element alone
726764
// would leave them undefined and the parse would fail. Copy the whole
727765
// document, declarations included, and cut it back to the element.
766+
// Unless the element was removed from that document: the dump would
767+
// not hold it at all, and the element alone is all there is to copy.
728768
PyObject* live_top = NULL;
769+
PyObject* live_root = PyObject_CallMethod(tree, "getroot", NULL);
770+
if (live_root == NULL) {
771+
goto ON_FAIL;
772+
}
729773
depth = PyXmlSec_LxmlLivePathTo((PyObject*)element, path, &live_top);
774+
whole = depth >= 0 && live_top == live_root;
730775
Py_XDECREF(live_top);
776+
Py_DECREF(live_root);
731777
if (depth < 0) {
732778
goto ON_FAIL;
733779
}
780+
}
781+
if (whole) {
734782
bytes = PyObject_CallFunctionObjArgs(PyXmlSec_LxmlEtreeToString, tree, NULL);
735783
} else {
736784
bytes = PyXmlSec_LxmlElementToBytes((PyObject*)element);
@@ -745,7 +793,7 @@ int PyXmlSec_LxmlShadowBegin(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElementPt
745793
if (shadow->doc == NULL) {
746794
goto ON_FAIL;
747795
}
748-
if (dtd && PyXmlSec_LxmlShadowReroot(shadow, path, depth) < 0) {
796+
if (whole && PyXmlSec_LxmlShadowReroot(shadow, path, depth) < 0) {
749797
goto ON_FAIL;
750798
}
751799
shadow->root = xmlDocGetRootElement(shadow->doc);
@@ -769,6 +817,7 @@ xmlDocPtr PyXmlSec_LxmlShadowBeginNewDoc(PyXmlSec_LxmlShadow* shadow, PyXmlSec_L
769817
shadow->root = NULL;
770818
shadow->tags = NULL;
771819
shadow->ntags = 0;
820+
shadow->unlinked = 0;
772821

773822
// Fast path: allocate the detached subtree straight in the element's own
774823
// document, as the raw code always did.
@@ -785,9 +834,15 @@ xmlDocPtr PyXmlSec_LxmlShadowBeginNewDoc(PyXmlSec_LxmlShadow* shadow, PyXmlSec_L
785834

786835
void PyXmlSec_LxmlShadowDiscard(PyXmlSec_LxmlShadow* shadow) {
787836
if (shadow->doc != NULL) {
837+
if (shadow->unlinked && shadow->root != NULL) {
838+
// Outside the document's tree: freeing the document does not
839+
// reach it.
840+
xmlFreeNode(shadow->root);
841+
}
788842
xmlFreeDoc(shadow->doc);
789843
shadow->doc = NULL;
790844
shadow->root = NULL;
845+
shadow->unlinked = 0;
791846
}
792847
PyMem_Free(shadow->tags);
793848
shadow->tags = NULL;
@@ -1523,8 +1578,10 @@ static void PyXmlSec_LxmlShadowApplyIdSpec(xmlDocPtr doc, xmlNodePtr node, const
15231578
}
15241579

15251580
// Replays the specs recorded for the shadow's live document onto the copy,
1526-
// each at the copy's counterpart of the element it was registered for.
1527-
static int PyXmlSec_LxmlShadowReplayIds(PyXmlSec_LxmlShadow* shadow) {
1581+
// each at the copy's counterpart of the element it was registered for: a spec
1582+
// recorded for an element under the live `live_top` is applied under its
1583+
// counterpart `copy_top`. Returns 0, or -1 with an exception set.
1584+
static int PyXmlSec_LxmlShadowReplayIds(PyXmlSec_LxmlShadow* shadow, PyObject* live_top, xmlNodePtr copy_top) {
15281585
PyObject* key;
15291586
PyObject* entry;
15301587
PyObject* nodes;
@@ -1567,19 +1624,49 @@ static int PyXmlSec_LxmlShadowReplayIds(PyXmlSec_LxmlShadow* shadow) {
15671624
return -1;
15681625
}
15691626
// lxml hands out one proxy per node, so identity settles whether the
1570-
// element still hangs under the root being copied; a registration for
1571-
// an element that has since left this tree applies to nothing here.
1572-
if (top == (PyObject*)shadow->element) {
1573-
PyXmlSec_LxmlShadowApplyIdSpec(shadow->doc, PyXmlSec_LxmlShadowWalkNode(shadow->root, path, depth),
1627+
// element still hangs under the tree being replayed; a registration
1628+
// for an element that has since left it applies to nothing here.
1629+
if (top == live_top) {
1630+
PyXmlSec_LxmlShadowApplyIdSpec(shadow->doc, PyXmlSec_LxmlShadowWalkNode(copy_top, path, depth),
15741631
(const xmlChar*)name, (const xmlChar*)ns, subtree);
15751632
}
15761633
Py_DECREF(top);
15771634
}
15781635
return 0;
15791636
}
15801637

1638+
// Copies the live subtree `element` — removed from its document, so the copy
1639+
// of that document does not hold it either — into the private document, as an
1640+
// unlinked node beside its tree. That is the shape the raw path hands xmlsec:
1641+
// a node outside the tree whose `doc` still answers `#id` references.
1642+
// Returns 0, or -1 with an exception set.
1643+
static int PyXmlSec_LxmlShadowUnlinkedCopy(PyXmlSec_LxmlShadow* shadow, PyObject* element) {
1644+
PyObject* bytes = PyXmlSec_LxmlElementToBytes(element);
1645+
xmlDocPtr tdoc;
1646+
xmlNodePtr copy;
1647+
1648+
if (bytes == NULL) {
1649+
return -1;
1650+
}
1651+
tdoc = PyXmlSec_LxmlShadowParse(bytes, NULL, 0, "cannot make a private copy of the element.");
1652+
Py_DECREF(bytes);
1653+
if (tdoc == NULL) {
1654+
return -1;
1655+
}
1656+
copy = xmlDocCopyNode(xmlDocGetRootElement(tdoc), shadow->doc, 1);
1657+
xmlFreeDoc(tdoc);
1658+
if (copy == NULL) {
1659+
PyErr_SetString(PyXmlSec_InternalError, "cannot make a private copy of the element.");
1660+
return -1;
1661+
}
1662+
shadow->root = copy;
1663+
shadow->unlinked = 1;
1664+
return 0;
1665+
}
1666+
15811667
int PyXmlSec_LxmlShadowBeginDoc(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElementPtr element, xmlNodePtr* target) {
15821668
PyObject* cur = NULL;
1669+
PyObject* live_root = NULL;
15831670
PyObject* tree = NULL;
15841671
PyObject* bytes = NULL;
15851672
PyObject* url_holder = NULL;
@@ -1595,6 +1682,7 @@ int PyXmlSec_LxmlShadowBeginDoc(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElemen
15951682
shadow->root = NULL;
15961683
shadow->tags = NULL;
15971684
shadow->ntags = 0;
1685+
shadow->unlinked = 0;
15981686
*target = NULL;
15991687

16001688
if (!PyXmlSec_LxmlShadowActive) {
@@ -1617,6 +1705,13 @@ int PyXmlSec_LxmlShadowBeginDoc(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElemen
16171705
if (tree == NULL) {
16181706
goto ON_FAIL;
16191707
}
1708+
// `cur` is the document's root element unless `element` — or an ancestor
1709+
// of it — was removed from the tree: lxml leaves such a subtree pointing
1710+
// at the document it left, which the dump below therefore does not hold.
1711+
live_root = PyObject_CallMethod(tree, "getroot", NULL);
1712+
if (live_root == NULL) {
1713+
goto ON_FAIL;
1714+
}
16201715
// The whole tree is dumped either way here, so only the external subset
16211716
// matters: it has to be loaded for the copy to know the IDs it declares.
16221717
bytes = PyObject_CallFunctionObjArgs(PyXmlSec_LxmlEtreeToString, tree, NULL);
@@ -1632,6 +1727,9 @@ int PyXmlSec_LxmlShadowBeginDoc(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElemen
16321727
goto ON_FAIL;
16331728
}
16341729
shadow->root = xmlDocGetRootElement(shadow->doc);
1730+
if (live_root != cur && PyXmlSec_LxmlShadowUnlinkedCopy(shadow, cur) < 0) {
1731+
goto ON_FAIL;
1732+
}
16351733
if (PyXmlSec_LxmlShadowMark(shadow) < 0) {
16361734
goto ON_FAIL;
16371735
}
@@ -1649,14 +1747,23 @@ int PyXmlSec_LxmlShadowBeginDoc(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElemen
16491747
cur = NULL;
16501748

16511749
// The registered IDs live in lxml's document, which the copy knows
1652-
// nothing about; replay them so that #id references resolve.
1653-
if (PyXmlSec_LxmlShadowReplayIds(shadow) < 0) {
1750+
// nothing about; replay them so that #id references resolve. An unlinked
1751+
// subtree carries its own registrations, and the document it left keeps
1752+
// the rest — a reference from the subtree into that document resolves on
1753+
// the raw path too.
1754+
if (PyXmlSec_LxmlShadowReplayIds(shadow, (PyObject*)shadow->element, shadow->root) < 0) {
16541755
goto ON_FAIL;
16551756
}
1757+
if (shadow->unlinked
1758+
&& PyXmlSec_LxmlShadowReplayIds(shadow, live_root, xmlDocGetRootElement(shadow->doc)) < 0) {
1759+
goto ON_FAIL;
1760+
}
1761+
Py_CLEAR(live_root);
16561762
return 0;
16571763

16581764
ON_FAIL:
16591765
Py_XDECREF(cur);
1766+
Py_XDECREF(live_root);
16601767
Py_XDECREF(tree);
16611768
Py_XDECREF(bytes);
16621769
Py_XDECREF(url_holder);
@@ -1993,20 +2100,27 @@ static int PyXmlSec_LxmlShadowReflectSites(PyXmlSec_LxmlShadow* shadow) {
19932100
int rv = -1;
19942101

19952102
// Re-fetch the root: replacement operations may swap nodes at the top.
1996-
list.top = xmlDocGetRootElement(shadow->doc);
2103+
// An unlinked subtree cannot be replaced at all (libxml2 needs a parent
2104+
// to put the replacement in), so its top is the one the shadow made.
2105+
list.top = shadow->unlinked ? shadow->root : xmlDocGetRootElement(shadow->doc);
19972106
if (list.top == NULL) {
19982107
PyErr_SetString(PyXmlSec_InternalError, "unexpected mutation site.");
19992108
goto DONE;
20002109
}
20012110
if (!PYXMLSEC_SHADOW_TAGGED(shadow, list.top)) {
2111+
xmlNodePtr n;
2112+
int elements = 0;
2113+
int others = 0;
2114+
2115+
if (shadow->unlinked) {
2116+
PyErr_SetString(PyXmlSec_InternalError, "unexpected mutation site.");
2117+
goto DONE;
2118+
}
20022119
// A fresh root: the call replaced the root itself, so there are no
20032120
// sites to graft — the live root is morphed into it wholesale. lxml
20042121
// holds one element (plus comments and PIs) at document level, so a
20052122
// root replaced by anything else (a Type=Content decryption of the
20062123
// root) cannot be reflected.
2007-
xmlNodePtr n;
2008-
int elements = 0;
2009-
int others = 0;
20102124
for (n = shadow->doc->children; n != NULL; n = n->next) {
20112125
if (n->type == XML_ELEMENT_NODE) {
20122126
++elements;

src/lxml.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ typedef struct {
9090
xmlNodePtr root; // doc's root element, the copy of `element`; NULL for BeginNewDoc
9191
PyXmlSec_LxmlShadowTag* tags; // one per pre-existing node of `doc`, owned by the shadow
9292
int ntags;
93+
int unlinked; // `root` hangs in `doc` outside its tree (BeginDoc of an element
94+
// removed from its document); the shadow frees it itself
9395
} PyXmlSec_LxmlShadow;
9496

9597
// Subtree copy: `shadow.root` is the copy of `element`. When the document
@@ -105,6 +107,11 @@ int PyXmlSec_LxmlShadowBegin(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElementPt
105107
// `*target` receives the copy's counterpart of `element` (the live node
106108
// itself on the fast path); `shadow.root` / `shadow.element` become the copy
107109
// root / the live root, which is what the End functions map paths between.
110+
// An element removed from its document keeps pointing at it, and so does the
111+
// raw path — xmlsec works on the unlinked node while the document it left
112+
// still answers its `#id` references. The copy holds both: the document, and
113+
// the unlinked subtree copied into it beside the tree (`shadow.unlinked`,
114+
// with `shadow.root` / `shadow.element` the subtree's top on either side).
108115
int PyXmlSec_LxmlShadowBeginDoc(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElementPtr element, xmlNodePtr* target);
109116

110117
// Create shape (template.create, encrypted_data_create): the call only needs

0 commit comments

Comments
 (0)