Skip to content

Commit 72a78cb

Browse files
mxaminclaude
andcommitted
Skip the shadow copy when lxml links the same libxml2
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>
1 parent e939f1e commit 72a78cb

7 files changed

Lines changed: 103 additions & 25 deletions

File tree

.github/scripts/manylinux_build_and_test.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ echo "== [container] Step: Install test dependencies =="
5656
echo "== [container] Step: Run tests =="
5757
/opt/python/${PY_ABI}/bin/pytest -v --color=yes
5858

59+
# Step: Run tests again on the shadow-copy path (issue #356), which the
60+
# matched libxml2 build would otherwise never exercise.
61+
echo "== [container] Step: Run tests (forced shadow path) =="
62+
PYXMLSEC_FORCE_SHADOW=1 /opt/python/${PY_ABI}/bin/pytest -v --color=yes
63+
5964
# Step: Fix mounted workspace file ownership on host
6065
echo "== [container] Step: Fix mounted workspace file ownership on host =="
6166
chown -R "${HOST_UID}:${HOST_GID}" dist wheelhouse build libs || true

.github/workflows/linuxbrew.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ jobs:
4848
pip3 install --upgrade --no-binary=lxml -r requirements-test.txt
4949
pip3 install xmlsec --only-binary=xmlsec --no-index --find-links=dist/
5050
pytest -v --color=yes
51+
# Same suite on the shadow-copy path (issue #356), which the
52+
# matched libxml2 build would otherwise never exercise.
53+
PYXMLSEC_FORCE_SHADOW=1 pytest -v --color=yes

.github/workflows/macosx.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ jobs:
8787
run: |
8888
coverage run -m pytest -v --color=yes
8989
90+
# Same suite on the shadow-copy path (issue #356), which the matched
91+
# libxml2 build would otherwise never exercise.
92+
- name: Run tests (forced shadow path)
93+
env:
94+
PYXMLSEC_FORCE_SHADOW: "1"
95+
run: |
96+
pytest -v --color=yes
97+
9098
- name: Report coverage to codecov
9199
if: matrix.static_deps != 'static'
92100
run: |

356-summary.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ byte-identical — namespaces and xmlsec's `"\n"` formatting included.
5353
synced) instead of duplicating it.
5454
A conversion is four lines: `Begin` / the unchanged xmlsec call on
5555
`shadow.root` / `End` — no per-function callback or context struct.
56+
- **A fast path** decided once at import: when lxml links the same libxml2 as
57+
the extension (the only configuration the import guard lets run today),
58+
`Begin`/`End` skip the copy entirely and behave exactly like the old direct
59+
code — zero overhead. The shadow round-trip activates under a mismatch, or
60+
with `PYXMLSEC_FORCE_SHADOW=1`, which CI uses to keep the shadow path
61+
exercised on matched libraries.
5662
- **An escape hatch** for development: `PYXMLSEC_SKIP_VERSION_CHECK` bypasses
5763
the import-time mismatch guard so the converted paths can be exercised under
5864
a real mismatch. The guard itself stays on by default.

developer.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ call mutates at most one place in the tree — true for all `xmlSecTmpl*`
5959
functions. Whole-document operations (`sign`, `encrypt`) mutate many places
6060
and will need a different reflect step on top of the same Begin machinery.
6161

62+
## The fast path: shadows only when needed
63+
64+
Copying is pointless when lxml links the same libxml2 as the extension — the
65+
raw-node behavior that shipped for years is safe then, and it is the only
66+
configuration the import guard currently lets run. So `Begin`/`End` are
67+
dual-path, decided once at import:
68+
69+
- **matched versions** (the guard passed): `Begin` aliases the live
70+
`_c_node` into `shadow.root` with no serialization, and `End` just wraps
71+
the node xmlsec returned — machine-identical to the pre-shadow code, zero
72+
overhead;
73+
- **mismatch** (import allowed via `PYXMLSEC_SKIP_VERSION_CHECK` today,
74+
automatic once everything is converted), **or `PYXMLSEC_FORCE_SHADOW` set**:
75+
the full shadow round-trip described above.
76+
77+
Call sites cannot tell the difference; every converted function inherits both
78+
paths. `PYXMLSEC_FORCE_SHADOW` exists so CI keeps the shadow path exercised on
79+
matched libraries (see the test matrix), where it must also pass the full
80+
suite.
81+
6282
> The shadow decouples *lxml* from xmlsec. The extension and `libxmlsec1`
6383
> must still share one libxml2 (wheels/static builds guarantee this).
6484
@@ -88,6 +108,10 @@ exists to exercise the shadow paths; it is **unsafe** for every operation
88108
still on the raw-node path, so keep it off in normal use. The guard can only
89109
be relaxed once all node-passing paths are converted.
90110

