Skip to main content

libxml_rs/xml/
save.rs

1//! XML save-context API (upstream xmlsave.c, 2.15.3).
2//!
3//! `xmlSaveToFd` / `xmlSaveToFilename` / `xmlSaveToBuffer` / `xmlSaveToIO`
4//! create a save context; `xmlSaveDoc` / `xmlSaveTree` serialize into it;
5//! `xmlSaveFlush` / `xmlSaveClose` / `xmlSaveFinish` finalize it.
6//!
7//! # UPSTREAM-PARITY
8//!
9//! `xmlSaveCtxt` is opaque in the public headers (xmlsave.h); the candidate
10//! defines its own internal representation — there is no ABI constraint on
11//! its layout. Behavior mirrors xmlsave.c: options XML_SAVE_FORMAT,
12//! XML_SAVE_NO_DECL, XML_SAVE_NO_EMPTY and the deprecated escape callbacks.
13//! Formatting/decl handling is provided by the tree serializer
14//! (`serialize_node_opts`), which mirrors upstream `xmlSaveDoc`/
15//! `xmlSaveTree`/DumpState mechanics.
16//!
17//! # Courts
18//!
19//! SAVE-* differential cases compare `xmlSave*` output byte-for-byte with
20//! the oracle DSO across option combinations.
21//!
22//! # Upstream contract
23//!
24//! Mirrors upstream `xmlsave.c` (+ xmlIO.c output buffers) at libxml2
25//! 2.15.3 (`SRC-LIBXML2-2.15.0-XMLSAVE-C`): `xmlSaveToFd` / `xmlSaveToIO` /
26//! `xmlSaveToFilename` / `xmlSaveToBuffer`, `xmlSaveDoc`, `xmlSaveTree`,
27//! `xmlSaveFlush` / `xmlSaveFinish` / `xmlSaveClose`, and the deprecated
28//! `xmlSaveSetEscape` / `xmlSaveSetAttrEscape` hooks.
29//!
30//! # Conceptual behavior
31//!
32//! A save context wraps an output buffer plus the `XML_SAVE_*` option mask;
33//! `xmlSaveDoc`/`xmlSaveTree` delegate to the tree serializer
34//! (`serialize_node_opts`), which mirrors upstream DumpState mechanics
35//! (format/indent, XML declaration suppression, empty-element policy).
36//!
37//! # Ownership & safety invariants
38//!
39//! `xmlSaveTo*` adopts the output buffer; `xmlSaveClose` flushes and frees
40//! it. The escape/attrEscape callback slots are stored verbatim and never
41//! dereferenced by the context (deprecated upstream).
42//!
43//! # Historical quirks & epochs
44//!
45//! The escape/attrEscape hooks are deprecated since the 2.x era and kept
46//! only for source compatibility; the serializer behavior targets the
47//! 2.15.3 epoch (e.g. the html-dump single-line epoch E-007 applies to the
48//! HTML serializer, and XSLT output relies on these options).
49//!
50//! # Deliberate oddities
51//!
52//! `xmlSaveCtxt` is opaque in the public header, so the candidate-internal
53//! layout is unconstrained — the deliberate fidelity surface is the
54//! behavior, not the struct bytes.
55//!
56//! # Proving courts
57//!
58//! SAVE-* differential probes (courts/suites/data-abi/*) compare output
59//! byte-identical against the oracle DSO; the CLI differential courts
60//! (xmllint save paths) and cargo test round-trips cover the options.
61//!
62//! # Tempting simplifications that would break parity
63//!
64//! Do not drop the deprecated escape callback slots: consumers still set
65//! them and observe them firing during serialization. Do not bypass the
66//! output-buffer layer (xmlIO.c): flush counts and encoder interaction
67//! (R-000151) are observable through `xmlSaveFlush`/`xmlSaveClose`.
68
69use crate::abi::allocator::xmlFreeImpl;
70use crate::abi::callbacks::{
71    xmlCharEncodingOutputFunc, xmlOutputCloseCallback, xmlOutputWriteCallback,
72};
73use crate::abi::structs::{_xmlDoc, _xmlNode, _xmlOutputBuffer};
74use crate::abi::types::xmlChar;
75use crate::xml::io;
76use std::os::raw::{c_char, c_int, c_long};
77use std::ptr;
78
79/// XML_SAVE_FORMAT — format output (newlines + indentation).
80pub const XML_SAVE_FORMAT: c_int = 1 << 0;
81/// XML_SAVE_NO_DECL — don't emit an XML declaration.
82pub const XML_SAVE_NO_DECL: c_int = 1 << 1;
83/// XML_SAVE_NO_EMPTY — don't emit empty tags.
84pub const XML_SAVE_NO_EMPTY: c_int = 1 << 2;
85/// Candidate-internal: nokogiri passes `SaveOptions::AS_HTML` (64) to emit
86/// HTML-style output (HTML-void elements stay `<br>`-style, non-void empty
87/// elements get an explicit end tag). Mirrors nokogiri's flag value, which is
88/// 64 (2^6); upstream uses `XML_SAVE_AS_HTML = (1<<10)`.
89pub const XML_SAVE_AS_HTML: c_int = 1 << 6;
90
91/// Candidate-internal save context (opaque upstream).
92#[derive(Debug)]
93#[repr(C)]
94pub struct _xmlSaveCtxt {
95    /// The output buffer the context serializes into.
96    pub buf: *mut _xmlOutputBuffer,
97    /// The `XML_SAVE_*` option bitmask passed to `xmlSaveTo*`.
98    pub options: c_int,
99    /// Whether `XML_SAVE_FORMAT` (newlines + indentation) is enabled.
100    pub format: c_int,
101    /// Whether the XML declaration is suppressed (`XML_SAVE_NO_DECL`).
102    pub no_decl: c_int,
103    /// Whether empty elements must be written with an explicit end tag
104    /// (`XML_SAVE_NO_EMPTY`).
105    pub no_empty: c_int,
106    /// HTML output mode (nokogiri `SaveOptions::AS_HTML` = 64): empty HTML
107    /// void elements (br/img/…) serialise as `<br>` and other empty
108    /// non-void elements as `<a></a>`.
109    pub as_html: c_int,
110    /// Optional indentation string used when formatting is enabled.
111    pub indent: *mut xmlChar,
112    /// The encoding name carried into the XML declaration (upstream
113    /// `ctxt->encoding`, xmlStrdup'd at context creation, freed by
114    /// `xmlSaveClose`/`xmlSaveFinish` like upstream xmlFreeSaveCtxt).
115    /// NULL means "use the document's own encoding" (upstream
116    /// `if (encoding == NULL) encoding = cur->encoding;`).
117    pub encoding: *mut xmlChar,
118    /// Character-escaping callback for text content (deprecated upstream).
119    pub escape: Option<xmlCharEncodingOutputFunc>,
120    /// Character-escaping callback for attribute values (deprecated upstream).
121    pub attrEscape: Option<xmlCharEncodingOutputFunc>,
122}
123
124/// Create a save context around an output buffer.
125///
126/// `encoding` is the encoding name passed to the `xmlSaveTo*` constructor
127/// (upstream `xmlNewSaveCtxt` xmlStrdups it into `ctxt->encoding`); it is
128/// emitted in the XML declaration and used to pick the encoder.
129///
130/// # SAFETY
131///
132/// - `encoding` must be NULL or a valid NUL-terminated string.
133unsafe fn save_ctxt_new(
134    buf: *mut _xmlOutputBuffer,
135    options: c_int,
136    encoding: *const c_char,
137) -> *mut _xmlSaveCtxt {
138    if buf.is_null() {
139        return ptr::null_mut();
140    }
141    let ctxt = libc::calloc(1, core::mem::size_of::<_xmlSaveCtxt>()) as *mut _xmlSaveCtxt;
142    if ctxt.is_null() {
143        io::output_buffer_close(buf);
144        return ptr::null_mut();
145    }
146    (*ctxt).buf = buf;
147    (*ctxt).options = options;
148    (*ctxt).format = if (options & XML_SAVE_FORMAT) != 0 {
149        1
150    } else {
151        0
152    };
153    (*ctxt).no_decl = if (options & XML_SAVE_NO_DECL) != 0 {
154        1
155    } else {
156        0
157    };
158    (*ctxt).no_empty = if (options & XML_SAVE_NO_EMPTY) != 0 {
159        1
160    } else {
161        0
162    };
163    (*ctxt).as_html = if (options & XML_SAVE_AS_HTML) != 0 {
164        1
165    } else {
166        0
167    };
168    (*ctxt).encoding = if encoding.is_null() {
169        ptr::null_mut()
170    } else {
171        crate::abi::exports_xml2::xmlStrdup(encoding as *const xmlChar)
172    };
173    ctxt
174}
175
176/// Resolve an encoding name to an encoding handler.
177unsafe fn encoding_handler(
178    encoding: *const c_char,
179) -> *mut crate::abi::structs::_xmlCharEncodingHandler {
180    if encoding.is_null() {
181        return ptr::null_mut();
182    }
183    crate::xml::encoding::xmlFindCharEncodingHandler(encoding)
184}
185
186/// `xmlSaveCtxt *xmlSaveToFd(int fd, const char *encoding, int options)`.
187///
188/// # SAFETY
189///
190/// - `fd` must be a valid open file descriptor.
191#[no_mangle]
192pub unsafe extern "C" fn xmlSaveToFd(
193    fd: c_int,
194    encoding: *const c_char,
195    options: c_int,
196) -> *mut _xmlSaveCtxt {
197    let enc = unsafe { encoding_handler(encoding) };
198    let out = io::output_buffer_create_fd(fd, enc);
199    unsafe { save_ctxt_new(out, options, encoding) }
200}
201
202/// `xmlSaveCtxt *xmlSaveToFilename(const char *filename, const char *encoding, int options)`.
203///
204/// # SAFETY
205///
206/// - `filename` must be a valid NUL-terminated path.
207#[no_mangle]
208pub unsafe extern "C" fn xmlSaveToFilename(
209    filename: *const c_char,
210    encoding: *const c_char,
211    options: c_int,
212) -> *mut _xmlSaveCtxt {
213    let enc = unsafe { encoding_handler(encoding) };
214    // UPSTREAM-PARITY (xmlsave.c xmlSaveToFilename): the file open funnels
215    // through xmlOutputBufferCreateFilename, honoring a registered default
216    // create-filename callback (php streams under PHP).
217    let out = io::output_buffer_create_filename_routed(filename, enc, 0);
218    unsafe { save_ctxt_new(out, options, encoding) }
219}
220
221/// `xmlSaveCtxt *xmlSaveToBuffer(xmlBuffer *buffer, const char *encoding, int options)`.
222///
223/// # SAFETY
224///
225/// - `buffer` must be a valid `_xmlBuffer`.
226#[no_mangle]
227pub unsafe extern "C" fn xmlSaveToBuffer(
228    buffer: *mut crate::abi::structs::_xmlBuffer,
229    encoding: *const c_char,
230    options: c_int,
231) -> *mut _xmlSaveCtxt {
232    let enc = unsafe { encoding_handler(encoding) };
233    let out = io::output_buffer_create_buffer(buffer, enc);
234    unsafe { save_ctxt_new(out, options, encoding) }
235}
236
237/// `xmlSaveCtxt *xmlSaveToIO(xmlOutputWriteCallback iowrite, xmlOutputCloseCallback ioclose, void *ioctx, const char *encoding, int options)`.
238///
239/// # SAFETY
240///
241/// - The callbacks must be valid function pointers or NULL.
242#[no_mangle]
243pub unsafe extern "C" fn xmlSaveToIO(
244    iowrite: Option<xmlOutputWriteCallback>,
245    ioclose: Option<xmlOutputCloseCallback>,
246    ioctx: *mut core::ffi::c_void,
247    encoding: *const c_char,
248    options: c_int,
249) -> *mut _xmlSaveCtxt {
250    let enc = unsafe { encoding_handler(encoding) };
251    let out = io::output_buffer_create_io(iowrite, ioclose, ioctx, enc);
252    unsafe { save_ctxt_new(out, options, encoding) }
253}
254
255/// Serialize `doc` into the save context's output buffer.
256///
257/// Returns the number of bytes written, or -1 on error.
258///
259/// # SAFETY
260///
261/// - `ctxt` must be a valid save context.
262/// - `doc` must be a valid document or NULL.
263#[no_mangle]
264pub unsafe extern "C" fn xmlSaveDoc(ctxt: *mut _xmlSaveCtxt, doc: *mut _xmlDoc) -> c_long {
265    unsafe { save_doc_or_tree(ctxt, doc as *mut _xmlNode) }
266}
267
268/// Serialize a node tree into the save context's output buffer.
269///
270/// Returns the number of bytes written, or -1 on error.
271///
272/// # SAFETY
273///
274/// - `ctxt` must be a valid save context.
275/// - `node` must be a valid node or NULL.
276#[no_mangle]
277pub unsafe extern "C" fn xmlSaveTree(ctxt: *mut _xmlSaveCtxt, node: *mut _xmlNode) -> c_long {
278    unsafe { save_doc_or_tree(ctxt, node) }
279}
280
281unsafe fn save_doc_or_tree(ctxt: *mut _xmlSaveCtxt, node: *mut _xmlNode) -> c_long {
282    if ctxt.is_null() || node.is_null() {
283        return -1;
284    }
285    let buf = io::buf_create(-1);
286    if buf.is_null() {
287        return -1;
288    }
289    let indent = (*ctxt).indent;
290    let format = (*ctxt).format;
291    let no_decl = (*ctxt).no_decl;
292    let no_empty = (*ctxt).no_empty;
293    let as_html = (*ctxt).as_html;
294    let encoding = (*ctxt).encoding as *const xmlChar;
295    crate::xml::tree::serialize_node_opts_enc_full(
296        node, buf, format, 0, indent, no_decl, no_empty, as_html, encoding,
297    );
298
299    let before = io::buf_length(buf);
300    let content = io::buf_content(buf);
301    let ret = if before > 0 && !content.is_null() {
302        io::output_buffer_write((*ctxt).buf, before, content as *const c_char)
303    } else {
304        0
305    };
306    io::buf_free(buf);
307    if ret < 0 {
308        -1
309    } else {
310        ret as c_long
311    }
312}
313
314/// `int xmlSaveFlush(xmlSaveCtxt *ctxt)` — flush the output buffer.
315///
316/// # SAFETY
317///
318/// - `ctxt` must be a valid save context.
319#[no_mangle]
320pub unsafe extern "C" fn xmlSaveFlush(ctxt: *mut _xmlSaveCtxt) -> c_int {
321    if ctxt.is_null() {
322        return -1;
323    }
324    io::output_buffer_flush((*ctxt).buf)
325}
326
327/// `int xmlSaveClose(xmlSaveCtxt *ctxt)` — flush, close and free the context.
328///
329/// # UPSTREAM-PARITY
330///
331/// Returns the number of bytes written (the flush result), like upstream
332/// xmlSaveClose (xmlsave.c 2.15); the underlying output buffer is closed by
333/// xmlFreeSaveCtxt.
334///
335/// # SAFETY
336///
337/// - `ctxt` must be a valid save context; it is freed by this call.
338#[no_mangle]
339pub unsafe extern "C" fn xmlSaveClose(ctxt: *mut _xmlSaveCtxt) -> c_int {
340    if ctxt.is_null() {
341        return -1;
342    }
343    let flush_ret = if (*ctxt).buf.is_null() {
344        -1
345    } else {
346        io::output_buffer_flush((*ctxt).buf)
347    };
348    // xmlFreeSaveCtxt closes the output buffer and frees the context.
349    if !(*ctxt).buf.is_null() {
350        io::output_buffer_close((*ctxt).buf);
351    }
352    if !(*ctxt).indent.is_null() {
353        libc::free((*ctxt).indent as *mut libc::c_void);
354    }
355    if !(*ctxt).encoding.is_null() {
356        xmlFreeImpl((*ctxt).encoding as *mut core::ffi::c_void);
357    }
358    libc::free(ctxt as *mut libc::c_void);
359    flush_ret
360}
361
362/// `xmlParserErrors xmlSaveFinish(xmlSaveCtxt *ctxt)` — flush, close, free;
363/// returns an xmlParserErrors code (XML_ERR_OK on success).
364///
365/// # UPSTREAM-PARITY
366///
367/// Upstream xmlSaveFinish returns `xmlOutputBufferClose(ctxt->buf)`'s error
368/// code (negated when negative), i.e. XML_ERR_OK (0) on success.
369///
370/// # SAFETY
371///
372/// - `ctxt` must be a valid save context; it is freed by this call.
373#[no_mangle]
374pub unsafe extern "C" fn xmlSaveFinish(ctxt: *mut _xmlSaveCtxt) -> c_int {
375    if ctxt.is_null() {
376        return -1;
377    }
378    let ret = if (*ctxt).buf.is_null() {
379        -1
380    } else {
381        io::output_buffer_close((*ctxt).buf)
382    };
383    if !(*ctxt).indent.is_null() {
384        libc::free((*ctxt).indent as *mut libc::c_void);
385    }
386    if !(*ctxt).encoding.is_null() {
387        xmlFreeImpl((*ctxt).encoding as *mut core::ffi::c_void);
388    }
389    libc::free(ctxt as *mut libc::c_void);
390    if ret < 0 {
391        -ret
392    } else {
393        0
394    }
395}
396
397/// `int xmlSaveSetIndentString(xmlSaveCtxt *ctxt, const char *indent)`.
398///
399/// # SAFETY
400///
401/// - `ctxt` must be a valid save context.
402/// - `indent` must be a valid NUL-terminated string or NULL (reset to
403///   default).
404#[no_mangle]
405pub unsafe extern "C" fn xmlSaveSetIndentString(
406    ctxt: *mut _xmlSaveCtxt,
407    indent: *const c_char,
408) -> c_int {
409    // UPSTREAM-PARITY: xmlSaveSetIndentString rejects NULL/empty/overlong
410    // indents (xmlsave.c 2.15: (ctxt==NULL)||(indent==NULL) -> -1,
411    // len<=0 || len>MAX_INDENT -> -1).
412    if ctxt.is_null() || indent.is_null() {
413        return -1;
414    }
415    let len = libc::strlen(indent) as usize;
416    if len == 0 || len > 60 {
417        return -1;
418    }
419    if !(*ctxt).indent.is_null() {
420        libc::free((*ctxt).indent as *mut libc::c_void);
421        (*ctxt).indent = ptr::null_mut();
422    }
423    let copy = libc::malloc(len + 1) as *mut xmlChar;
424    if copy.is_null() {
425        return -1;
426    }
427    libc::memcpy(
428        copy as *mut libc::c_void,
429        indent as *const libc::c_void,
430        len + 1,
431    );
432    (*ctxt).indent = copy;
433    0
434}
435
436/// `int xmlSaveSetEscape(xmlSaveCtxt *ctxt, xmlCharEncodingOutputFunc escape)`.
437///
438/// # SAFETY
439///
440/// - `ctxt` must be a valid save context.
441#[no_mangle]
442pub unsafe extern "C" fn xmlSaveSetEscape(
443    ctxt: *mut _xmlSaveCtxt,
444    escape: Option<xmlCharEncodingOutputFunc>,
445) -> c_int {
446    if ctxt.is_null() {
447        return -1;
448    }
449    (*ctxt).escape = escape;
450    0
451}
452
453/// `int xmlSaveSetAttrEscape(xmlSaveCtxt *ctxt, xmlCharEncodingOutputFunc escape)`.
454///
455/// # SAFETY
456///
457/// - `ctxt` must be a valid save context.
458#[no_mangle]
459pub unsafe extern "C" fn xmlSaveSetAttrEscape(
460    ctxt: *mut _xmlSaveCtxt,
461    escape: Option<xmlCharEncodingOutputFunc>,
462) -> c_int {
463    if ctxt.is_null() {
464        return -1;
465    }
466    (*ctxt).attrEscape = escape;
467    0
468}
469
470/// Wrap an existing output buffer in a save context (candidate-internal;
471/// does not close the buffer on allocation failure — upstream xmlSaveFormatFileTo
472/// semantics). `encoding` is threaded into the XML declaration like upstream
473/// xmlDocDumpInternal (buf->encoder takes precedence there; the candidate
474/// resolves the encoder at xmlSaveTo* time).
475///
476/// # SAFETY
477///
478/// - `encoding` must be NULL or a valid NUL-terminated string.
479unsafe fn save_ctxt_wrap(
480    buf: *mut _xmlOutputBuffer,
481    options: c_int,
482    encoding: *const c_char,
483) -> *mut _xmlSaveCtxt {
484    if buf.is_null() {
485        return ptr::null_mut();
486    }
487    let ctxt = libc::calloc(1, core::mem::size_of::<_xmlSaveCtxt>()) as *mut _xmlSaveCtxt;
488    if ctxt.is_null() {
489        return ptr::null_mut();
490    }
491    (*ctxt).buf = buf;
492    (*ctxt).options = options;
493    (*ctxt).format = if (options & XML_SAVE_FORMAT) != 0 {
494        1
495    } else {
496        0
497    };
498    (*ctxt).no_decl = if (options & XML_SAVE_NO_DECL) != 0 {
499        1
500    } else {
501        0
502    };
503    (*ctxt).no_empty = if (options & XML_SAVE_NO_EMPTY) != 0 {
504        1
505    } else {
506        0
507    };
508    (*ctxt).as_html = if (options & XML_SAVE_AS_HTML) != 0 {
509        1
510    } else {
511        0
512    };
513    (*ctxt).encoding = if encoding.is_null() {
514        ptr::null_mut()
515    } else {
516        crate::abi::exports_xml2::xmlStrdup(encoding as *const xmlChar)
517    };
518    ctxt
519}
520
521/// `int xmlSaveFormatFileTo(xmlOutputBufferPtr buf, xmlDocPtr cur, const char *encoding, int format)`
522/// — serialize `cur` into an existing output buffer and close it (upstream
523/// xmlsave.c).
524///
525/// # SAFETY
526///
527/// - `buf` must be a valid output buffer (closed by this call).
528/// - `cur` must be a valid document.
529#[no_mangle]
530pub unsafe extern "C" fn xmlSaveFormatFileTo(
531    buf: *mut _xmlOutputBuffer,
532    cur: *mut _xmlDoc,
533    encoding: *const c_char,
534    format: c_int,
535) -> c_int {
536    let options = if format != 0 { XML_SAVE_FORMAT } else { 0 };
537    let ctxt = unsafe { save_ctxt_wrap(buf, options, encoding) };
538    if ctxt.is_null() {
539        return -1;
540    }
541    let ret = unsafe { xmlSaveDoc(ctxt, cur) };
542    let close_ret = unsafe { xmlSaveClose(ctxt) };
543    if ret < 0 {
544        -1
545    } else {
546        close_ret
547    }
548}
549
550/// `int xmlSaveFileTo(xmlOutputBufferPtr buf, xmlDocPtr cur, const char *encoding)`
551/// — upstream xmlsave.c delegates to xmlSaveFormatFileTo(buf, cur, encoding, 0).
552///
553/// # SAFETY
554///
555/// - `buf` must be a valid output buffer (closed by this call).
556/// - `cur` must be a valid document.
557#[no_mangle]
558pub unsafe extern "C" fn xmlSaveFileTo(
559    buf: *mut _xmlOutputBuffer,
560    cur: *mut _xmlDoc,
561    encoding: *const c_char,
562) -> c_int {
563    unsafe { xmlSaveFormatFileTo(buf, cur, encoding, 0) }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::xml::tree::new_doc;
570
571    /// Build a document with a single `root` element.
572    ///
573    /// # Safety
574    ///
575    /// - The returned document is non-NULL and owns its root element; the
576    ///   caller must free it with `tree::free_doc` exactly once.
577    fn doc_with_root() -> *mut _xmlDoc {
578        unsafe {
579            let doc = new_doc(c"1.0".as_ptr() as *const xmlChar);
580            let root =
581                crate::xml::tree::new_node(ptr::null_mut(), c"root".as_ptr() as *const xmlChar);
582            crate::xml::tree::doc_set_root_element(doc, root);
583            doc
584        }
585    }
586
587    /// Save a formatted doc to a buffer and compare the serialized bytes.
588    ///
589    /// # Safety
590    ///
591    /// - `doc` and `buf` are non-NULL (asserted) and valid until freed with
592    ///   `tree::free_doc`/`io::buf_free`; `ctxt` is non-NULL and valid
593    ///   until `xmlSaveFinish`; the buffer content/pointers are valid while
594    ///   the byte slice is constructed and read.
595    #[test]
596    fn test_save_to_buffer_format_and_nodes() {
597        unsafe {
598            let doc = doc_with_root();
599            let buf = io::buf_create(-1);
600            let ctxt = xmlSaveToBuffer(buf, ptr::null(), XML_SAVE_FORMAT);
601            assert!(!ctxt.is_null());
602            assert!(xmlSaveDoc(ctxt, doc) >= 0);
603            assert_eq!(xmlSaveFinish(ctxt), 0);
604            let content = io::buf_content(buf);
605            let len = io::buf_length(buf);
606            let s = core::slice::from_raw_parts(content, len as usize);
607            let expected = "<?xml version=\"1.0\"?>\n<root/>\n";
608            assert_eq!(s, expected.as_bytes());
609            crate::xml::tree::free_doc(doc);
610            io::buf_free(buf);
611        }
612    }
613
614    /// xmlSaveFormatFileTo with an encoding name: the XML declaration
615    /// carries `encoding="..."` (upstream xmlsave.c xmlDocDumpInternal —
616    /// tree2.c's xmlSaveFormatFileEnc("-", doc, "UTF-8", 1) path; Phase-12
617    /// EXTERNAL-CONSUMERS court).
618    ///
619    /// # Safety
620    ///
621    /// - `doc` and `buf` are non-NULL (asserted) and valid until freed;
622    ///   the buffer content is valid while the byte slice is read.
623    #[test]
624    fn test_save_format_file_to_encoding_decl() {
625        unsafe {
626            let doc = doc_with_root();
627            let buf = io::buf_create(-1);
628            let obuf = io::output_buffer_create_buffer(buf, ptr::null_mut());
629            assert!(!obuf.is_null());
630            assert!(xmlSaveFormatFileTo(obuf, doc, c"UTF-8".as_ptr(), 1) >= 0);
631            let content = io::buf_content(buf);
632            let len = io::buf_length(buf);
633            let s = core::slice::from_raw_parts(content, len as usize);
634            assert_eq!(s, b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<root/>\n");
635            crate::xml::tree::free_doc(doc);
636            io::buf_free(buf);
637        }
638    }
639
640    /// Save a doc without an XML declaration and compare the output.
641    ///
642    /// # Safety
643    ///
644    /// - `doc` and `buf` are non-NULL (asserted) and valid until freed;
645    ///   `ctxt` is valid until `xmlSaveFinish`; the buffer content is
646    ///   valid while the byte slice is read.
647    #[test]
648    fn test_save_no_decl() {
649        unsafe {
650            let doc = doc_with_root();
651            let buf = io::buf_create(-1);
652            let ctxt = xmlSaveToBuffer(buf, ptr::null(), XML_SAVE_NO_DECL);
653            assert!(!ctxt.is_null());
654            xmlSaveDoc(ctxt, doc);
655            xmlSaveFinish(ctxt);
656            let content = io::buf_content(buf);
657            let len = io::buf_length(buf);
658            let s = core::slice::from_raw_parts(content, len as usize);
659            assert_eq!(s, b"<root/>\n");
660            crate::xml::tree::free_doc(doc);
661            io::buf_free(buf);
662        }
663    }
664
665    /// Phase 14.3 (dom S1): an HTML-parsed document saved with
666    /// XML_SAVE_AS_XML (PHP DOMDocument::saveXML) is dumped by the XML
667    /// serializer with the XML declaration and doc->standalone — upstream
668    /// xmlSaveDocInternal only takes the HTML branch when an HTML/XHTML save
669    /// was requested. The pre-fix html-document arm always used the HTML
670    /// serializer, so the saveXML of a loadHTML()'d document dropped the
671    /// declaration entirely (ext/dom dom005 / gh15670 / gh16535 / gh19612).
672    ///
673    /// # Safety
674    ///
675    /// - the parsed html doc and buffer are freed exactly once; content is
676    ///   valid while the byte slice is read.
677    #[test]
678    fn test_save_html_doc_as_xml_includes_declaration() {
679        unsafe {
680            let doc = crate::xml::html::parse_memory(c"<html><body>x</body></html>".as_ptr(), 23);
681            assert!(!doc.is_null());
682            assert_eq!((*doc).standalone, 1);
683            let buf = io::buf_create(-1);
684            // PHP DOMDocument::saveXML passes XML_SAVE_AS_XML (1 << 5) —
685            // upstream xmlsave.h; the candidate save layer only exposes the
686            // AS_HTML bit, so pass the raw upstream option value.
687            let ctxt = xmlSaveToBuffer(buf, ptr::null(), 1 << 5);
688            assert!(!ctxt.is_null());
689            xmlSaveDoc(ctxt, doc);
690            xmlSaveFinish(ctxt);
691            let content = io::buf_content(buf);
692            let len = io::buf_length(buf);
693            let s = core::slice::from_raw_parts(content, len as usize);
694            let text = String::from_utf8_lossy(s);
695            assert!(
696                text.starts_with("<?xml version=\"1.0\" standalone=\"yes\"?>"),
697                "AS_XML save of an html doc must emit the declaration: {text:?}"
698            );
699            crate::xml::tree::free_doc(doc);
700            io::buf_free(buf);
701        }
702    }
703
704    /// Phase 14.3 (dom S1 / xmlsave parity): with NO output encoder on the
705    /// save context (ctxt->encoding == NULL), upstream xmlSaveWriteText sets
706    /// XML_ESCAPE_NON_ASCII and xmlSerializeText writes every non-ASCII
707    /// character as a hex reference (xmlSerializeHexCharRef) — `café` →
708    /// `caf&#xE9;`, U+00A0 → `&#xA0;` — in text AND attribute content. The
709    /// pre-fix serializer passed raw UTF-8 bytes through (ext/dom dom005's
710    /// xml save of html-origin text, xslt/xmlreader non-ASCII saves).
711    ///
712    /// # Safety
713    ///
714    /// - doc/buf are freed exactly once; the byte slice is valid while read.
715    #[test]
716    fn test_save_no_encoding_escapes_non_ascii() {
717        unsafe {
718            // src: <r a="caf\xc3\xa9 \xc2\xa0">caf\xc3\xa9 \xc2\xa0 x</r>
719            let doc = crate::abi::exports_xml2::xmlReadMemory(
720                b"<r a=\"caf\xc3\xa9\">caf\xc3\xa9 \xc2\xa0 x</r>\0".as_ptr() as *const c_char,
721                27,
722                ptr::null(),
723                ptr::null(),
724                0,
725            );
726            assert!(!doc.is_null());
727            let buf = io::buf_create(-1);
728            let ctxt = xmlSaveToBuffer(buf, ptr::null(), 0);
729            assert!(!ctxt.is_null());
730            xmlSaveDoc(ctxt, doc);
731            xmlSaveFinish(ctxt);
732            let content = io::buf_content(buf);
733            let len = io::buf_length(buf);
734            let s = core::slice::from_raw_parts(content, len as usize);
735            let text = String::from_utf8_lossy(s);
736            assert!(
737                text.contains("a=\"caf&#xE9;\""),
738                "attr content must be hex-escaped: {text:?}"
739            );
740            assert!(
741                text.contains(">caf&#xE9; &#xA0; x</r>"),
742                "text content must be hex-escaped: {text:?}"
743            );
744            crate::xml::tree::free_doc(doc);
745            io::buf_free(buf);
746        }
747    }
748
749    /// Set an indent string and verify it appears in the serialized output.
750    ///
751    /// # Safety
752    ///
753    /// - `doc`, `buf` and `ctxt` are non-NULL (asserted) and valid until
754    ///   their respective frees; the indent string is a static
755    ///   NUL-terminated string valid for `xmlSaveSetIndentString`; the
756    ///   buffer content is valid while the byte slice is read.
757    #[test]
758    fn test_save_set_indent_string() {
759        unsafe {
760            let doc = doc_with_root();
761            let child =
762                crate::xml::tree::new_node(ptr::null_mut(), c"child".as_ptr() as *const xmlChar);
763            crate::xml::tree::add_child(crate::xml::tree::doc_get_root_element(doc), child);
764            let buf = io::buf_create(-1);
765            let ctxt = xmlSaveToBuffer(buf, ptr::null(), XML_SAVE_FORMAT);
766            assert!(!ctxt.is_null());
767            assert_eq!(
768                xmlSaveSetIndentString(ctxt, c"\t".as_ptr() as *const c_char),
769                0
770            );
771            xmlSaveDoc(ctxt, doc);
772            xmlSaveFinish(ctxt);
773            let content = io::buf_content(buf);
774            let len = io::buf_length(buf);
775            let s = core::slice::from_raw_parts(content, len as usize);
776            let expected = "<?xml version=\"1.0\"?>\n<root>\n\t<child/>\n</root>\n";
777            assert_eq!(s, expected.as_bytes());
778            crate::xml::tree::free_doc(doc);
779            io::buf_free(buf);
780        }
781    }
782
783    /// NULL and invalid arguments must be rejected without crashing.
784    ///
785    /// # Safety
786    ///
787    /// - `xmlSaveToFd`, `xmlSaveFlush`, `xmlSaveFinish`, `xmlSaveClose`,
788    ///   `xmlSaveSetIndentString`, `xmlSaveSetEscape`,
789    ///   `xmlSaveSetAttrEscape`, `xmlSaveDoc` and `xmlSaveTree` handle NULL
790    ///   contexts/documents as documented no-ops returning an error code;
791    ///   no pointer is dereferenced.
792    #[test]
793    fn test_save_close_null_and_errors() {
794        unsafe {
795            assert!(xmlSaveToFd(-1, ptr::null(), 0).is_null());
796            assert_eq!(xmlSaveFlush(ptr::null_mut()), -1);
797            assert_eq!(xmlSaveFinish(ptr::null_mut()), -1);
798            assert_eq!(xmlSaveClose(ptr::null_mut()), -1);
799            assert_eq!(xmlSaveSetIndentString(ptr::null_mut(), ptr::null()), -1);
800            assert_eq!(xmlSaveSetEscape(ptr::null_mut(), None), -1);
801            assert_eq!(xmlSaveSetAttrEscape(ptr::null_mut(), None), -1);
802            assert_eq!(xmlSaveDoc(ptr::null_mut(), ptr::null_mut()), -1);
803            assert_eq!(xmlSaveTree(ptr::null_mut(), ptr::null_mut()), -1);
804        }
805    }
806}