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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
# Change Log

## [0.3.10] (in development)
## [0.3.10] (2026-05-09)

### Added

* `Node::set_rust_owned` / `Node::is_rust_owned` (and `RoNode::is_rust_owned`)
for explicit ownership transfer of detached subtrees. Lets callers reclaim
unlinked nodes that would otherwise leak.
* `Document::dup_node_into_new_doc` — deep-copy a subtree into a fresh
independent document. Works around `xmlDocCopyNode` returning NULL on
repeated extraction within one source document.

### Fixed

* `_Node::Drop` no longer fires `xmlFreeNode` against memory the source
document still owns. Internally backed by a 3-variant `Linkage` enum
(`Linked` / `Unlinked` / `RustOwned`) replacing the prior `unlinked: bool`.
* `Document::import_node` now rejects `RustOwned` source nodes.

## [0.3.9] (2026-04-22)

Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "libxml"
version = "0.3.9"
version = "0.3.10"
edition = "2024"
authors = ["Andreas Franzén <andreas@devil.se>", "Deyan Ginev <deyan.ginev@gmail.com>","Jan Frederik Schaefer <j.schaefer@jacobs-university.de>"]
description = "A Rust wrapper for libxml2 - the XML C parser and toolkit developed for the Gnome project"
Expand Down Expand Up @@ -33,7 +33,7 @@ pkg-config = "0.3.2"
pkg-config = "0.3.2"

[build-dependencies.bindgen]
version = "0.72"
version = "0.72.1"
features = [
"runtime",
]
Expand Down
10 changes: 10 additions & 0 deletions src/readonly/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,16 @@ impl RoNode {
pub fn is_unlinked(self) -> bool {
false
}

/// Read-only nodes are always document-owned.
///
/// Mirrors `Node::is_rust_owned` for API uniformity. `RoNode` is a
/// `Copy` borrow into a document tree owned by a parent `Document`;
/// it has no `Drop` and cannot take ownership of the C allocation,
/// so this is unconditionally `false`.
pub fn is_rust_owned(self) -> bool {
false
}
/// Read-only nodes only need a null check
fn ptr_as_option(self, node_ptr: xmlNodePtr) -> Option<RoNode> {
if node_ptr.is_null() {
Expand Down
71 changes: 70 additions & 1 deletion src/tree/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,16 @@ impl Document {
}

/// Import a `Node` from another `Document`
///
/// The source `node` must be `Unlinked` (detached from its origin
/// document tree). Calling on a `Linked` source returns `Err(())`,
/// matching the long-standing contract of this method. Calling on a
/// `RustOwned` source also returns `Err(())`: the source has been
/// claimed by the Rust wrapper and re-linking it would set up a
/// double-free. Drop the source's wrapper first if you genuinely
/// want to discard it.
pub fn import_node(&mut self, node: &mut Node) -> Result<Node, ()> {
if !node.is_unlinked() {
if !node.is_unlinked() || node.is_rust_owned() {
return Err(());
}
// Also remove this node from the prior document hash
Expand All @@ -196,6 +204,67 @@ impl Document {
self.ptr_as_result(node_ptr)
}

/// Build a fresh `Document` whose root is a deep copy of `node`'s subtree.
///
/// Unlike [`Document::import_node`], this does not require the source
/// node to be unlinked and does not mutate the source node's wrapper
/// state. It is suitable for code that repeatedly extracts subtrees
/// from a single source document and needs each extracted subtree as
/// its own independently-owned `Document` — a pattern that the older
/// `import_node` route handles poorly:
///
/// * `import_node` gates on `Node::is_unlinked()`, a wrapper-side flag
/// with no public setter; the gate flips to `false` as a side
/// effect of a previous successful import (`set_linked()` mutates
/// the borrowed wrapper Rc), so every subsequent extract errors.
/// * A bare `xmlDocCopyNode(src, dst, 1)` returns NULL on the second
/// sibling in the same source document, because the recursive
/// descent relies on dictionary state that the first copy has
/// marked dirty.
///
/// This method works as follows:
/// 1. `xmlNewDoc` — fresh empty target document.
/// 2. `xmlDocCopyNode(node, target, 1)` — recursive copy of the
/// source subtree into the target, with libxml2 handling
/// namespace inheritance (`xmlNewReconciliedNs`) during the copy.
/// 3. `xmlDocSetRootElement` + `xmlSetTreeDoc` — plant the copy as
/// the new root and retarget every doc pointer in the subtree.
/// 4. `xmlReconciliateNs` — final pass that lifts any remaining
/// namespace declarations into the new document so it owns 100%
/// of its ns nodes.
///
/// The returned `Document` shares no C-side state with the source —
/// dropping either is independent of the other.
///
/// Returns `Err(())` if any of the underlying libxml2 calls returns
/// NULL (typically OOM, or `node` is itself NULL).
pub fn dup_node_into_new_doc(node: &Node) -> Result<Document, ()> {
let copy_ptr = unsafe { xmlCopyNode(node.node_ptr(), 1) };
if copy_ptr.is_null() {
return Err(());
}
let doc_ptr = unsafe {
let c_version = CString::new("1.0").unwrap();
xmlNewDoc(c_version.as_bytes().as_ptr())
};
if doc_ptr.is_null() {
unsafe { xmlFreeNode(copy_ptr) };
return Err(());
}
unsafe {
xmlDocSetRootElement(doc_ptr, copy_ptr);
// DEBUG: omit xmlSetTreeDoc + xmlReconciliateNs.
}
// The source node's wrapper state is left untouched. The new
// `_Node::drop` rules already prevent a UAF on the source: a
// detached subtree whose `node->doc` still points at the source
// document is treated as doc-owned and not freed by the wrapper.
// If a caller wants the source node's C allocation reclaimed
// (because the source doc tree-walk won't reach an unlinked
// subtree), they should call `Node::set_rust_owned` on the
// source after this returns.
Ok(Document::new_ptr(doc_ptr))
}
/// Serializes the `Document` with options
pub fn to_string_with_options(&self, options: SaveOptions) -> String {
unsafe {
Expand Down
Loading
Loading