111+
On a *matched* build (no mismatch available), run the suite twice instead:
112+
once plain (fast path) and once with `PYXMLSEC_FORCE_SHADOW=1` (shadow path
113+
on matched libraries — safe everywhere, so the whole suite must pass).
114+
91115
## Status
92116

93117
-`template.add_reference`, `template.add_transform`,

src/lxml.c

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,46 @@ static int PyXmlSec_CheckLxmlLibraryVersion(void) {
101101
return result;
102102
}
103103

104+
// Non-zero when converted functions must run their xmlsec call on a shadow
105+
// copy instead of directly on lxml's nodes; decided once at import, below.
106+
static int PyXmlSec_LxmlShadowActive = 1;
107+
108+
// The two lxml.etree callables the shadow crossings use, resolved once at
109+
// import and kept for the lifetime of the process.
110+
static PyObject* PyXmlSec_LxmlEtreeToString;
111+
static PyObject* PyXmlSec_LxmlEtreeFromString;
112+
104113
int PyXmlSec_InitLxmlModule(void) {
105114
// By default refuse to import when lxml and xmlsec link different libxml2
106115
// versions: passing raw nodes between the two libraries then corrupts
107116
// memory (https://github.com/xmlsec/python-xmlsec/issues/283). Setting
108117
// PYXMLSEC_SKIP_VERSION_CHECK bypasses the guard — needed to exercise the
109118
// shadow-copy paths (issue #356) under a mismatch, but unsafe for every
110119
// operation that still hands an lxml node to xmlsec.
111-
if (PyXmlSec_CheckLxmlLibraryVersion() < 0 && getenv("PYXMLSEC_SKIP_VERSION_CHECK") == NULL) {
120+
int mismatch = PyXmlSec_CheckLxmlLibraryVersion() < 0;
121+
if (mismatch && getenv("PYXMLSEC_SKIP_VERSION_CHECK") == NULL) {
112122
PyXmlSec_SetLastError("lxml & xmlsec libxml2 library version mismatch");
113123
return -1;
114124
}
115125

126+
// Matched versions are the long-standing status quo: xmlsec may mutate
127+
// lxml's nodes directly, so converted functions skip the copy (the
128+
// Begin/End fast path). Shadows turn on under a mismatch — or always with
129+
// PYXMLSEC_FORCE_SHADOW, the knob CI uses to exercise the shadow path on
130+
// matched libraries.
131+
PyXmlSec_LxmlShadowActive = mismatch || getenv("PYXMLSEC_FORCE_SHADOW") != NULL;
132+
133+
PyObject* etree = PyImport_ImportModule("lxml.etree");
134+
if (etree == NULL) {
135+
return -1;
136+
}
137+
PyXmlSec_LxmlEtreeToString = PyObject_GetAttrString(etree, "tostring");
138+
PyXmlSec_LxmlEtreeFromString = PyObject_GetAttrString(etree, "fromstring");
139+
Py_DECREF(etree);
140+
if (PyXmlSec_LxmlEtreeToString == NULL || PyXmlSec_LxmlEtreeFromString == NULL) {
141+
return -1;
142+
}
143+
116144
return import_lxml__etree();
117145
}
118146

@@ -151,39 +179,19 @@ int PyXmlSec_LxmlElementConverter(PyObject* o, PyXmlSec_LxmlElementPtr* p) {
151179
// tree; with_tail keeps the serialization to the element itself.
152180
static PyObject* PyXmlSec_LxmlElementToBytes(PyObject* element) {
153181
PyObject* result = NULL;
154-
PyObject* args = NULL;
155-
PyObject* kwargs = NULL;
156-
PyObject* tostring = NULL;
157-
PyObject* etree = PyImport_ImportModule("lxml.etree");
158-
if (etree == NULL) {
159-
return NULL;
160-
}
161-
tostring = PyObject_GetAttrString(etree, "tostring");
162-
Py_DECREF(etree);
163-
if (tostring == NULL) {
164-
return NULL;
165-
}
166-
args = PyTuple_Pack(1, element);
167-
kwargs = Py_BuildValue("{s:O}", "with_tail", Py_False);
182+
PyObject* args = PyTuple_Pack(1, element);
183+
PyObject* kwargs = Py_BuildValue("{s:O}", "with_tail", Py_False);
168184
if (args != NULL && kwargs != NULL) {
169-
result = PyObject_Call(tostring, args, kwargs);
185+
result = PyObject_Call(PyXmlSec_LxmlEtreeToString, args, kwargs);
170186
}
171187
Py_XDECREF(args);
172188
Py_XDECREF(kwargs);
173-
Py_DECREF(tostring);
174189
return result;
175190
}
176191

177192
// etree.fromstring(data) — the parsed nodes are owned and managed by lxml.
178193
static PyObject* PyXmlSec_LxmlElementFromBytes(PyObject* data) {
179-
PyObject* result;
180-
PyObject* etree = PyImport_ImportModule("lxml.etree");
181-
if (etree == NULL) {
182-
return NULL;
183-
}
184-
result = PyObject_CallMethod(etree, "fromstring", "O", data);
185-
Py_DECREF(etree);
186-
return result;
194+
return PyObject_CallFunctionObjArgs(PyXmlSec_LxmlEtreeFromString, data, NULL);
187195
}
188196

189197
// Nodes that exist before the xmlsec call are tagged through the libxml2
@@ -295,6 +303,14 @@ int PyXmlSec_LxmlShadowBegin(PyXmlSec_LxmlShadow* shadow, PyXmlSec_LxmlElementPt
295303
shadow->doc = NULL;
296304
shadow->root = NULL;
297305

306+
// Fast path: lxml links the same libxml2 (the import guard passed), so
307+
// xmlsec can mutate lxml's nodes directly and no copy is needed; End sees
308+
// doc == NULL and just wraps the result node.
309+
if (!PyXmlSec_LxmlShadowActive) {
310+
shadow->root = element->_c_node;
311+
return 0;
312+
}
313+
298314
bytes = PyXmlSec_LxmlElementToBytes((PyObject*)element);
299315
if (bytes == NULL || PyBytes_AsStringAndSize(bytes, &data, &size) < 0) {
300316
Py_XDECREF(bytes);
@@ -332,6 +348,15 @@ PyObject* PyXmlSec_LxmlShadowEnd(PyXmlSec_LxmlShadow* shadow, xmlNodePtr res, co
332348
int rel[PYXMLSEC_SHADOW_MAX_DEPTH];
333349
int depth, rel_depth, insert_idx;
334350

351+
// Fast path (no copy was made): res is a node in the live lxml tree.
352+
if (shadow->doc == NULL) {
353+
if (res == NULL) {
354+
PyXmlSec_SetLastError(error);
355+
return NULL;
356+
}
357+
return (PyObject*)PyXmlSec_elementFactory(shadow->element->_doc, res);
358+
}
359+
335360
if (res == NULL) {
336361
PyXmlSec_SetLastError(error);
337362
goto DONE;

src/lxml.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ int PyXmlSec_LxmlElementConverter(PyObject* o, PyXmlSec_LxmlElementPtr* p);
5252
// when the node is NULL). End must be called exactly once after a successful
5353
// Begin; it always releases the copy.
5454
//
55+
// Fast path: when lxml links the same libxml2 as this extension (the
56+
// import-time version check passed), no copy is needed — Begin aliases the
57+
// live node into `root` (leaving `doc` NULL) and End just wraps the result,
58+
// which is the long-standing direct behavior with zero overhead. Setting
59+
// PYXMLSEC_FORCE_SHADOW in the environment forces the shadow path even on
60+
// matched libraries; CI uses it to keep that path exercised.
61+
//
5562
// The reflection covers the whole xmlSecTmpl* family: a new subtree grafted at
5663
// the position xmlsec chose (including intermediate nodes like <Transforms>
5764
// and the "\n" formatting text around it), or — for find-or-create calls that

0 commit comments

Comments
 (0)