Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

## [Unreleased]

## [0.3.17] (2026-07-29)

### Added

* `Node::free_subtree`: detach and free an entire C subtree IMMEDIATELY,
neutralizing every registered Rust wrapper into it (shared `node_ptr`
nulled — stray clones become inert no-ops; bookkeeping entries cleared so
address reuse cannot collide with a stale wrapper). This is the discard
operation `unlink_node` + `set_rust_owned` cannot provide safely when
clones survive in long-lived collections: their deferred drop can fire
`xmlFreeNode` after the owning document's `xmlFreeDoc` (use-after-free in
`xmlDictOwns`). Driver: consumers that discard thousands of replaced
subtrees per document (latexml-oxide's math parser) leaked them all.

## [0.3.16] (2026-07-12)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "libxml"
version = "0.3.16"
version = "0.3.17"
edition = "2024"
rust-version = "1.88"
authors = ["Andreas Franzén <andreas@devil.se>", "Deyan Ginev <deyan.ginev@gmail.com>","Jan Frederik Schaefer <j.schaefer@jacobs-university.de>"]
Expand Down
75 changes: 75 additions & 0 deletions src/tree/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1210,6 +1210,81 @@ impl Node {
matches!(self.0.borrow().linkage, Linkage::RustOwned)
}

/// Detach this node and free its entire C subtree IMMEDIATELY.
///
/// This is the discard operation `unlink_node` alone does not provide: an
/// unlinked doc-owned node is freed by nobody (see `unlink_node`), so a
/// caller discarding a subtree for good must transfer ownership somewhere.
/// `set_rust_owned` defers the free to the last wrapper drop — but a stray
/// clone parked in a long-lived collection postpones that free past the
/// owning document's `xmlFreeDoc`, and the eventual `xmlFreeNode` then
/// reads the freed document (its dictionary) — a use-after-free.
///
/// `free_subtree` instead frees NOW, after *neutralizing* every registered
/// Rust wrapper into the subtree: each wrapper's shared inner `node_ptr`
/// is nulled — clones held anywhere become inert (`Drop` is a no-op, the
/// null-guarded accessors return their empty defaults) — and the
/// bookkeeping entries are cleared, so a future allocation reusing one of
/// the freed addresses cannot collide with a stale wrapper. Attribute
/// nodes are neutralized along with elements and texts; namespace
/// declarations and attribute values are owned by their nodes and freed by
/// `xmlFreeNode` itself.
///
/// Documents (and document-fragment shells) are refused — tearing a whole
/// document down belongs to `Document`'s `Drop`.
///
/// # Safety contract (mirrors C `xmlFreeNode`)
///
/// The subtree must be garbage to the caller. Wrappers created in an
/// earlier bookkeeping epoch (re-wrapped after an unlink cleared their
/// entry) cannot be found and neutralized here; using one after this call
/// is undefined behavior, exactly as using a freed `xmlNodePtr` in C.
pub fn free_subtree(mut self) {
if matches!(
self.get_type(),
Some(NodeType::DocumentNode) | Some(NodeType::DocumentFragNode)
) {
return;
}
// Sever from parent/siblings (and clear self's bookkeeping entry).
self.unlink_node();
let top = self.0.borrow().node_ptr;
if top.is_null() {
return; // already freed/neutralized through another handle
}
if let Some(doc) = self.get_docref().upgrade() {
// Walk the subtree iteratively (an explicit stack — recursion would
// risk the call stack on deep trees), neutralizing wrappers.
let mut stack: Vec<xmlNodePtr> = vec![top];
while let Some(ptr) = stack.pop() {
let mut child = xmlGetFirstChild(ptr);
while !child.is_null() {
stack.push(child);
child = xmlNextSibling(child);
}
// Attribute structs share the xmlNode prefix layout through `ns`,
// but only ELEMENTS have a `properties` field — reading it through
// an attribute or text pointer would be out of bounds.
if NodeType::from_int(xmlGetNodeType(ptr)) == Some(NodeType::ElementNode) {
let mut attr = xmlGetFirstProperty(ptr);
while !attr.is_null() {
stack.push(attr as xmlNodePtr);
attr = xmlNextPropertySibling(attr);
}
}
let mut doc_borrowed = doc.borrow_mut();
if let Some(wrapper) = doc_borrowed.get_node(ptr) {
wrapper.0.borrow_mut().node_ptr = ptr::null_mut();
}
doc_borrowed.forget_node(ptr);
}
}
// Neutralize this handle itself (its registry entry went away with
// unlink_node, so the walk above could not reach it), then free.
self.0.borrow_mut().node_ptr = ptr::null_mut();
unsafe { xmlFreeNode(top) };
}

fn ptr_as_option(&self, node_ptr: xmlNodePtr) -> Option<Node> {
if node_ptr.is_null() {
None
Expand Down
99 changes: 99 additions & 0 deletions tests/free_subtree_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! Tests for `Node::free_subtree` — immediate discard of a garbage subtree
//! with wrapper neutralization, the operation `unlink_node` +
//! `set_rust_owned` cannot provide safely when stray clones survive in
//! long-lived collections.

use libxml::parser::Parser;
use libxml::tree::{Document, Node, NodeType};

/// Freeing a subtree detaches it from the document and neutralizes every
/// live wrapper into it: held clones become inert (null node) instead of
/// dangling, and their later drop is a no-op — even after the document
/// itself is gone.
#[test]
fn free_subtree_neutralizes_held_wrappers() {
let parser = Parser::default();
let doc = parser
.parse_string("<r><a x=\"1\"><b>text</b></a><c/></r>".as_bytes())
.expect("parse");
let root = doc.get_root_element().expect("root");
let a = root.get_first_element_child().expect("a");
let b = a.get_first_element_child().expect("b");
let b_clone = b.clone(); // a stray holder, as in a bookkeeping Vec

a.free_subtree();

// The document no longer contains <a>; <c> survives.
let remaining = root.get_first_element_child().expect("c stays");
assert_eq!(remaining.get_name(), "c");
// The held descendant wrapper is inert, not dangling.
assert!(b_clone.get_type().is_none(), "neutralized wrapper has no type");
assert_eq!(b_clone.get_name(), "", "null-guarded accessors go empty");
// Dropping holders after the document is freed must be a no-op.
drop(doc);
drop(b_clone);
}

/// An already-unlinked (detached) subtree is freed too — the case where the
/// caller detached first and decides to discard afterwards.
#[test]
fn free_subtree_frees_detached_tree() {
let parser = Parser::default();
let doc = parser
.parse_string("<r><a><b/></a></r>".as_bytes())
.expect("parse");
let root = doc.get_root_element().expect("root");
let mut a = root.get_first_element_child().expect("a");
let b = a.get_first_element_child().expect("b");
a.unlink_node();
a.free_subtree();
assert!(b.get_type().is_none(), "detached descendant neutralized");
assert!(root.get_first_element_child().is_none());
}

/// Fresh, never-linked nodes (built trees that end up discarded) free
/// cleanly, and the neutralization covers nodes added under them.
#[test]
fn free_subtree_frees_built_tree() {
let mut doc = Document::new().expect("new doc");
let root = Node::new("r", None, &doc).expect("root");
doc.set_root_element(&root);
let mut shell = Node::new("shell", None, &doc).expect("shell");
let mut inner = Node::new("inner", None, &doc).expect("inner");
shell.add_child(&mut inner).expect("add");
let inner_clone = inner.clone();
// Never linked into the document tree.
shell.free_subtree();
assert!(inner_clone.get_type().is_none());
// Document tree unaffected.
assert_eq!(root.get_name(), "r");
}

/// Calling `free_subtree` twice through separate clones must not
/// double-free: the second call sees a neutralized (null) handle.
#[test]
fn free_subtree_second_call_is_noop() {
let parser = Parser::default();
let doc = parser
.parse_string("<r><a/></r>".as_bytes())
.expect("parse");
let root = doc.get_root_element().expect("root");
let a = root.get_first_element_child().expect("a");
let a_clone = a.clone();
a.free_subtree();
a_clone.free_subtree(); // neutralized — must be a no-op
}

/// Documents and document-fragment shells are refused.
#[test]
fn free_subtree_refuses_documents() {
let parser = Parser::default();
let doc = parser.parse_string("<r/>".as_bytes()).expect("parse");
let root = doc.get_root_element().expect("root");
if let Some(doc_node) = root.get_parent() {
assert_eq!(doc_node.get_type(), Some(NodeType::DocumentNode));
doc_node.free_subtree(); // refused
}
// Still a live document with a root.
assert_eq!(doc.get_root_element().expect("root").get_name(), "r");
}
Loading