Skip to main content

libxml_rs/abi/
exports_uri.rs

1//! C ABI exports for the URI subsystem (`uri.h`) — family closure (11.1-I).
2//!
3//! This module implements the 11 URI entry points assigned to this
4//! workstream:
5//!
6//! 1. `xmlBuildURI` / `xmlBuildURISafe`
7//! 2. `xmlBuildRelativeURI` / `xmlBuildRelativeURISafe`
8//! 3. `xmlCanonicPath`
9//! 4. `xmlPathToURI`
10//! 5. `xmlPrintURI`
11//! 6. `xmlURIEscape`
12//! 7. `xmlNormalizeWindowsPath`
13//! 8. `xmlCheckLanguageID`
14//! 9. `xmlParseURISafe`
15//!
16//! All functions follow the upstream `uri.c` / `xmlIO.c` / `parser.c`
17//! implementations (libxml2 2.15.3, see `archaeology/libxml2-git`),
18//! reusing the internal parser/resolver from `src/xml/uri/mod.rs`
19//! (`parse_uri`, `build_uri`, `resolve_uri`, `normalize_uri_path`,
20//! `xmlURIEscapeStr`, `xmlSaveUri`, `xmlParseURI`).
21//!
22//! All returned strings are allocated with `xmlMalloc` so C callers release
23//! them with `xmlFree`, exactly as with upstream libxml2.
24//!
25//! The `*Safe` variants follow the upstream convention of returning an `int`
26//! status code (0 = success, 1 = invalid argument/URI, -1 = allocation
27//! failure) and storing the result through an out parameter.
28
29#![allow(
30    missing_docs,
31    non_snake_case,
32    non_camel_case_types,
33    non_upper_case_globals
34)]
35#![allow(clippy::missing_safety_doc)]
36#![allow(clippy::not_unsafe_ptr_arg_deref)]
37
38use core::ffi::c_void;
39use core::ptr;
40use std::os::raw::{c_char, c_int};
41
42use crate::abi::allocator;
43use crate::abi::types::xmlChar;
44use crate::xml::uri::{build_uri, parse_uri, resolve_uri, UriParts};
45
46extern "C" {
47    /// `size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);`
48    ///
49    /// `FILE*` is opaque to Rust, so the stream is carried as `*mut c_void`.
50    fn fwrite(ptr: *const c_void, size: usize, nmemb: usize, stream: *mut c_void) -> usize;
51}
52
53// ── Internal helpers ─────────────────────────────────────────────────────────
54
55/// View a NUL-terminated C string as a byte slice (empty when NULL).
56///
57/// # Safety
58///
59/// `p` must be a valid NUL-terminated string (or NULL) for the duration of
60/// the returned slice.
61unsafe fn cstr_bytes<'a>(p: *const c_char) -> &'a [u8] {
62    if p.is_null() {
63        return &[];
64    }
65    let len = unsafe { libc::strlen(p) };
66    unsafe { core::slice::from_raw_parts(p as *const u8, len) }
67}
68
69/// Copy `bytes` into a fresh `xmlMalloc`'d NUL-terminated string.
70///
71/// Returns NULL on allocation failure. An empty `bytes` yields a valid
72/// 1-byte NUL string (never NULL), matching `xmlStrdup("")`.
73unsafe fn dup_c_str(bytes: &[u8]) -> *mut xmlChar {
74    let len = bytes.len();
75    let p = unsafe { allocator::xmlMallocImpl(len + 1) as *mut u8 };
76    if p.is_null() {
77        return ptr::null_mut();
78    }
79    unsafe {
80        ptr::copy_nonoverlapping(bytes.as_ptr(), p, len);
81        *p.add(len) = 0;
82    }
83    p as *mut xmlChar
84}
85
86/// Escape `s` with `xmlURIEscapeStr` semantics (keep unreserved + '%' +
87/// every byte in `list`), returning an `xmlMalloc`'d string.
88///
89/// Returns NULL on allocation failure.
90unsafe fn escape_slice(s: &[u8], list: &[u8]) -> *mut xmlChar {
91    let mut s_buf = s.to_vec();
92    s_buf.push(0);
93    let mut l_buf = list.to_vec();
94    l_buf.push(0);
95    unsafe {
96        crate::xml::uri::xmlURIEscapeStr(
97            s_buf.as_ptr() as *const xmlChar,
98            l_buf.as_ptr() as *const xmlChar,
99        )
100    }
101}
102
103// ── RFC 3986 character grammar (upstream uri.c, with/without ALLOW_UNWISE) ──
104
105/// `unreserved` per RFC 3986, plus the "unwise" set when `allow_unwise`
106/// mirrors upstream `XML_URI_ALLOW_UNWISE` in `xmlIsUnreserved`.
107fn v_unres(b: u8, allow_unwise: bool) -> bool {
108    b.is_ascii_alphanumeric()
109        || matches!(b, b'-' | b'.' | b'_' | b'~')
110        || (allow_unwise && matches!(b, b'{' | b'}' | b'|' | b'\\' | b'^' | b'[' | b']' | b'`'))
111}
112
113/// `sub-delims` per RFC 3986.
114fn v_sub_delim(b: u8) -> bool {
115    matches!(
116        b,
117        b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
118    )
119}
120
121/// `pct-encoded = "%" HEXDIG HEXDIG` starting at position `i`.
122fn v_pct(s: &[u8], i: usize) -> bool {
123    i + 2 < s.len() && s[i] == b'%' && s[i + 1].is_ascii_hexdigit() && s[i + 2].is_ascii_hexdigit()
124}
125
126/// `pchar = unreserved / pct-encoded / sub-delims / ":" / "@"` at position `i`.
127fn v_pchar(s: &[u8], i: usize, allow_unwise: bool) -> bool {
128    v_unres(s[i], allow_unwise) || v_pct(s, i) || v_sub_delim(s[i]) || s[i] == b':' || s[i] == b'@'
129}
130
131/// Advance over a run of `pchar` bytes.
132fn v_advance_pchar(s: &[u8], i: &mut usize, allow_unwise: bool) {
133    while *i < s.len() && v_pchar(s, *i, allow_unwise) {
134        if s[*i] == b'%' {
135            *i += 3;
136        } else {
137            *i += 1;
138        }
139    }
140}
141
142/// `authority = [ userinfo "@" ] host [ ":" port ]`.
143///
144/// Mirrors the upstream character set: unreserved / pct-encoded /
145/// sub-delims / ":" / "@", with `[...]` IP-literals accepted as a unit
146/// (upstream `xmlParse3986IPLiteral`).
147fn v_authority(s: &[u8], i: &mut usize, allow_unwise: bool) -> bool {
148    while *i < s.len() && !matches!(s[*i], b'/' | b'?' | b'#') {
149        let b = s[*i];
150        if b == b'[' {
151            if let Some(close) = s[*i + 1..].iter().position(|&c| c == b']') {
152                *i += close + 2;
153            } else {
154                return false;
155            }
156        } else if v_unres(b, allow_unwise)
157            || v_pct(s, *i)
158            || v_sub_delim(b)
159            || b == b':'
160            || b == b'@'
161        {
162            if b == b'%' {
163                *i += 3;
164            } else {
165                *i += 1;
166            }
167        } else {
168            return false;
169        }
170    }
171    true
172}
173
174/// `path-abempty = *( "/" segment )` — empty segments allowed.
175fn v_path_abempty(s: &[u8], i: &mut usize, allow_unwise: bool) -> bool {
176    while *i < s.len() && s[*i] == b'/' {
177        *i += 1;
178        v_advance_pchar(s, i, allow_unwise);
179    }
180    true
181}
182
183/// `path-absolute = "/" [ segment-nz *( "/" segment ) ]`, with `i` just
184/// past the leading "/".
185fn v_path_absolute(s: &[u8], i: &mut usize, allow_unwise: bool) -> bool {
186    if *i < s.len() && !matches!(s[*i], b'?' | b'#') {
187        if !v_pchar(s, *i, allow_unwise) {
188            return false;
189        }
190        v_advance_pchar(s, i, allow_unwise);
191    }
192    v_path_abempty(s, i, allow_unwise)
193}
194
195/// `path-rootless = segment-nz *( "/" segment )`.
196fn v_path_rootless(s: &[u8], i: &mut usize, allow_unwise: bool) -> bool {
197    if *i >= s.len() || !v_pchar(s, *i, allow_unwise) {
198        return false;
199    }
200    v_advance_pchar(s, i, allow_unwise);
201    v_path_abempty(s, i, allow_unwise)
202}
203
204/// `path-noscheme = segment-nz-nc *( "/" segment )` — the first segment
205/// must not contain ":".
206fn v_path_noscheme(s: &[u8], i: &mut usize, allow_unwise: bool) -> bool {
207    if *i >= s.len()
208        || !(v_unres(s[*i], allow_unwise) || v_pct(s, *i) || v_sub_delim(s[*i]) || s[*i] == b'@')
209    {
210        return false;
211    }
212    while *i < s.len()
213        && (v_unres(s[*i], allow_unwise) || v_pct(s, *i) || v_sub_delim(s[*i]) || s[*i] == b'@')
214    {
215        if s[*i] == b'%' {
216            *i += 3;
217        } else {
218            *i += 1;
219        }
220    }
221    v_path_abempty(s, i, allow_unwise)
222}
223
224/// Validate a URI reference against the upstream RFC 3986 grammar
225/// (`xmlParse3986URIReference`, uri.c).
226///
227/// `allow_unwise` mirrors parsing with `XML_URI_ALLOW_UNWISE` set, which is
228/// exactly how `xmlURIEscape` parses its input. `xmlParseURISafe` and the
229/// `xmlBuildURI*` family parse without it.
230fn uri_reference_valid(s: &[u8], allow_unwise: bool) -> bool {
231    let n = s.len();
232    let mut i = 0usize;
233    let mut has_scheme = false;
234
235    // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"
236    if i < n && s[i].is_ascii_alphabetic() {
237        let mut j = i;
238        while j < n && (s[j].is_ascii_alphanumeric() || matches!(s[j], b'+' | b'-' | b'.')) {
239            j += 1;
240        }
241        if j < n && s[j] == b':' {
242            has_scheme = true;
243            i = j + 1;
244        }
245    }
246
247    if has_scheme {
248        // hier-part
249        if i + 1 < n && s[i] == b'/' && s[i + 1] == b'/' {
250            i += 2;
251            if !v_authority(s, &mut i, allow_unwise) || !v_path_abempty(s, &mut i, allow_unwise) {
252                return false;
253            }
254        } else if i < n && s[i] == b'/' {
255            i += 1;
256            if !v_path_absolute(s, &mut i, allow_unwise) {
257                return false;
258            }
259        } else if i < n && v_pchar(s, i, allow_unwise) {
260            if !v_path_rootless(s, &mut i, allow_unwise) {
261                return false;
262            }
263        }
264    } else {
265        // relative-ref
266        if i + 1 < n && s[i] == b'/' && s[i + 1] == b'/' {
267            i += 2;
268            if !v_authority(s, &mut i, allow_unwise) || !v_path_abempty(s, &mut i, allow_unwise) {
269                return false;
270            }
271        } else if i < n && s[i] == b'/' {
272            i += 1;
273            if !v_path_absolute(s, &mut i, allow_unwise) {
274                return false;
275            }
276        } else if i < n && v_pchar(s, i, allow_unwise) {
277            if !v_path_noscheme(s, &mut i, allow_unwise) {
278                return false;
279            }
280        }
281    }
282
283    // [ "?" query ]
284    if i < n && s[i] == b'?' {
285        i += 1;
286        while i < n && (v_pchar(s, i, allow_unwise) || matches!(s[i], b'/' | b'?')) {
287            if s[i] == b'%' {
288                i += 3;
289            } else {
290                i += 1;
291            }
292        }
293    }
294    // [ "#" fragment ]
295    if i < n && s[i] == b'#' {
296        i += 1;
297        while i < n && (v_pchar(s, i, allow_unwise) || matches!(s[i], b'/' | b'?')) {
298            if s[i] == b'%' {
299                i += 3;
300            } else {
301                i += 1;
302            }
303        }
304    }
305    i == n
306}
307
308/// Port of upstream `xmlNormalizePath` (uri.c), a filesystem path
309/// normalizer: collapses "./" and extra separators, resolves ".." segments,
310/// keeps a leading "../" on relative paths and a trailing "/" or ".".
311///
312/// On Linux the only effect of `is_file` is keeping "." when the result
313/// would otherwise be empty.
314fn normalize_path(path: &[u8], is_file: bool) -> Vec<u8> {
315    if path.is_empty() {
316        return Vec::new();
317    }
318    let is_sep = |c: u8| c == b'/';
319    let mut out: Vec<u8> = Vec::with_capacity(path.len());
320    let mut cur = 0usize;
321    let mut num_seg: i64 = 0;
322
323    if is_sep(path[0]) {
324        cur += 1;
325        out.push(b'/');
326    }
327
328    while cur < path.len() {
329        // Collapse multiple separators.
330        while cur < path.len() && is_sep(path[cur]) {
331            cur += 1;
332        }
333        if cur >= path.len() {
334            break;
335        }
336
337        if path[cur] == b'.' {
338            if cur + 1 >= path.len() {
339                // "." at end of path → ignore.
340                break;
341            } else if is_sep(path[cur + 1]) {
342                // Skip "./".
343                cur += 2;
344                continue;
345            } else if path[cur + 1] == b'.' && (cur + 2 >= path.len() || is_sep(path[cur + 2])) {
346                if num_seg > 0 {
347                    // Remove the last segment and its trailing separator:
348                    // the C code backs `out` up past the separator, then
349                    // past the segment, stopping at the previous '/'.
350                    out.pop(); // the separator after the segment
351                    while !out.is_empty() && out.last() != Some(&b'/') {
352                        out.pop(); // the segment itself
353                    }
354                    num_seg -= 1;
355                    if cur + 2 >= path.len() {
356                        break;
357                    }
358                    cur += 3;
359                    continue;
360                } else if path.get(out.len()).copied() == Some(b'/') {
361                    // Ignore extraneous ".." in absolute paths.
362                    if cur + 2 >= path.len() {
363                        break;
364                    }
365                    cur += 3;
366                    continue;
367                } else {
368                    // Keep "../" at the start of relative paths.
369                    num_seg -= 1;
370                }
371            }
372        }
373
374        // Copy segment.
375        while cur < path.len() && !is_sep(path[cur]) {
376            out.push(path[cur]);
377            cur += 1;
378        }
379        // Copy separator.
380        if cur < path.len() {
381            cur += 1;
382            out.push(b'/');
383        }
384        num_seg += 1;
385    }
386
387    // Keep "." if the output is empty and it's a file.
388    if is_file && out.is_empty() {
389        out.push(b'.');
390    }
391    out
392}
393
394// ── xmlSaveUri-style serialization (upstream escaping rules) ─────────────────
395
396/// `IS_UNRESERVED` from upstream uri.c: ALPHANUM + mark
397/// (`- _ . ! ~ * ' ( )`).
398fn save_unreserved(b: u8) -> bool {
399    b.is_ascii_alphanumeric()
400        || matches!(
401            b,
402            b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')'
403        )
404}
405
406/// `IS_RESERVED` from upstream uri.c: `; / ? : @ & = + $ , [ ]`.
407fn save_reserved(b: u8) -> bool {
408    matches!(
409        b,
410        b';' | b'/' | b'?' | b':' | b'@' | b'&' | b'=' | b'+' | b'$' | b',' | b'[' | b']'
411    )
412}
413
414fn push_escaped(out: &mut Vec<u8>, b: u8) {
415    const HEX: &[u8; 16] = b"0123456789ABCDEF";
416    out.push(b'%');
417    out.push(HEX[(b >> 4) as usize]);
418    out.push(HEX[(b & 0x0f) as usize]);
419}
420
421/// Escape `s` into `out`, keeping bytes for which `keep` returns true.
422fn save_escape(out: &mut Vec<u8>, s: &[u8], keep: impl Fn(u8) -> bool) {
423    for &b in s {
424        if keep(b) {
425            out.push(b);
426        } else {
427            push_escaped(out, b);
428        }
429    }
430}
431
432/// Serialize parsed URI parts with the exact escaping of upstream
433/// `xmlSaveUri` (uri.c) — used for the "return the URI itself" tail of
434/// `xmlBuildRelativeURISafe`.
435fn save_uri_parts(p: &UriParts) -> Vec<u8> {
436    let mut out = Vec::new();
437
438    if let Some(scheme) = p.scheme.as_deref() {
439        out.extend_from_slice(scheme);
440        out.push(b':');
441    }
442
443    if let Some(opaque) = p.opaque.as_deref() {
444        // Kept: IS_UNRESERVED || IS_RESERVED.
445        save_escape(&mut out, opaque, |b| save_unreserved(b) || save_reserved(b));
446    } else if p.server.is_some() || p.port != 0 {
447        out.extend_from_slice(b"//");
448        if let Some(user) = p.user.as_deref() {
449            // Kept: IS_UNRESERVED || ";:&=+$,."
450            save_escape(&mut out, user, |b| {
451                save_unreserved(b) || matches!(b, b';' | b':' | b'&' | b'=' | b'+' | b'$' | b',')
452            });
453            out.push(b'@');
454        }
455        if let Some(server) = p.server.as_deref() {
456            // The internal representation already includes the port.
457            out.extend_from_slice(server);
458        }
459    } else if let Some(authority) = p.authority.as_deref() {
460        out.extend_from_slice(b"//");
461        // Kept: IS_UNRESERVED || "$,;:@&=+."
462        save_escape(&mut out, authority, |b| {
463            save_unreserved(b) || matches!(b, b'$' | b',' | b';' | b':' | b'@' | b'&' | b'=' | b'+')
464        });
465    }
466
467    if let Some(path) = p.path.as_deref() {
468        // The colon in file:///d: must not be escaped (upstream special
469        // case), or Windows accesses fail later.
470        let mut path = path;
471        if p.scheme.as_deref() == Some(b"file")
472            && path.starts_with(b"/")
473            && path.len() >= 3
474            && path[1].is_ascii_alphabetic()
475            && path[2] == b':'
476        {
477            out.extend_from_slice(&path[..3]);
478            path = &path[3..];
479        }
480        // Kept: IS_UNRESERVED || "/;@&=+$,."
481        save_escape(&mut out, path, |b| {
482            save_unreserved(b) || matches!(b, b'/' | b';' | b'@' | b'&' | b'=' | b'+' | b'$' | b',')
483        });
484    }
485
486    // The internal parser does not track query_raw, so the escaped query
487    // form is used, as upstream does when query_raw is NULL.
488    if let Some(query) = p.query.as_deref() {
489        out.push(b'?');
490        save_escape(&mut out, query, |b| save_unreserved(b) || save_reserved(b));
491    }
492    if let Some(fragment) = p.fragment.as_deref() {
493        out.push(b'#');
494        save_escape(&mut out, fragment, |b| {
495            save_unreserved(b) || save_reserved(b)
496        });
497    }
498
499    out
500}
501
502// ── xmlBuildURI / xmlBuildURISafe ────────────────────────────────────────────
503
504/// Resolve a URI reference against a base URI (safe variant).
505///
506/// # UPSTREAM-PARITY
507///
508/// ```c
509/// int xmlBuildURISafe(const xmlChar *URI, const xmlChar *base, xmlChar **out);
510/// ```
511///
512/// Implements RFC 3986 §5.2 resolution via the internal `resolve_uri` +
513/// `build_uri`, matching upstream `xmlBuildURISafe` (uri.c):
514///
515/// - `out == NULL` → returns 1
516/// - `URI == NULL` → returns 1
517/// - `base == NULL` → `*out` is a copy of `URI` (returns 0)
518/// - a non-empty `URI` must parse as a URI reference, otherwise 1
519/// - an absolute `URI` (with a scheme) is returned unchanged
520/// - a `base` without "://" is treated as a filesystem path
521/// - an invalid `base` with "://" yields `*out` = the URI itself (0)
522/// - an empty `URI` resolves to `base` with the base fragment ignored
523///
524/// Returns 0 on success, 1 if the URI/base is invalid, -1 on allocation
525/// failure. On failure `*out` is left NULL.
526///
527/// # Safety
528///
529/// `URI`/`base` must be valid NUL-terminated strings or NULL; `out` must be
530/// a valid pointer to a writable `xmlChar*`.
531#[no_mangle]
532pub unsafe extern "C" fn xmlBuildURISafe(
533    uri: *const c_char,
534    base: *const c_char,
535    out: *mut *mut xmlChar,
536) -> c_int {
537    unsafe {
538        if out.is_null() {
539            return 1;
540        }
541        *out = ptr::null_mut();
542        if uri.is_null() {
543            return 1;
544        }
545        let uri_bytes = cstr_bytes(uri);
546
547        if base.is_null() {
548            // Upstream: base == NULL → val = xmlStrdup(URI).
549            *out = dup_c_str(uri_bytes);
550            return if (*out).is_null() { -1 } else { 0 };
551        }
552
553        let base_bytes = cstr_bytes(base);
554
555        // Upstream parses the URI first (strictly); an invalid URI fails
556        // regardless of the base.
557        if !uri_bytes.is_empty() && !uri_reference_valid(uri_bytes, false) {
558            return 1;
559        }
560
561        // Base without "://": treated as a filesystem path
562        // (upstream xmlResolvePath) — approximated by the internal resolver.
563        if !base_bytes.windows(3).any(|w| w == b"://") {
564            let resolved = match resolve_uri(base_bytes, uri_bytes) {
565                Some(v) => v,
566                None => return 1,
567            };
568            *out = dup_c_str(&resolved);
569            return if (*out).is_null() { -1 } else { 0 };
570        }
571
572        // Base with "://": full RFC 3986 merge.
573        if uri_bytes.is_empty() {
574            // Empty reference: upstream returns the base with the base
575            // fragment ignored; an invalid base yields 1 with NULL.
576            if !uri_reference_valid(base_bytes, false) {
577                return 1;
578            }
579            let mut base_parts = match parse_uri(base_bytes) {
580                Some(p) => p,
581                None => return 1,
582            };
583            base_parts.fragment = None;
584            let resolved = build_uri(&base_parts);
585            *out = dup_c_str(&resolved);
586            return if (*out).is_null() { -1 } else { 0 };
587        }
588
589        let ref_parts = match parse_uri(uri_bytes) {
590            Some(p) => p,
591            None => return 1,
592        };
593        if ref_parts.scheme.is_some() {
594            // The URI is absolute — don't modify.
595            *out = dup_c_str(uri_bytes);
596            return if (*out).is_null() { -1 } else { 0 };
597        }
598
599        if !uri_reference_valid(base_bytes, false) {
600            // Invalid base: upstream returns the URI itself with ret 0.
601            let saved = build_uri(&ref_parts);
602            *out = dup_c_str(&saved);
603            return if (*out).is_null() { -1 } else { 0 };
604        }
605
606        // RFC 3986 §5.2.2: a reference with an empty path ("?query" or
607        // "#fragment" only) inherits the base path; the base fragment is
608        // ignored and the reference query (or the base query) applies.
609        let resolved = if uri_bytes.starts_with(b"?") || uri_bytes.starts_with(b"#") {
610            let mut base_parts = match parse_uri(base_bytes) {
611                Some(p) => p,
612                None => return 1,
613            };
614            let ref2 = match parse_uri(uri_bytes) {
615                Some(p) => p,
616                None => return 1,
617            };
618            if ref2.query.is_some() {
619                base_parts.query = ref2.query;
620            }
621            base_parts.fragment = ref2.fragment;
622            build_uri(&base_parts)
623        } else {
624            match resolve_uri(base_bytes, uri_bytes) {
625                Some(v) => v,
626                None => return 1,
627            }
628        };
629        *out = dup_c_str(&resolved);
630        if (*out).is_null() {
631            -1
632        } else {
633            0
634        }
635    }
636}
637
638/// Resolve a URI reference against a base URI.
639///
640/// # UPSTREAM-PARITY
641///
642/// ```c
643/// xmlChar *xmlBuildURI(const xmlChar *URI, const xmlChar *base);
644/// ```
645///
646/// Returns the resolved URI (`xmlMalloc`'d, free with `xmlFree`) or NULL on
647/// error. Both arguments may be NULL (then returns NULL); a NULL `base`
648/// yields a copy of `URI`.
649///
650/// # Safety
651///
652/// `URI`/`base` must be valid NUL-terminated strings or NULL.
653#[no_mangle]
654pub unsafe extern "C" fn xmlBuildURI(uri: *const c_char, base: *const c_char) -> *mut xmlChar {
655    let mut out: *mut xmlChar = ptr::null_mut();
656    let ret = unsafe { xmlBuildURISafe(uri, base, &mut out) };
657    if ret != 0 {
658        return ptr::null_mut();
659    }
660    out
661}
662
663// ── xmlBuildRelativeURI / xmlBuildRelativeURISafe ───────────────────────────
664
665/// Compute a relative URI from `URI` to `base` (safe variant).
666///
667/// # UPSTREAM-PARITY
668///
669/// ```c
670/// int xmlBuildRelativeURISafe(const xmlChar *URI, const xmlChar *base, xmlChar **out);
671/// ```
672///
673/// Port of upstream `xmlBuildRelativeURISafe` (uri.c):
674///
675/// - `out == NULL` → returns 1
676/// - `URI == NULL` or empty → returns 1
677/// - if `URI` contains "://" it must parse as a URI reference; otherwise
678///   `*out` is a copy of `URI` (0)
679/// - strings without "://" are treated as filesystem paths (normalized)
680/// - if `base` is NULL/empty/invalid, or the scheme/server/port differ,
681///   `*out` is the URI itself
682/// - identical paths yield the empty string; otherwise the shortest
683///   relative reference (`../` groups plus the unique suffix) is computed
684///
685/// Returns 0 on success, 1 for invalid arguments, -1 on allocation failure.
686/// On failure `*out` is left NULL.
687///
688/// # Safety
689///
690/// `URI`/`base` must be valid NUL-terminated strings or NULL; `out` must be
691/// a valid pointer to a writable `xmlChar*`.
692#[no_mangle]
693pub unsafe extern "C" fn xmlBuildRelativeURISafe(
694    uri: *const c_char,
695    base: *const c_char,
696    out: *mut *mut xmlChar,
697) -> c_int {
698    unsafe {
699        if out.is_null() {
700            return 1;
701        }
702        *out = ptr::null_mut();
703        if uri.is_null() {
704            return 1;
705        }
706        let uri_bytes = cstr_bytes(uri);
707        if uri_bytes.is_empty() {
708            return 1;
709        }
710
711        // Upstream xmlParseUriOrPath(URI, &ref): strings containing "://"
712        // are parsed as URI references (invalid ones are returned as-is);
713        // other strings are normalized filesystem paths.
714        let mut ref_parts = match parse_uri_or_path(uri_bytes) {
715            Ok(p) => p,
716            Err(raw) => {
717                *out = dup_c_str(&raw);
718                return if (*out).is_null() { -1 } else { 0 };
719            }
720        };
721
722        let mut val: *mut xmlChar = ptr::null_mut();
723        let mut ret: c_int = 0;
724
725        // "Return URI if base is empty" (base == NULL || base[0] == 0).
726        let base_empty = base.is_null() || cstr_bytes(base).is_empty();
727        if !base_empty {
728            let base_bytes = cstr_bytes(base);
729            match parse_uri_or_path(base_bytes) {
730                Ok(base_parts) => {
731                    if ref_parts.scheme != base_parts.scheme
732                        || ref_parts.server != base_parts.server
733                        || ref_parts.port != base_parts.port
734                    {
735                        // Scheme/server/port differ → return the URI.
736                        // val stays NULL; the common tail saves ref_parts.
737                    } else if ref_parts.path == base_parts.path {
738                        // Identical paths → empty relative reference.
739                        val = dup_c_str(b"");
740                        if val.is_null() {
741                            ret = -1;
742                        }
743                    } else if base_parts.path.is_none() {
744                        // Base has no path: the whole ref path is the suffix.
745                        val = escape_slice(ref_parts.path.as_deref().unwrap_or(b""), b"/;&=+$,");
746                        if val.is_null() {
747                            ret = -1;
748                        }
749                    } else {
750                        // ref->path is guaranteed non-NULL from here on
751                        // (upstream replaces a NULL path with "/").
752                        if ref_parts.path.is_none() {
753                            ref_parts.path = Some(b"/".to_vec());
754                        }
755                        let b = base_parts.path.as_deref().unwrap();
756                        let r = ref_parts.path.as_deref().unwrap();
757
758                        // "Return URI if URI and base aren't both absolute
759                        // or both relative."
760                        if (b.first() == Some(&b'/')) != (r.first() == Some(&b'/')) {
761                            // val stays NULL → common tail saves ref_parts.
762                        } else {
763                            // Find the first differing byte.
764                            let mut pos = 0usize;
765                            while pos < b.len() && pos < r.len() && b[pos] == r[pos] {
766                                pos += 1;
767                            }
768                            if pos == b.len() && pos == r.len() {
769                                // Paths are byte-identical → empty reference.
770                                val = dup_c_str(b"");
771                                if val.is_null() {
772                                    ret = -1;
773                                }
774                            } else {
775                                // Back up in ref to the last '/' before pos;
776                                // uptr is the unique suffix of the ref path.
777                                let mut ix = pos;
778                                while ix > 0 {
779                                    if r[ix - 1] == b'/' {
780                                        break;
781                                    }
782                                    ix -= 1;
783                                }
784                                let uptr = &r[ix..];
785
786                                // Count '/' in base starting at the same ix.
787                                let mut nbslash = 0usize;
788                                let mut i = ix;
789                                while i < b.len() {
790                                    if b[i] == b'/' {
791                                        nbslash += 1;
792                                    }
793                                    i += 1;
794                                }
795                                let len = uptr.len() + 1;
796
797                                if nbslash == 0 && uptr.is_empty() {
798                                    // e.g. URI="foo/" base="foo/bar" → "./"
799                                    val = dup_c_str(b"./");
800                                    if val.is_null() {
801                                        ret = -1;
802                                    }
803                                } else if nbslash == 0 {
804                                    val = escape_slice(uptr, b"/;&=+$,");
805                                    if val.is_null() {
806                                        ret = -1;
807                                    }
808                                } else {
809                                    let mut buf = Vec::with_capacity(len + 3 * nbslash);
810                                    for _ in 0..nbslash {
811                                        buf.extend_from_slice(b"../");
812                                    }
813                                    if !uptr.is_empty() {
814                                        if buf.last() == Some(&b'/') && uptr[0] == b'/' {
815                                            // Avoid "../" + "/suffix" doubling
816                                            // the separator (upstream vptr fix).
817                                            buf.extend_from_slice(&uptr[1..]);
818                                        } else {
819                                            buf.extend_from_slice(uptr);
820                                        }
821                                    }
822                                    val = escape_slice(&buf, b"/;&=+$,");
823                                    if val.is_null() {
824                                        ret = -1;
825                                    } else {
826                                        ret = 0;
827                                    }
828                                }
829                            }
830                        }
831                    }
832                }
833                Err(_raw) => {
834                    // "Return URI if base is invalid": val stays NULL → the
835                    // common tail saves ref_parts.
836                }
837            }
838        }
839
840        // done: if ret == 0 && val == NULL → val = xmlSaveUri(ref).
841        if ret == 0 && val.is_null() {
842            let saved = save_uri_parts(&ref_parts);
843            val = dup_c_str(&saved);
844            if val.is_null() {
845                ret = -1;
846            }
847        }
848        if ret != 0 {
849            if !val.is_null() {
850                allocator::xmlFreeImpl(val as *mut c_void);
851            }
852            val = ptr::null_mut();
853        }
854        *out = val;
855        ret
856    }
857}
858
859/// Upstream `xmlParseUriOrPath`: parse `s` as a URI reference when it
860/// contains "://" (returning the raw string on parse failure), otherwise
861/// normalize it as a filesystem path and parse that.
862fn parse_uri_or_path(s: &[u8]) -> Result<UriParts, Vec<u8>> {
863    if s.windows(3).any(|w| w == b"://") {
864        if !uri_reference_valid(s, false) {
865            return Err(s.to_vec());
866        }
867        let mut parts = parse_uri(s).ok_or_else(|| s.to_vec())?;
868        // Upstream xmlParseUriOrPath also normalizes the parsed path
869        // (xmlNormalizePath(uri->path, /* isFile */ 0)).
870        if let Some(path) = parts.path.take() {
871            parts.path = Some(normalize_path(&path, false));
872        }
873        Ok(parts)
874    } else {
875        let norm = normalize_path(s, true);
876        parse_uri(&norm).ok_or_else(|| s.to_vec())
877    }
878}
879
880/// Compute a relative URI from `URI` to `base`.
881///
882/// # UPSTREAM-PARITY
883///
884/// ```c
885/// xmlChar *xmlBuildRelativeURI(const xmlChar *URI, const xmlChar *base);
886/// ```
887///
888/// Returns the relative URI (`xmlMalloc`'d, free with `xmlFree`) or NULL if
889/// not possible.
890///
891/// # Safety
892///
893/// `URI`/`base` must be valid NUL-terminated strings or NULL.
894#[no_mangle]
895pub unsafe extern "C" fn xmlBuildRelativeURI(
896    uri: *const c_char,
897    base: *const c_char,
898) -> *mut xmlChar {
899    let mut out: *mut xmlChar = ptr::null_mut();
900    let ret = unsafe { xmlBuildRelativeURISafe(uri, base, &mut out) };
901    if ret != 0 {
902        return ptr::null_mut();
903    }
904    out
905}
906
907// ── xmlCanonicPath / xmlPathToURI ────────────────────────────────────────────
908
909/// Prepares a path: if it contains "://" it is treated as a Legacy Extended
910/// IRI and every character not allowed in URIs is escaped; otherwise the
911/// path is copied unmodified.
912///
913/// # UPSTREAM-PARITY
914///
915/// ```c
916/// xmlChar *xmlCanonicPath(const xmlChar *path);
917/// ```
918///
919/// Returns NULL if `path` is NULL.
920///
921/// # Safety
922///
923/// `path` must be a valid NUL-terminated string or NULL.
924#[no_mangle]
925pub unsafe extern "C" fn xmlCanonicPath(path: *const c_char) -> *mut xmlChar {
926    if path.is_null() {
927        return ptr::null_mut();
928    }
929    let bytes = cstr_bytes(path);
930    if bytes.windows(3).any(|w| w == b"://") {
931        // "Absolute uri": escape everything except reserved, unreserved
932        // and the percent sign (upstream xmlCanonicPath).
933        unsafe { escape_slice(bytes, b":/?#[]@!$&()*+,;='%") }
934    } else {
935        unsafe { dup_c_str(bytes) }
936    }
937}
938
939/// Construct a URI expressing the existing path.
940///
941/// # UPSTREAM-PARITY
942///
943/// ```c
944/// xmlChar *xmlPathToURI(const xmlChar *path);
945/// ```
946///
947/// Upstream `xmlPathToURI` is a thin wrapper around `xmlCanonicPath`
948/// (2.15.x uri.c), so this returns the same canonicalized path.
949///
950/// # Safety
951///
952/// `path` must be a valid NUL-terminated string or NULL.
953#[no_mangle]
954pub unsafe extern "C" fn xmlPathToURI(path: *const c_char) -> *mut xmlChar {
955    unsafe { xmlCanonicPath(path) }
956}
957
958// ── xmlPrintURI ──────────────────────────────────────────────────────────────
959
960/// Print the URI string to a `FILE*` stream.
961///
962/// # UPSTREAM-PARITY
963///
964/// ```c
965/// void xmlPrintURI(FILE *stream, xmlURI *uri);
966/// ```
967///
968/// Serializes `uri` with `xmlSaveUri` and writes it to `stream` with
969/// `fwrite` (the upstream equivalent of `fprintf(stream, "%s", out)`).
970/// A NULL `uri` or NULL `stream` is a silent no-op.
971///
972/// # Safety
973///
974/// `stream` must be a valid open `FILE*` (or NULL); `uri` must be a valid
975/// `xmlURI` from `xmlParseURI`/`xmlCreateURI` (or NULL).
976#[no_mangle]
977pub unsafe extern "C" fn xmlPrintURI(stream: *mut c_void, uri: *mut c_void) {
978    if stream.is_null() {
979        return;
980    }
981    let out = unsafe { crate::xml::uri::xmlSaveUri(uri) };
982    if out.is_null() {
983        return;
984    }
985    let len = unsafe { libc::strlen(out as *const c_char) };
986    unsafe {
987        fwrite(out as *const c_void, 1, len, stream);
988        allocator::xmlFreeImpl(out as *mut c_void);
989    }
990}
991
992// ── xmlURIEscape ─────────────────────────────────────────────────────────────
993
994/// Escape a URI string per RFC 2396 (deprecated upstream).
995///
996/// # UPSTREAM-PARITY
997///
998/// ```c
999/// xmlChar *xmlURIEscape(const xmlChar *str);
1000/// ```
1001///
1002/// Port of upstream `xmlURIEscape` (uri.c): the string is parsed with
1003/// `XML_URI_ALLOW_UNWISE` (so "unwise" characters are permitted) and each
1004/// component is re-escaped with its component-specific safe list. Strings
1005/// that don't parse as a URI reference (e.g. containing a space) yield NULL.
1006///
1007/// # Safety
1008///
1009/// `str` must be a valid NUL-terminated string or NULL.
1010#[no_mangle]
1011pub unsafe extern "C" fn xmlURIEscape(str: *const c_char) -> *mut xmlChar {
1012    if str.is_null() {
1013        return ptr::null_mut();
1014    }
1015    let bytes = cstr_bytes(str);
1016    if !uri_reference_valid(bytes, true) {
1017        return ptr::null_mut();
1018    }
1019    let parts = match parse_uri(bytes) {
1020        Some(p) => p,
1021        None => return ptr::null_mut(),
1022    };
1023
1024    let mut result: Vec<u8> = Vec::new();
1025
1026    // Scheme (upstream safe list "+-.").
1027    if let Some(ref scheme) = parts.scheme {
1028        let esc = unsafe { escape_slice(scheme, b"+-.") };
1029        if esc.is_null() {
1030            return ptr::null_mut();
1031        }
1032        result.extend_from_slice(unsafe { cstr_bytes(esc as *const c_char) });
1033        result.push(b':');
1034        unsafe { allocator::xmlFreeImpl(esc as *mut c_void) };
1035    }
1036
1037    // Note: the C struct's `authority` and `opaque` fields are never set by
1038    // the parser (upstream xmlParse3986HierPart), so those blocks of the
1039    // upstream xmlURIEscape are dead code for parsed URIs and are not
1040    // emitted here. `scheme:rest` parses as a rootless path upstream.
1041
1042    // User info.
1043    if let Some(ref user) = parts.user {
1044        let esc = unsafe { escape_slice(user, b";:&=+$,") };
1045        if esc.is_null() {
1046            return ptr::null_mut();
1047        }
1048        result.extend_from_slice(b"//");
1049        result.extend_from_slice(unsafe { cstr_bytes(esc as *const c_char) });
1050        result.push(b'@');
1051        unsafe { allocator::xmlFreeImpl(esc as *mut c_void) };
1052    }
1053
1054    // Server (host part only; the port is emitted separately below).
1055    if let Some(ref host) = parts.host {
1056        let esc = unsafe { escape_slice(host, b"/?;:@") };
1057        if esc.is_null() {
1058            return ptr::null_mut();
1059        }
1060        if parts.user.is_none() {
1061            result.extend_from_slice(b"//");
1062        }
1063        result.extend_from_slice(unsafe { cstr_bytes(esc as *const c_char) });
1064        unsafe { allocator::xmlFreeImpl(esc as *mut c_void) };
1065    }
1066
1067    // Port.
1068    if parts.port > 0 {
1069        result.push(b':');
1070        result.extend_from_slice(format!("{}", parts.port).as_bytes());
1071    }
1072
1073    // Path.
1074    if let Some(ref path) = parts.path {
1075        let esc = unsafe { escape_slice(path, b":@&=+$,/?;") };
1076        if esc.is_null() {
1077            return ptr::null_mut();
1078        }
1079        result.extend_from_slice(unsafe { cstr_bytes(esc as *const c_char) });
1080        unsafe { allocator::xmlFreeImpl(esc as *mut c_void) };
1081    }
1082
1083    // Query. (The internal parser does not track query_raw, so the escaped
1084    // form is used, as upstream does when query_raw is NULL.)
1085    if let Some(ref query) = parts.query {
1086        let esc = unsafe { escape_slice(query, b";/?:@&=+,$") };
1087        if esc.is_null() {
1088            return ptr::null_mut();
1089        }
1090        result.push(b'?');
1091        result.extend_from_slice(unsafe { cstr_bytes(esc as *const c_char) });
1092        unsafe { allocator::xmlFreeImpl(esc as *mut c_void) };
1093    } else if bytes.ends_with(b"?") {
1094        // The internal parser drops an empty query; upstream keeps the "?".
1095        result.push(b'?');
1096    }
1097
1098    // Fragment.
1099    if let Some(ref fragment) = parts.fragment {
1100        let esc = unsafe { escape_slice(fragment, b"#") };
1101        if esc.is_null() {
1102            return ptr::null_mut();
1103        }
1104        result.push(b'#');
1105        result.extend_from_slice(unsafe { cstr_bytes(esc as *const c_char) });
1106        unsafe { allocator::xmlFreeImpl(esc as *mut c_void) };
1107    }
1108
1109    unsafe { dup_c_str(&result) }
1110}
1111
1112// ── xmlNormalizeWindowsPath ──────────────────────────────────────────────────
1113
1114/// Normalize a Windows path.
1115///
1116/// # UPSTREAM-PARITY
1117///
1118/// ```c
1119/// xmlChar *xmlNormalizeWindowsPath(const xmlChar *path);
1120/// ```
1121///
1122/// Upstream libxml2 (2.15.x, xmlIO.c) marks this function deprecated —
1123/// "This never really worked" — and simply returns a copy of `path`.
1124/// Returns NULL if `path` is NULL.
1125///
1126/// # Safety
1127///
1128/// `path` must be a valid NUL-terminated string or NULL.
1129#[no_mangle]
1130pub unsafe extern "C" fn xmlNormalizeWindowsPath(path: *const c_char) -> *mut xmlChar {
1131    if path.is_null() {
1132        return ptr::null_mut();
1133    }
1134    unsafe { dup_c_str(cstr_bytes(path)) }
1135}
1136
1137// ── xmlCheckLanguageID ───────────────────────────────────────────────────────
1138
1139/// Check whether a string is a valid language ID per RFC 3066.
1140///
1141/// # UPSTREAM-PARITY
1142///
1143/// ```c
1144/// int xmlCheckLanguageID(const xmlChar *lang);
1145/// ```
1146///
1147/// Faithful port of upstream `xmlCheckLanguageID` (parser.c): returns 1 for
1148/// valid tags (including the deprecated IANA/user "i-*" and "x-*" forms),
1149/// 0 otherwise, 0 for NULL.
1150///
1151/// # Safety
1152///
1153/// `lang` must be a valid NUL-terminated string or NULL.
1154#[no_mangle]
1155pub unsafe extern "C" fn xmlCheckLanguageID(lang: *const c_char) -> c_int {
1156    if lang.is_null() {
1157        return 0;
1158    }
1159    check_language_id(unsafe { cstr_bytes(lang) })
1160}
1161
1162fn lang_is_alpha(c: u8) -> bool {
1163    c.is_ascii_alphabetic()
1164}
1165
1166fn lang_is_digit(c: u8) -> bool {
1167    c.is_ascii_digit()
1168}
1169
1170/// Byte at `i`, or 0 past the end (mirrors reading the C NUL terminator).
1171fn lang_byte(s: &[u8], i: usize) -> u8 {
1172    s.get(i).copied().unwrap_or(0)
1173}
1174
1175/// The "variant" label of upstream xmlCheckLanguageID: `nxt` is just past
1176/// the variant subtag. Extensions and private-use subtags are not checked.
1177fn lang_variant(s: &[u8], nxt: usize) -> c_int {
1178    match lang_byte(s, nxt) {
1179        0 => 1,
1180        b'-' => 1,
1181        _ => 0,
1182    }
1183}
1184
1185/// The "region" label: `nxt` is just past the region subtag.
1186fn lang_region(s: &[u8], nxt: usize) -> c_int {
1187    match lang_byte(s, nxt) {
1188        0 => return 1,
1189        b'-' => {}
1190        _ => return 0,
1191    }
1192    let mut nxt = nxt + 1;
1193    let cur = nxt;
1194    while lang_is_alpha(lang_byte(s, nxt)) {
1195        nxt += 1;
1196    }
1197    if !(5..=8).contains(&(nxt - cur)) {
1198        return 0;
1199    }
1200    lang_variant(s, nxt)
1201}
1202
1203/// The "region_m49" label: `nxt` points at the first digit.
1204fn lang_region_m49(s: &[u8], nxt: usize) -> c_int {
1205    if lang_is_digit(lang_byte(s, nxt + 1)) && lang_is_digit(lang_byte(s, nxt + 2)) {
1206        lang_region(s, nxt + 3)
1207    } else {
1208        0
1209    }
1210}
1211
1212/// The "script" label: `nxt` is just past the script subtag.
1213fn lang_script(s: &[u8], nxt: usize) -> c_int {
1214    match lang_byte(s, nxt) {
1215        0 => return 1,
1216        b'-' => {}
1217        _ => return 0,
1218    }
1219    let mut nxt = nxt + 1;
1220    let cur = nxt;
1221    if lang_is_digit(lang_byte(s, nxt)) {
1222        return lang_region_m49(s, nxt);
1223    }
1224    while lang_is_alpha(lang_byte(s, nxt)) {
1225        nxt += 1;
1226    }
1227    let len = nxt - cur;
1228    if (5..=8).contains(&len) {
1229        return lang_variant(s, nxt);
1230    }
1231    if len != 2 {
1232        return 0;
1233    }
1234    lang_region(s, nxt)
1235}
1236
1237/// Port of upstream `xmlCheckLanguageID` (parser.c) over a byte slice.
1238fn check_language_id(s: &[u8]) -> c_int {
1239    // Deprecated IANA/user codes: "i-...", "I-...", "x-...", "X-...".
1240    let c0 = lang_byte(s, 0);
1241    let c1 = lang_byte(s, 1);
1242    if (c0 == b'i' || c0 == b'I' || c0 == b'x' || c0 == b'X') && c1 == b'-' {
1243        let mut cur = 2usize;
1244        while lang_is_alpha(lang_byte(s, cur)) {
1245            cur += 1;
1246        }
1247        return if lang_byte(s, cur) == 0 { 1 } else { 0 };
1248    }
1249
1250    // Primary language subtag.
1251    let mut nxt = 0usize;
1252    while lang_is_alpha(lang_byte(s, nxt)) {
1253        nxt += 1;
1254    }
1255    let primary_len = nxt;
1256    if primary_len >= 4 {
1257        // Reserved language codes: 4..=8 chars and must end the tag.
1258        if primary_len > 8 || lang_byte(s, nxt) != 0 {
1259            return 0;
1260        }
1261        return 1;
1262    }
1263    if primary_len < 2 {
1264        return 0;
1265    }
1266    // We got an ISO 639 code.
1267    match lang_byte(s, nxt) {
1268        0 => return 1,
1269        b'-' => {}
1270        _ => return 0,
1271    }
1272    nxt += 1;
1273    let cur = nxt;
1274
1275    // Next subtag: extlang / script / region / variant.
1276    if lang_is_digit(lang_byte(s, nxt)) {
1277        return lang_region_m49(s, nxt);
1278    }
1279    while lang_is_alpha(lang_byte(s, nxt)) {
1280        nxt += 1;
1281    }
1282    let len = nxt - cur;
1283    if len == 4 {
1284        return lang_script(s, nxt);
1285    }
1286    if len == 2 {
1287        return lang_region(s, nxt);
1288    }
1289    if (5..=8).contains(&len) {
1290        return lang_variant(s, nxt);
1291    }
1292    if len != 3 {
1293        return 0;
1294    }
1295    // We parsed an extlang.
1296    match lang_byte(s, nxt) {
1297        0 => return 1,
1298        b'-' => {}
1299        _ => return 0,
1300    }
1301    nxt += 1;
1302    let cur = nxt;
1303
1304    // Now script or region or variant.
1305    if lang_is_digit(lang_byte(s, nxt)) {
1306        return lang_region_m49(s, nxt);
1307    }
1308    while lang_is_alpha(lang_byte(s, nxt)) {
1309        nxt += 1;
1310    }
1311    let len = nxt - cur;
1312    if len == 2 {
1313        return lang_region(s, nxt);
1314    }
1315    if (5..=8).contains(&len) {
1316        return lang_variant(s, nxt);
1317    }
1318    if len != 4 {
1319        return 0;
1320    }
1321    // We parsed a script → falls into the "script" handling.
1322    lang_script(s, nxt)
1323}
1324
1325// ── xmlParseURISafe ──────────────────────────────────────────────────────────
1326
1327/// Parse a URI reference (safe variant).
1328///
1329/// # UPSTREAM-PARITY
1330///
1331/// ```c
1332/// int xmlParseURISafe(const char *str, xmlURI **uri);
1333/// ```
1334///
1335/// Returns 0 on success with `*uri` set to a newly created `xmlURI` (free
1336/// with `xmlFreeURI`), 1 if `str` is NULL/invalid or `uri` is NULL, -1 on
1337/// allocation failure. On failure `*uri` is left NULL. An empty string is
1338/// a valid (empty) URI reference.
1339///
1340/// # Safety
1341///
1342/// `str` must be a valid NUL-terminated string or NULL; `uri` must be a
1343/// valid pointer to a writable `xmlURI*`.
1344#[no_mangle]
1345pub unsafe extern "C" fn xmlParseURISafe(str: *const c_char, uri_out: *mut *mut c_void) -> c_int {
1346    unsafe {
1347        if uri_out.is_null() {
1348            return 1;
1349        }
1350        *uri_out = ptr::null_mut();
1351        if str.is_null() {
1352            return 1;
1353        }
1354        let bytes = cstr_bytes(str);
1355        if bytes.is_empty() {
1356            // The empty reference parses to an empty xmlURI upstream.
1357            *uri_out = crate::xml::uri::xmlCreateURI();
1358            return if (*uri_out).is_null() { -1 } else { 0 };
1359        }
1360        if !uri_reference_valid(bytes, false) {
1361            return 1;
1362        }
1363        let parsed = crate::xml::uri::xmlParseURI(str);
1364        if parsed.is_null() {
1365            return 1;
1366        }
1367        *uri_out = parsed;
1368        0
1369    }
1370}