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