Skip to main content

libxml_rs/xml/uri/
mod.rs

1//! URI/IRI handling (§26, §85 Phase 4).
2//!
3//! libxml2's own URI parser implementation. NOT a general URI library —
4//! parity of malformed-URI handling is UPSTREAM_QUIRK territory.
5//!
6//! This module implements libxml2's URI parsing subsystem in native Rust,
7//! covering URI parsing, normalization, resolution, and C ABI-compatible
8//! wrapper functions.
9
10#![allow(clippy::missing_safety_doc)]
11#![allow(clippy::not_unsafe_ptr_arg_deref)]
12
13use crate::abi::allocator;
14use crate::abi::types::*;
15use core::ffi::c_void;
16use core::ptr;
17use std::os::raw::{c_char, c_int};
18
19// ── URI character classification ────────────────────────────────────────────
20
21/// Check if a byte is a valid URI unreserved character.
22/// Unreserved characters are: ALPHA, DIGIT, '-', '.', '_', '~'
23fn is_unreserved(b: u8) -> bool {
24    b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'_' || b == b'~'
25}
26
27/// Check if a byte is a valid URI reserved character.
28/// Reserved characters are gen-delims and sub-delims.
29fn is_reserved(b: u8) -> bool {
30    is_gen_delim(b) || is_sub_delim(b)
31}
32
33/// Check if a byte is a valid URI scheme character.
34/// Scheme characters are: ALPHA, DIGIT, '+', '-', '.'
35fn is_scheme_char(b: u8) -> bool {
36    b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.'
37}
38
39/// Check if a byte is a URI gen-delimiter.
40fn is_gen_delim(b: u8) -> bool {
41    matches!(b, b':' | b'/' | b'?' | b'#' | b'[' | b']' | b'@')
42}
43
44/// Check if a byte is a URI sub-delimiter.
45fn is_sub_delim(b: u8) -> bool {
46    matches!(
47        b,
48        b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
49    )
50}
51
52// ── Percent encoding / decoding ─────────────────────────────────────────────
53
54/// Decode a hex digit character to its numeric value.
55fn hex_val(c: u8) -> Option<u8> {
56    match c {
57        b'0'..=b'9' => Some(c - b'0'),
58        b'a'..=b'f' => Some(c - b'a' + 10),
59        b'A'..=b'F' => Some(c - b'A' + 10),
60        _ => None,
61    }
62}
63
64/// Percent-decode a URI component in-place.
65/// Returns a new `Vec<u8>` with percent-encoded sequences decoded.
66fn percent_decode(data: &[u8]) -> Vec<u8> {
67    let mut result = Vec::with_capacity(data.len());
68    let mut i = 0;
69    while i < data.len() {
70        if data[i] == b'%' && i + 2 < data.len() {
71            if let Some(h) = hex_val(data[i + 1]) {
72                if let Some(l) = hex_val(data[i + 2]) {
73                    result.push((h << 4) | l);
74                    i += 3;
75                    continue;
76                }
77            }
78        }
79        result.push(data[i]);
80        i += 1;
81    }
82    result
83}
84
85/// Percent-encode a byte for use in a URI.
86/// Returns a new `Vec<u8>` with non-unreserved/non-reserved bytes percent-encoded.
87fn percent_encode(data: &[u8]) -> Vec<u8> {
88    let mut result = Vec::with_capacity(data.len());
89    for &b in data {
90        if is_unreserved(b) || is_reserved(b) || b == b'%' {
91            result.push(b);
92        } else {
93            result.extend_from_slice(format!("%{:02X}", b).as_bytes());
94        }
95    }
96    result
97}
98
99// ── URI structure ───────────────────────────────────────────────────────────
100
101/// Parsed URI components.
102///
103/// This is the internal Rust representation, corresponding to libxml2's
104/// `xmlURI` struct but using safe Rust types.
105#[derive(Debug, Clone, Default)]
106pub(crate) struct UriParts {
107    pub scheme: Option<Vec<u8>>,     // e.g., "http", "file"
108    pub opaque: Option<Vec<u8>>,     // opaque part (for non-hierarchical URIs)
109    pub authority: Option<Vec<u8>>,  // e.g., "user@host:port"
110    pub server: Option<Vec<u8>>,     // server part of authority
111    pub user: Option<Vec<u8>>,       // user info
112    pub host: Option<Vec<u8>>,       // host
113    pub port: c_int,                 // port number (0 = not specified)
114    pub path: Option<Vec<u8>>,       // path (e.g., "/dir/file.xml")
115    pub query: Option<Vec<u8>>,      // query string (after ?)
116    pub fragment: Option<Vec<u8>>,   // fragment (after #)
117    pub path_raw: Option<Vec<u8>>,   // raw (un-escaped) path
118    pub clean_path: Option<Vec<u8>>, // cleaned/normalized path
119}
120
121// ═══════════════════════════════════════════════════════════════════════════════
122// C-ABI URI object (struct _xmlURI layout)
123// ═══════════════════════════════════════════════════════════════════════════════
124//
125// The public `xmlURIPtr` returned by `xmlParseURI`/`xmlCreateURI` must be
126// readable by C consumers as `struct _xmlURI` (upstream uri.h):
127//
128// ```c
129// struct _xmlURI {
130//     char *scheme;     char *opaque;   char *authority;
131//     char *server;     char *user;     int port;
132//     char *path;       char *query;    char *fragment;
133//     int  cleanup;     char *query_raw;
134// };
135// ```
136//
137// sizeof == 104, _Alignof == 8 on x86-64 (verified by the ABI probe). The
138// object is allocated as a `Box<CXmlUri>`; every string field is an
139// allocator-owned (`xmlMalloc`) null-terminated copy, so C code may read and
140// (with `xmlFreeURI`) release them exactly as with upstream libxml2.
141//
142// Internal Rust-only fields (`host`, `path_raw`, `clean_path`) cannot be
143// represented in the C struct; they are kept in the internal [`UriParts`]
144// only. Conversions are lossless for the C-visible fields.
145
146#[repr(C)]
147struct CXmlUri {
148    scheme: *mut c_char,
149    opaque: *mut c_char,
150    authority: *mut c_char,
151    server: *mut c_char,
152    user: *mut c_char,
153    port: c_int,
154    path: *mut c_char,
155    query: *mut c_char,
156    fragment: *mut c_char,
157    cleanup: c_int,
158    query_raw: *mut c_char,
159}
160
161impl Default for CXmlUri {
162    fn default() -> Self {
163        CXmlUri {
164            scheme: ptr::null_mut(),
165            opaque: ptr::null_mut(),
166            authority: ptr::null_mut(),
167            server: ptr::null_mut(),
168            user: ptr::null_mut(),
169            port: 0,
170            path: ptr::null_mut(),
171            query: ptr::null_mut(),
172            fragment: ptr::null_mut(),
173            cleanup: 0,
174            query_raw: ptr::null_mut(),
175        }
176    }
177}
178
179/// Allocate an allocator-owned null-terminated copy of `bytes`, or NULL.
180unsafe fn to_c_str(bytes: Option<&[u8]>) -> *mut c_char {
181    let b = match bytes {
182        Some(b) if !b.is_empty() => b,
183        _ => return ptr::null_mut(),
184    };
185    let p = unsafe { allocator::xmlMallocImpl(b.len() + 1) as *mut u8 };
186    if p.is_null() {
187        return ptr::null_mut();
188    }
189    unsafe {
190        ptr::copy_nonoverlapping(b.as_ptr(), p, b.len());
191        *p.add(b.len()) = 0;
192    }
193    p as *mut c_char
194}
195
196/// Read an allocator-owned C string back into `Vec<u8>` (empty when NULL).
197unsafe fn from_c_str(p: *const c_char) -> Option<Vec<u8>> {
198    if p.is_null() {
199        return None;
200    }
201    let len = unsafe { libc::strlen(p) };
202    if len == 0 {
203        return None;
204    }
205    let slice = unsafe { core::slice::from_raw_parts(p as *const u8, len) };
206    Some(slice.to_vec())
207}
208
209/// Free a C-ABI URI object and all its strings.
210unsafe fn free_c_uri(uri: *mut CXmlUri) {
211    if uri.is_null() {
212        return;
213    }
214    unsafe {
215        let u = &*uri;
216        for p in [
217            u.scheme,
218            u.opaque,
219            u.authority,
220            u.server,
221            u.user,
222            u.path,
223            u.query,
224            u.fragment,
225            u.query_raw,
226        ] {
227            if !p.is_null() {
228                allocator::xmlFreeImpl(p as *mut c_void);
229            }
230        }
231        drop(Box::from_raw(uri));
232    }
233}
234
235/// Convert internal parts to a C-ABI URI object (allocates).
236unsafe fn parts_to_c(parts: &UriParts) -> *mut CXmlUri {
237    let boxed = Box::new(CXmlUri {
238        scheme: unsafe { to_c_str(parts.scheme.as_deref()) },
239        opaque: unsafe { to_c_str(parts.opaque.as_deref()) },
240        authority: unsafe { to_c_str(parts.authority.as_deref()) },
241        server: unsafe { to_c_str(parts.server.as_deref()) },
242        user: unsafe { to_c_str(parts.user.as_deref()) },
243        port: parts.port,
244        path: unsafe { to_c_str(parts.path.as_deref()) },
245        query: unsafe { to_c_str(parts.query.as_deref()) },
246        fragment: unsafe { to_c_str(parts.fragment.as_deref()) },
247        cleanup: 0,
248        query_raw: unsafe { to_c_str(parts.query.as_deref()) },
249    });
250    Box::into_raw(boxed)
251}
252
253/// Convert a C-ABI URI object back to internal parts (copies strings).
254unsafe fn c_to_parts(uri: *const CXmlUri) -> UriParts {
255    let u = unsafe { &*uri };
256    UriParts {
257        scheme: unsafe { from_c_str(u.scheme) },
258        opaque: unsafe { from_c_str(u.opaque) },
259        authority: unsafe { from_c_str(u.authority) },
260        server: unsafe { from_c_str(u.server) },
261        user: unsafe { from_c_str(u.user) },
262        host: None,
263        port: u.port,
264        path: unsafe { from_c_str(u.path) },
265        query: unsafe { from_c_str(u.query) },
266        fragment: unsafe { from_c_str(u.fragment) },
267        path_raw: None,
268        clean_path: None,
269    }
270}
271
272// ── URI parsing ─────────────────────────────────────────────────────────────
273
274/// Find the scheme in a URI string.
275/// Returns `(start_of_scheme, end_of_scheme)` if found.
276/// The scheme must start with a letter and be followed by "://" or ":" (non-hierarchical).
277fn find_scheme(uri: &[u8]) -> Option<(usize, usize)> {
278    if uri.is_empty() {
279        return None;
280    }
281    // Scheme must start with a letter
282    if !uri[0].is_ascii_alphabetic() {
283        return None;
284    }
285    // Scan for ':' or end
286    let mut i = 1;
287    while i < uri.len() && is_scheme_char(uri[i]) {
288        i += 1;
289    }
290    if i < uri.len() && uri[i] == b':' {
291        // Check if it's "://" (hierarchical) or just ":" (opaque)
292        Some((0, i))
293    } else {
294        None
295    }
296}
297
298/// Parse the authority part of a URI.
299/// Input is the authority string (e.g., "user@host:port").
300/// Returns (user, host, port).
301fn parse_authority(auth: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, c_int) {
302    let mut user: Option<Vec<u8>> = None;
303    let mut host: Option<Vec<u8>> = None;
304    let mut port: c_int = 0;
305
306    if auth.is_empty() {
307        return (None, None, 0);
308    }
309
310    // Split on '@' for user info
311    let (user_part, host_part) = if let Some(at_pos) = auth.iter().position(|&b| b == b'@') {
312        user = Some(auth[..at_pos].to_vec());
313        (&auth[at_pos + 1..], true)
314    } else {
315        (auth, false)
316    };
317
318    // The remaining part is host:port
319    // Check for IPv6 literal [::1]
320    if user_part.starts_with(b"[") {
321        // Find the closing bracket
322        if let Some(close_bracket) = user_part.iter().position(|&b| b == b']') {
323            let host_end = close_bracket + 1;
324            host = Some(user_part[..host_end].to_vec());
325            // Check for port after the closing bracket
326            if host_end < user_part.len() && user_part[host_end] == b':' {
327                let port_str = &user_part[host_end + 1..];
328                if !port_str.is_empty() {
329                    let port_str_decoded = core::str::from_utf8(port_str).unwrap_or("");
330                    port = port_str_decoded.parse::<c_int>().unwrap_or(0);
331                }
332            }
333        } else {
334            // No closing bracket, take everything as host
335            host = Some(user_part.to_vec());
336        }
337    } else {
338        // Split on ':' for port
339        if let Some(colon_pos) = user_part.iter().position(|&b| b == b':') {
340            host = Some(user_part[..colon_pos].to_vec());
341            let port_str = &user_part[colon_pos + 1..];
342            if !port_str.is_empty() {
343                let port_str_decoded = core::str::from_utf8(port_str).unwrap_or("");
344                port = port_str_decoded.parse::<c_int>().unwrap_or(0);
345            }
346        } else {
347            host = Some(user_part.to_vec());
348        }
349    }
350
351    (user, host, port)
352}
353
354/// Parse a URI string into its components.
355///
356/// This implements libxml2's own URI parsing logic, following the patterns
357/// used in the upstream `xmlParseURI` function.
358///
359/// Returns `None` on failure.
360pub(crate) fn parse_uri(str: &[u8]) -> Option<UriParts> {
361    if str.is_empty() {
362        return None;
363    }
364
365    let mut parts = UriParts::default();
366    let mut remaining = str;
367
368    // 1. Extract scheme
369    if let Some((_start, end)) = find_scheme(remaining) {
370        parts.scheme = Some(remaining[..end].to_vec());
371        remaining = &remaining[end + 1..]; // skip ':'
372
373        // Check if it's hierarchical (://)
374        if remaining.starts_with(b"//") {
375            remaining = &remaining[2..];
376            // Parse authority: everything up to '/', '?', or '#'
377            let auth_end = remaining
378                .iter()
379                .position(|&b| b == b'/' || b == b'?' || b == b'#')
380                .unwrap_or(remaining.len());
381            let authority = &remaining[..auth_end];
382            // Always store authority (even if empty) to preserve "file:///" style URIs
383            parts.authority = if authority.is_empty() {
384                Some(Vec::new())
385            } else {
386                Some(authority.to_vec())
387            };
388            if !authority.is_empty() {
389                let (user, host, port) = parse_authority(authority);
390                parts.user = user;
391                parts.host = host;
392                parts.port = port;
393                if let Some(ref host_val) = parts.host {
394                    // Reconstruct server part (without user@)
395                    let mut server = host_val.clone();
396                    if port != 0 {
397                        server.extend_from_slice(format!(":{}", port).as_bytes());
398                    }
399                    parts.server = Some(server);
400                }
401            }
402            remaining = &remaining[auth_end..];
403        } else {
404            // Opaque URI: scheme:rest
405            // The opaque part is everything up to '#' or end
406            if let Some(frag_pos) = remaining.iter().position(|&b| b == b'#') {
407                parts.opaque = Some(remaining[..frag_pos].to_vec());
408                parts.fragment = Some(remaining[frag_pos + 1..].to_vec());
409            } else {
410                parts.opaque = Some(remaining.to_vec());
411            }
412            // For opaque URIs, the "path" is the opaque part
413            parts.path = parts.opaque.clone();
414            return Some(parts);
415        }
416    }
417
418    // 2. Extract path
419    // Path is everything up to '?' or '#'
420    let query_pos = remaining.iter().position(|&b| b == b'?');
421    let frag_pos = remaining.iter().position(|&b| b == b'#');
422
423    let path_end = match (query_pos, frag_pos) {
424        (Some(q), Some(f)) => q.min(f),
425        (Some(q), None) => q,
426        (None, Some(f)) => f,
427        (None, None) => remaining.len(),
428    };
429
430    if path_end > 0 {
431        let path = remaining[..path_end].to_vec();
432        parts.path = Some(path.clone());
433        parts.path_raw = Some(path);
434    }
435
436    // 3. Extract query
437    if let Some(qpos) = query_pos {
438        let qstart = qpos + 1;
439        let qend = frag_pos.unwrap_or(remaining.len());
440        if qstart < qend {
441            parts.query = Some(remaining[qstart..qend].to_vec());
442        }
443    }
444
445    // 4. Extract fragment
446    if let Some(fpos) = frag_pos {
447        let fstart = fpos + 1;
448        if fstart < remaining.len() {
449            parts.fragment = Some(remaining[fstart..].to_vec());
450        }
451    }
452
453    Some(parts)
454}
455
456/// Parse a URI from a null-terminated C string.
457///
458/// Returns a heap-allocated `UriParts`, or null on failure.
459/// The caller must free the returned pointer with [`free_uri_parts`].
460pub(crate) fn parse_uri_cstr(str: *const xmlChar) -> *mut UriParts {
461    if str.is_null() {
462        return ptr::null_mut();
463    }
464
465    let len = unsafe { libc::strlen(str as *const libc::c_char) };
466    let slice = unsafe { core::slice::from_raw_parts(str, len) };
467
468    match parse_uri(slice) {
469        Some(parts) => {
470            let boxed = Box::new(parts);
471            Box::into_raw(boxed)
472        }
473        None => ptr::null_mut(),
474    }
475}
476
477/// Free a heap-allocated `UriParts` that was created by [`parse_uri_cstr`].
478///
479/// # Safety
480///
481/// `parts` must have been allocated by [`parse_uri_cstr`] and not yet freed.
482pub(crate) unsafe fn free_uri_parts(parts: *mut UriParts) {
483    if !parts.is_null() {
484        drop(Box::from_raw(parts));
485    }
486}
487
488// ── URI operations ──────────────────────────────────────────────────────────
489
490/// Build a URI string from its components.
491pub(crate) fn build_uri(parts: &UriParts) -> Vec<u8> {
492    let mut result = Vec::new();
493
494    // Scheme
495    if let Some(ref scheme) = parts.scheme {
496        result.extend_from_slice(scheme);
497        result.push(b':');
498    }
499
500    // Authority
501    if let Some(ref authority) = parts.authority {
502        result.extend_from_slice(b"//");
503        result.extend_from_slice(authority);
504    } else if parts.host.is_some() {
505        // Reconstruct authority from components
506        result.extend_from_slice(b"//");
507        if let Some(ref user) = parts.user {
508            result.extend_from_slice(user);
509            result.push(b'@');
510        }
511        if let Some(ref host) = parts.host {
512            result.extend_from_slice(host);
513        }
514        if parts.port != 0 {
515            result.push(b':');
516            result.extend_from_slice(format!("{}", parts.port).as_bytes());
517        }
518    }
519
520    // Path
521    if let Some(ref path) = parts.path {
522        result.extend_from_slice(path);
523    } else if let Some(ref opaque) = parts.opaque {
524        result.extend_from_slice(opaque);
525    }
526
527    // Query
528    if let Some(ref query) = parts.query {
529        result.push(b'?');
530        result.extend_from_slice(query);
531    }
532
533    // Fragment
534    if let Some(ref fragment) = parts.fragment {
535        result.push(b'#');
536        result.extend_from_slice(fragment);
537    }
538
539    result
540}
541
542/// Normalize a URI path (remove "." and ".." segments).
543///
544/// This implements the same logic as libxml2's `xmlNormalizeURIPath`.
545/// It processes path segments and resolves "." and ".." references.
546pub(crate) fn normalize_uri_path(uri: &[u8]) -> Vec<u8> {
547    if uri.is_empty() {
548        return Vec::new();
549    }
550
551    let absolute = uri.starts_with(b"/");
552    let ends_with_slash = uri.ends_with(b"/");
553
554    let parts: Vec<&[u8]> = uri.split(|&b| b == b'/').collect();
555    let mut segments: Vec<&[u8]> = Vec::new();
556
557    for segment in parts {
558        if segment == b"." || segment.is_empty() {
559            // Skip "." segments and empty segments (from leading/trailing/double slashes)
560            continue;
561        }
562        if segment == b".." {
563            // Remove the last segment if possible
564            segments.pop();
565        } else {
566            segments.push(segment);
567        }
568    }
569
570    let mut result = Vec::new();
571    if absolute {
572        result.push(b'/');
573    }
574    for (i, seg) in segments.iter().enumerate() {
575        if i > 0 {
576            result.push(b'/');
577        }
578        result.extend_from_slice(seg);
579    }
580
581    // Preserve trailing slash if original had one
582    if ends_with_slash && !segments.is_empty() {
583        result.push(b'/');
584    }
585
586    // If the result is empty and the path was absolute, return "/"
587    if result.is_empty() && absolute {
588        result.push(b'/');
589    }
590
591    result
592}
593
594/// Get the scheme part of a URI.
595pub(crate) fn get_scheme(uri: &[u8]) -> Option<Vec<u8>> {
596    if let Some((_start, end)) = find_scheme(uri) {
597        Some(uri[_start..end].to_vec())
598    } else {
599        None
600    }
601}
602
603/// Check if a URI is absolute (has a scheme).
604pub(crate) fn is_absolute(uri: &[u8]) -> bool {
605    find_scheme(uri).is_some()
606}
607
608/// Resolve a relative URI against a base URI.
609///
610/// Both are byte slices. Returns the resolved absolute URI.
611///
612/// This implements the resolution algorithm from RFC 3986 §5.3,
613/// matching libxml2's `xmlBuildURI` behavior.
614pub(crate) fn resolve_uri(base: &[u8], relative: &[u8]) -> Option<Vec<u8>> {
615    if base.is_empty() {
616        return if relative.is_empty() {
617            None
618        } else {
619            Some(relative.to_vec())
620        };
621    }
622
623    // If the relative URI is absolute, return it as-is
624    if is_absolute(relative) {
625        return Some(relative.to_vec());
626    }
627
628    // Parse the base URI
629    let base_parts = parse_uri(base)?;
630
631    // If the relative URI is empty, return the base
632    if relative.is_empty() {
633        return Some(build_uri(&base_parts));
634    }
635
636    // Parse the relative URI parts manually (simpler than full parse)
637    let rel_str = relative;
638
639    let mut result = UriParts {
640        scheme: base_parts.scheme.clone(),
641        ..Default::default()
642    };
643
644    if rel_str.starts_with(b"//") {
645        // Network-path reference: starts with "//"
646        // Authority is everything up to '/' or end
647        let rest = &rel_str[2..];
648        let auth_end = rest.iter().position(|&b| b == b'/').unwrap_or(rest.len());
649        let auth = rest[..auth_end].to_vec();
650        let (user, host, port) = parse_authority(&auth);
651        result.authority = Some(auth);
652        result.user = user;
653        result.host = host;
654        result.port = port;
655
656        let path_rest = if auth_end < rest.len() {
657            &rest[auth_end..]
658        } else {
659            b""
660        };
661        // Parse path, query, fragment from remaining
662        parse_path_query_fragment(path_rest, &mut result);
663    } else if rel_str.starts_with(b"/") {
664        // Absolute path reference
665        parse_path_query_fragment(rel_str, &mut result);
666        // Inherit authority from base
667        result.authority = base_parts.authority.clone();
668        result.user = base_parts.user.clone();
669        result.host = base_parts.host.clone();
670        result.port = base_parts.port;
671    } else {
672        // Relative path reference
673        // Start with base path's directory
674        let base_path = base_parts.path.as_deref().unwrap_or(b"");
675        let base_dir = if let Some(last_slash) = base_path.iter().rposition(|&b| b == b'/') {
676            &base_path[..=last_slash]
677        } else {
678            b""
679        };
680
681        // Parse the relative part
682        let mut combined = Vec::from(base_dir);
683        combined.extend_from_slice(rel_str);
684        parse_path_query_fragment(&combined, &mut result);
685
686        // Inherit authority from base
687        result.authority = base_parts.authority.clone();
688        result.user = base_parts.user.clone();
689        result.host = base_parts.host.clone();
690        result.port = base_parts.port;
691    }
692
693    // Normalize the path
694    if let Some(ref path) = result.path {
695        let normalized = normalize_uri_path(path);
696        result.path = Some(normalized);
697    }
698
699    Some(build_uri(&result))
700}
701
702/// Helper: parse path, query, and fragment from the remainder of a URI.
703fn parse_path_query_fragment(input: &[u8], parts: &mut UriParts) {
704    // Find '?' and '#'
705    let query_pos = input.iter().position(|&b| b == b'?');
706    let frag_pos = input.iter().position(|&b| b == b'#');
707
708    let path_end = match (query_pos, frag_pos) {
709        (Some(q), Some(f)) => q.min(f),
710        (Some(q), None) => q,
711        (None, Some(f)) => f,
712        (None, None) => input.len(),
713    };
714
715    if path_end > 0 {
716        parts.path = Some(input[..path_end].to_vec());
717        parts.path_raw = parts.path.clone();
718    }
719
720    // Query
721    if let Some(qpos) = query_pos {
722        let qstart = qpos + 1;
723        let qend = frag_pos.unwrap_or(input.len());
724        if qstart < qend {
725            parts.query = Some(input[qstart..qend].to_vec());
726        }
727    }
728
729    // Fragment
730    if let Some(fpos) = frag_pos {
731        let fstart = fpos + 1;
732        if fstart < input.len() {
733            parts.fragment = Some(input[fstart..].to_vec());
734        }
735    }
736}
737
738// ── C ABI-compatible wrapper functions ──────────────────────────────────────
739
740/// `xmlURIPtr xmlParseURI(const char *str)`
741///
742/// Parse a URI from a C string. Returns an opaque pointer to a heap-allocated
743/// `UriParts`, or null on failure.
744///
745/// The caller must free the result with [`xmlFreeURI`].
746///
747/// # Safety
748///
749/// `str` must be a valid null-terminated C string.
750pub(crate) unsafe fn xmlParseURI(str: *const c_char) -> *mut c_void {
751    if str.is_null() {
752        return ptr::null_mut();
753    }
754    let len = libc::strlen(str);
755    let slice = unsafe { core::slice::from_raw_parts(str as *const u8, len) };
756    match parse_uri(slice) {
757        Some(parts) => unsafe { parts_to_c(&parts) as *mut c_void },
758        None => ptr::null_mut(),
759    }
760}
761
762/// `void xmlFreeURI(xmlURIPtr uri)`
763///
764/// Free a URI structure previously returned by [`xmlParseURI`] or [`xmlCreateURI`].
765///
766/// # Safety
767///
768/// `uri` must have been allocated by [`xmlParseURI`] or [`xmlCreateURI`] and not yet freed.
769pub(crate) unsafe fn xmlFreeURI(uri: *mut c_void) {
770    if !uri.is_null() {
771        unsafe { free_c_uri(uri as *mut CXmlUri) };
772    }
773}
774
775/// `xmlURIPtr xmlCreateURI(void)`
776///
777/// Create an empty URI structure.
778/// Returns an opaque pointer to a heap-allocated, zero-initialized `CXmlUri`
779/// (C-ABI layout matching `struct _xmlURI`).
780///
781/// The caller must free the result with [`xmlFreeURI`].
782pub(crate) fn xmlCreateURI() -> *mut c_void {
783    let boxed = Box::new(CXmlUri::default());
784    Box::into_raw(boxed) as *mut c_void
785}
786
787/// `xmlChar *xmlSaveUri(xmlURIPtr uri)`
788///
789/// Serialize a URI structure back to a string.
790/// Returns a null-terminated `xmlChar*` string allocated with `xmlMalloc`,
791/// or null on failure.
792///
793/// The caller must free the result with `xmlFree`.
794///
795/// # Safety
796///
797/// `uri` must be a valid pointer to a `CXmlUri` previously created by
798/// [`xmlParseURI`] or [`xmlCreateURI`].
799pub(crate) unsafe fn xmlSaveUri(uri: *mut c_void) -> *mut xmlChar {
800    if uri.is_null() {
801        return ptr::null_mut();
802    }
803    let parts = unsafe { c_to_parts(uri as *const CXmlUri) };
804    let result = build_uri(&parts);
805    if result.is_empty() {
806        return ptr::null_mut();
807    }
808    // Allocate with xmlMalloc and copy
809    let len = result.len();
810    let ptr = unsafe { allocator::xmlMallocImpl(len + 1) as *mut u8 };
811    if ptr.is_null() {
812        return ptr::null_mut();
813    }
814    unsafe {
815        ptr::copy_nonoverlapping(result.as_ptr(), ptr, len);
816        *ptr.add(len) = 0; // null terminator
817    }
818    ptr as *mut xmlChar
819}
820
821/// `int xmlParseURIReference(xmlURIPtr uri, const char *str)`
822///
823/// Parse a URI string into an EXISTING URI structure (upstream uri.c
824/// `xmlParseURIReference`): the string is parsed and the fields of `uri` are
825/// replaced. Returns 0 on success, -1 on failure. On failure the URI
826/// structure is left untouched.
827///
828/// # Safety
829///
830/// `uri` must be a valid pointer to a `CXmlUri` previously created by
831/// [`xmlParseURI`] or [`xmlCreateURI`]; `str` must be a valid
832/// null-terminated C string.
833pub(crate) unsafe fn xmlParseURIReference(uri: *mut c_void, str: *const c_char) -> c_int {
834    if uri.is_null() || str.is_null() {
835        return -1;
836    }
837    let len = unsafe { libc::strlen(str) };
838    let slice = unsafe { core::slice::from_raw_parts(str as *const u8, len) };
839    let parts = match parse_uri(slice) {
840        Some(p) => p,
841        None => return -1,
842    };
843    let fresh = unsafe { parts_to_c(&parts) };
844    if fresh.is_null() {
845        return -1;
846    }
847    // swap the parsed fields into the caller's structure, then release the
848    // temporary shell (the strings were moved, so nothing is leaked)
849    unsafe {
850        let dst = &mut *(uri as *mut CXmlUri);
851        let src = &mut *fresh;
852        core::mem::swap(dst, src);
853        free_c_uri(fresh);
854    }
855    0
856}
857
858/// `int xmlNormalizeURIPath(char *path)`
859///
860/// Normalize a URI path IN PLACE (upstream uri.c `xmlNormalizeURIPath`):
861/// remove `.` and `..` segments per RFC 3986 §5.2.4, keeping the leading
862/// `/`. Returns 0 on success, -1 on failure (e.g. `..` above the root).
863///
864/// The candidate's internal normalizer (`normalize_uri_path`) implements the
865/// same algorithm on byte slices; this wrapper applies it to the C buffer in
866/// place.
867///
868/// # Safety
869///
870/// `path` must be a valid, writable, null-terminated C string buffer that
871/// is at least `strlen(path) + 1` bytes long.
872pub(crate) unsafe fn xmlNormalizeURIPath(path: *mut c_char) -> c_int {
873    if path.is_null() {
874        return -1;
875    }
876    // Faithful port of upstream uri.c `xmlNormalizeURIPath`: operates in
877    // place, removes `.`/`..` segments, and fails with -1 when `..` would
878    // climb above the root or when the path does not start with '/'
879    // (upstream only normalizes absolute paths).
880    unsafe {
881        let mut cur = path;
882        if *cur == b'/' as c_char {
883            cur = cur.add(1);
884        } else {
885            return -1;
886        }
887        let mut out = path;
888        while *cur != 0 {
889            let c0 = *cur as u8;
890            let c1 = *cur.add(1) as u8;
891            let c2 = *cur.add(2) as u8;
892            // "./" segment: skip
893            if c0 == b'.' && c1 == b'/' {
894                cur = cur.add(2);
895                continue;
896            }
897            // "../" segment: back up one segment, fail if at the root
898            if c0 == b'.' && c1 == b'.' && c2 == b'/' {
899                if out == path {
900                    return -1;
901                }
902                out = out.sub(1);
903                while out > path && *out.sub(1) != b'/' as c_char {
904                    out = out.sub(1);
905                }
906                cur = cur.add(3);
907                continue;
908            }
909            // trailing "." — drop it and finish
910            if c0 == b'.' && c1 == 0 {
911                break;
912            }
913            // trailing ".." — back up one segment, fail if at the root
914            if c0 == b'.' && c1 == b'.' && c2 == 0 {
915                if out == path {
916                    return -1;
917                }
918                out = out.sub(1);
919                while out > path && *out.sub(1) != b'/' as c_char {
920                    out = out.sub(1);
921                }
922                break;
923            }
924            *out = *cur;
925            out = out.add(1);
926            cur = cur.add(1);
927        }
928        *out = 0;
929    }
930    0
931}
932
933/// `xmlChar *xmlURIEscapeStr(unsigned char *str, unsigned char *list)`
934///
935/// Percent-escape a string for use in a URI.
936/// Characters in `list` are NOT escaped (they're treated as safe).
937///
938/// Returns a null-terminated `xmlChar*` string allocated with `xmlMalloc`,
939/// or null on failure.
940///
941/// # Safety
942///
943/// `str` must be a valid null-terminated C string. `list` may be null.
944pub(crate) unsafe fn xmlURIEscapeStr(str: *const xmlChar, list: *const xmlChar) -> *mut xmlChar {
945    if str.is_null() {
946        return ptr::null_mut();
947    }
948    let str_len = unsafe { libc::strlen(str as *const libc::c_char) };
949    let str_slice = unsafe { core::slice::from_raw_parts(str, str_len) };
950
951    // Build the safe-set: unreserved + reserved + chars in `list`
952    let mut safe_set = [false; 256];
953    for b in 0u8..=255 {
954        if is_unreserved(b) || b == b'%' {
955            safe_set[b as usize] = true;
956        }
957    }
958    if !list.is_null() {
959        let list_len = unsafe { libc::strlen(list as *const libc::c_char) };
960        let list_slice = unsafe { core::slice::from_raw_parts(list, list_len) };
961        for &b in list_slice {
962            safe_set[b as usize] = true;
963        }
964    }
965
966    // Build the result
967    let mut result = Vec::with_capacity(str_slice.len() * 3);
968    for &b in str_slice {
969        if safe_set[b as usize] {
970            result.push(b);
971        } else {
972            result.extend_from_slice(format!("%{:02X}", b).as_bytes());
973        }
974    }
975
976    let len = result.len();
977    let ptr = unsafe { allocator::xmlMallocImpl(len + 1) as *mut u8 };
978    if ptr.is_null() {
979        return ptr::null_mut();
980    }
981    unsafe {
982        ptr::copy_nonoverlapping(result.as_ptr(), ptr, len);
983        *ptr.add(len) = 0;
984    }
985    ptr as *mut xmlChar
986}
987
988/// `xmlChar *xmlURIUnescapeString(const char *str, int len, char *target)`
989///
990/// Unescape a percent-encoded URI string.
991///
992/// If `len` is negative, the string is assumed to be null-terminated.
993/// If `target` is not null, the result is written there (and returned).
994/// Otherwise, a new buffer is allocated with `xmlMalloc`.
995///
996/// Returns the unescaped string, or null on failure.
997///
998/// # Safety
999///
1000/// `str` must be a valid C string (null-terminated if `len` < 0).
1001/// `target` must be large enough to hold the result if not null.
1002pub(crate) unsafe fn xmlURIUnescapeString(
1003    str: *const c_char,
1004    len: c_int,
1005    target: *mut c_char,
1006) -> *mut c_char {
1007    if str.is_null() {
1008        return ptr::null_mut();
1009    }
1010    let slice = if len < 0 {
1011        let cstr_len = unsafe { libc::strlen(str) };
1012        unsafe { core::slice::from_raw_parts(str as *const u8, cstr_len) }
1013    } else {
1014        unsafe { core::slice::from_raw_parts(str as *const u8, len as usize) }
1015    };
1016
1017    let decoded = percent_decode(slice);
1018
1019    if !target.is_null() {
1020        unsafe {
1021            ptr::copy_nonoverlapping(decoded.as_ptr(), target as *mut u8, decoded.len());
1022            *((target as *mut u8).add(decoded.len())) = 0;
1023        }
1024        return target;
1025    }
1026
1027    let out_len = decoded.len();
1028    let ptr = unsafe { allocator::xmlMallocImpl(out_len + 1) as *mut u8 };
1029    if ptr.is_null() {
1030        return ptr::null_mut();
1031    }
1032    unsafe {
1033        ptr::copy_nonoverlapping(decoded.as_ptr(), ptr, out_len);
1034        *ptr.add(out_len) = 0;
1035    }
1036    ptr as *mut c_char
1037}
1038
1039/// `xmlURIPtr xmlParseURIRaw(const char *str, int raw)`
1040///
1041/// Parse a URI from a C string.
1042/// The `raw` flag is currently unused (reserved for future behavior).
1043///
1044/// Returns an opaque pointer to a heap-allocated `UriParts`, or null on failure.
1045///
1046/// The caller must free the result with [`xmlFreeURI`].
1047///
1048/// # Safety
1049///
1050/// `str` must be a valid null-terminated C string.
1051pub(crate) unsafe fn xmlParseURIRaw(str: *const c_char, _raw: c_int) -> *mut c_void {
1052    unsafe { xmlParseURI(str) }
1053}
1054
1055// ── Tests ───────────────────────────────────────────────────────────────────
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    // ── URI character classification ───────────────────────────────────────
1062
1063    #[test]
1064    fn test_is_unreserved() {
1065        assert!(is_unreserved(b'a'));
1066        assert!(is_unreserved(b'Z'));
1067        assert!(is_unreserved(b'0'));
1068        assert!(is_unreserved(b'-'));
1069        assert!(is_unreserved(b'.'));
1070        assert!(is_unreserved(b'_'));
1071        assert!(is_unreserved(b'~'));
1072        assert!(!is_unreserved(b':'));
1073        assert!(!is_unreserved(b'/'));
1074        assert!(!is_unreserved(b'%'));
1075        assert!(!is_unreserved(b' '));
1076    }
1077
1078    #[test]
1079    fn test_is_reserved() {
1080        assert!(is_reserved(b':'));
1081        assert!(is_reserved(b'/'));
1082        assert!(is_reserved(b'?'));
1083        assert!(is_reserved(b'#'));
1084        assert!(is_reserved(b'@'));
1085        assert!(is_reserved(b'!'));
1086        assert!(is_reserved(b'$'));
1087        assert!(is_reserved(b'&'));
1088        assert!(is_reserved(b'('));
1089        assert!(is_reserved(b')'));
1090        assert!(!is_reserved(b'a'));
1091        assert!(!is_reserved(b' '));
1092    }
1093
1094    #[test]
1095    fn test_is_scheme_char() {
1096        assert!(is_scheme_char(b'a'));
1097        assert!(is_scheme_char(b'Z'));
1098        assert!(is_scheme_char(b'0'));
1099        assert!(is_scheme_char(b'+'));
1100        assert!(is_scheme_char(b'-'));
1101        assert!(is_scheme_char(b'.'));
1102        assert!(!is_scheme_char(b':'));
1103        assert!(!is_scheme_char(b'/'));
1104        assert!(!is_scheme_char(b' '));
1105    }
1106
1107    // ── Percent encoding / decoding ────────────────────────────────────────
1108
1109    #[test]
1110    fn test_percent_decode_simple() {
1111        assert_eq!(percent_decode(b"hello"), b"hello");
1112        assert_eq!(percent_decode(b"%68%65%6C%6C%6F"), b"hello");
1113        assert_eq!(percent_decode(b"%48%65%6C%6C%6F"), b"Hello");
1114        assert_eq!(percent_decode(b"a%20b"), b"a b");
1115    }
1116
1117    #[test]
1118    fn test_percent_decode_invalid() {
1119        // Invalid percent sequence: keep as-is
1120        assert_eq!(percent_decode(b"%XX"), b"%XX");
1121        assert_eq!(percent_decode(b"%2"), b"%2");
1122        assert_eq!(percent_decode(b"%"), b"%");
1123        assert_eq!(percent_decode(b"%%20"), b"% ");
1124    }
1125
1126    #[test]
1127    fn test_percent_decode_empty() {
1128        assert_eq!(percent_decode(b""), b"");
1129    }
1130
1131    #[test]
1132    fn test_percent_encode() {
1133        assert_eq!(percent_encode(b"hello"), b"hello");
1134        assert_eq!(percent_encode(b"hello world"), b"hello%20world");
1135        assert_eq!(percent_encode(b"a/b"), b"a/b"); // '/' is reserved, keep as-is
1136    }
1137
1138    // ── URI parsing ────────────────────────────────────────────────────────
1139
1140    #[test]
1141    fn test_parse_http_uri() {
1142        let parts =
1143            parse_uri(b"http://example.com/path/to/file.xml?query=1#frag").expect("should parse");
1144        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1145        assert_eq!(parts.authority, Some(b"example.com".to_vec()));
1146        assert_eq!(parts.host, Some(b"example.com".to_vec()));
1147        assert_eq!(parts.port, 0);
1148        assert_eq!(parts.path, Some(b"/path/to/file.xml".to_vec()));
1149        assert_eq!(parts.query, Some(b"query=1".to_vec()));
1150        assert_eq!(parts.fragment, Some(b"frag".to_vec()));
1151    }
1152
1153    #[test]
1154    fn test_parse_https_uri() {
1155        let parts = parse_uri(b"https://example.com:443/path").expect("should parse");
1156        assert_eq!(parts.scheme, Some(b"https".to_vec()));
1157        assert_eq!(parts.host, Some(b"example.com".to_vec()));
1158        assert_eq!(parts.port, 443);
1159        assert_eq!(parts.path, Some(b"/path".to_vec()));
1160    }
1161
1162    #[test]
1163    fn test_parse_file_uri() {
1164        let parts = parse_uri(b"file:///etc/hosts").expect("should parse");
1165        assert_eq!(parts.scheme, Some(b"file".to_vec()));
1166        assert!(parts.authority.is_none() || parts.authority.as_deref() == Some(b""));
1167        assert_eq!(parts.path, Some(b"/etc/hosts".to_vec()));
1168    }
1169
1170    #[test]
1171    fn test_parse_file_uri_with_host() {
1172        // file://hostname/path is also valid
1173        let parts = parse_uri(b"file://localhost/etc/hosts").expect("should parse");
1174        assert_eq!(parts.scheme, Some(b"file".to_vec()));
1175        assert_eq!(parts.host, Some(b"localhost".to_vec()));
1176        assert_eq!(parts.path, Some(b"/etc/hosts".to_vec()));
1177    }
1178
1179    #[test]
1180    fn test_parse_relative_uri() {
1181        let parts = parse_uri(b"/path/to/file.xml").expect("should parse");
1182        assert!(parts.scheme.is_none());
1183        assert_eq!(parts.path, Some(b"/path/to/file.xml".to_vec()));
1184    }
1185
1186    #[test]
1187    fn test_parse_relative_uri_with_query() {
1188        let parts = parse_uri(b"file.xml?query=1").expect("should parse");
1189        assert!(parts.scheme.is_none());
1190        assert_eq!(parts.path, Some(b"file.xml".to_vec()));
1191        assert_eq!(parts.query, Some(b"query=1".to_vec()));
1192    }
1193
1194    #[test]
1195    fn test_parse_uri_with_user_info() {
1196        let parts = parse_uri(b"ftp://user@host.com:21/path").expect("should parse");
1197        assert_eq!(parts.scheme, Some(b"ftp".to_vec()));
1198        assert_eq!(parts.user, Some(b"user".to_vec()));
1199        assert_eq!(parts.host, Some(b"host.com".to_vec()));
1200        assert_eq!(parts.port, 21);
1201        assert_eq!(parts.path, Some(b"/path".to_vec()));
1202    }
1203
1204    #[test]
1205    fn test_parse_uri_with_user_password() {
1206        let parts = parse_uri(b"ftp://user:pass@host.com/path").expect("should parse");
1207        assert_eq!(parts.scheme, Some(b"ftp".to_vec()));
1208        assert_eq!(parts.user, Some(b"user:pass".to_vec()));
1209        assert_eq!(parts.host, Some(b"host.com".to_vec()));
1210        assert_eq!(parts.path, Some(b"/path".to_vec()));
1211    }
1212
1213    #[test]
1214    fn test_parse_opaque_uri() {
1215        let parts = parse_uri(b"mailto:user@example.com").expect("should parse");
1216        assert_eq!(parts.scheme, Some(b"mailto".to_vec()));
1217        assert_eq!(parts.opaque, Some(b"user@example.com".to_vec()));
1218        assert!(parts.authority.is_none());
1219    }
1220
1221    #[test]
1222    fn test_parse_opaque_uri_with_fragment() {
1223        let parts = parse_uri(b"urn:isbn:0-395-36341-1#frag").expect("should parse");
1224        assert_eq!(parts.scheme, Some(b"urn".to_vec()));
1225        assert_eq!(parts.opaque, Some(b"isbn:0-395-36341-1".to_vec()));
1226        assert_eq!(parts.fragment, Some(b"frag".to_vec()));
1227    }
1228
1229    #[test]
1230    fn test_parse_empty_uri() {
1231        assert!(parse_uri(b"").is_none());
1232    }
1233
1234    #[test]
1235    fn test_parse_uri_fragment_only() {
1236        let parts = parse_uri(b"#fragment").expect("should parse");
1237        assert!(parts.scheme.is_none());
1238        assert!(parts.path.is_none());
1239        assert_eq!(parts.fragment, Some(b"fragment".to_vec()));
1240    }
1241
1242    #[test]
1243    fn test_parse_uri_query_only() {
1244        let parts = parse_uri(b"?query").expect("should parse");
1245        assert!(parts.scheme.is_none());
1246        assert!(parts.path.is_none());
1247        assert_eq!(parts.query, Some(b"query".to_vec()));
1248    }
1249
1250    #[test]
1251    fn test_parse_uri_with_ipv6_host() {
1252        let parts = parse_uri(b"http://[::1]:8080/path").expect("should parse");
1253        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1254        assert_eq!(parts.host, Some(b"[::1]".to_vec()));
1255        assert_eq!(parts.port, 8080);
1256        assert_eq!(parts.path, Some(b"/path".to_vec()));
1257    }
1258
1259    #[test]
1260    fn test_parse_uri_no_path() {
1261        let parts = parse_uri(b"http://example.com").expect("should parse");
1262        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1263        assert_eq!(parts.host, Some(b"example.com".to_vec()));
1264        assert!(parts.path.is_none());
1265    }
1266
1267    #[test]
1268    fn test_parse_uri_no_path_with_query() {
1269        let parts = parse_uri(b"http://example.com?query").expect("should parse");
1270        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1271        assert_eq!(parts.host, Some(b"example.com".to_vec()));
1272        assert!(parts.path.is_none());
1273        assert_eq!(parts.query, Some(b"query".to_vec()));
1274    }
1275
1276    // ── URI building ───────────────────────────────────────────────────────
1277
1278    #[test]
1279    fn test_build_uri() {
1280        let parts = UriParts {
1281            scheme: Some(b"http".to_vec()),
1282            host: Some(b"example.com".to_vec()),
1283            port: 8080,
1284            path: Some(b"/path".to_vec()),
1285            query: Some(b"q=1".to_vec()),
1286            fragment: Some(b"frag".to_vec()),
1287            ..Default::default()
1288        };
1289        assert_eq!(build_uri(&parts), b"http://example.com:8080/path?q=1#frag");
1290    }
1291
1292    #[test]
1293    fn test_build_uri_simple() {
1294        let parts = UriParts {
1295            scheme: Some(b"http".to_vec()),
1296            host: Some(b"example.com".to_vec()),
1297            path: Some(b"/".to_vec()),
1298            ..Default::default()
1299        };
1300        assert_eq!(build_uri(&parts), b"http://example.com/");
1301    }
1302
1303    #[test]
1304    fn test_build_uri_opaque() {
1305        let parts = UriParts {
1306            scheme: Some(b"mailto".to_vec()),
1307            opaque: Some(b"user@example.com".to_vec()),
1308            ..Default::default()
1309        };
1310        assert_eq!(build_uri(&parts), b"mailto:user@example.com");
1311    }
1312
1313    #[test]
1314    fn test_build_uri_relative() {
1315        let parts = UriParts {
1316            path: Some(b"/relative/path".to_vec()),
1317            ..Default::default()
1318        };
1319        assert_eq!(build_uri(&parts), b"/relative/path");
1320    }
1321
1322    // ── URI normalization ──────────────────────────────────────────────────
1323
1324    #[test]
1325    fn test_normalize_uri_path_simple() {
1326        assert_eq!(normalize_uri_path(b"/foo/bar"), b"/foo/bar");
1327        assert_eq!(normalize_uri_path(b"/foo/./bar"), b"/foo/bar");
1328        assert_eq!(normalize_uri_path(b"/foo/../bar"), b"/bar");
1329        assert_eq!(normalize_uri_path(b"/foo/bar/.."), b"/foo");
1330        assert_eq!(normalize_uri_path(b"/"), b"/");
1331    }
1332
1333    #[test]
1334    fn test_normalize_uri_path_relative() {
1335        assert_eq!(normalize_uri_path(b"foo/bar"), b"foo/bar");
1336        assert_eq!(normalize_uri_path(b"foo/./bar"), b"foo/bar");
1337        assert_eq!(normalize_uri_path(b"foo/../bar"), b"bar");
1338    }
1339
1340    #[test]
1341    fn test_normalize_uri_path_double_dot_overflow() {
1342        // ".." above root should just be removed
1343        assert_eq!(normalize_uri_path(b"/a/../../b"), b"/b");
1344        assert_eq!(normalize_uri_path(b"/../b"), b"/b");
1345    }
1346
1347    #[test]
1348    fn test_normalize_uri_path_empty() {
1349        assert_eq!(normalize_uri_path(b""), b"");
1350    }
1351
1352    #[test]
1353    fn test_normalize_uri_path_dots_only() {
1354        assert_eq!(normalize_uri_path(b"./././."), b"");
1355        assert_eq!(normalize_uri_path(b"/./././"), b"/");
1356    }
1357
1358    // ── URI scheme / absolute check ────────────────────────────────────────
1359
1360    #[test]
1361    fn test_get_scheme() {
1362        assert_eq!(get_scheme(b"http://example.com"), Some(b"http".to_vec()));
1363        assert_eq!(get_scheme(b"https://example.com"), Some(b"https".to_vec()));
1364        assert_eq!(get_scheme(b"file:///path"), Some(b"file".to_vec()));
1365        assert_eq!(get_scheme(b"ftp://host"), Some(b"ftp".to_vec()));
1366        assert_eq!(get_scheme(b"mailto:user@host"), Some(b"mailto".to_vec()));
1367        assert_eq!(get_scheme(b"urn:isbn:1234"), Some(b"urn".to_vec()));
1368        assert_eq!(get_scheme(b"/path"), None);
1369        assert_eq!(get_scheme(b"relative"), None);
1370        assert_eq!(get_scheme(b""), None);
1371    }
1372
1373    #[test]
1374    fn test_is_absolute() {
1375        assert!(is_absolute(b"http://example.com"));
1376        assert!(is_absolute(b"file:///path"));
1377        assert!(is_absolute(b"mailto:user@host"));
1378        assert!(!is_absolute(b"/path"));
1379        assert!(!is_absolute(b"relative"));
1380        assert!(!is_absolute(b""));
1381    }
1382
1383    // ── URI resolution ─────────────────────────────────────────────────────
1384
1385    #[test]
1386    fn test_resolve_uri_absolute_relative() {
1387        let result =
1388            resolve_uri(b"http://example.com/base/", b"relative.xml").expect("should resolve");
1389        assert_eq!(result, b"http://example.com/base/relative.xml");
1390    }
1391
1392    #[test]
1393    fn test_resolve_uri_absolute_absolute() {
1394        let result = resolve_uri(
1395            b"http://example.com/base/",
1396            b"http://other.com/absolute.xml",
1397        )
1398        .expect("should resolve");
1399        assert_eq!(result, b"http://other.com/absolute.xml");
1400    }
1401
1402    #[test]
1403    fn test_resolve_uri_root_relative() {
1404        let result =
1405            resolve_uri(b"http://example.com/base/file.xml", b"/root.xml").expect("should resolve");
1406        assert_eq!(result, b"http://example.com/root.xml");
1407    }
1408
1409    #[test]
1410    fn test_resolve_uri_network_path() {
1411        let result = resolve_uri(b"http://example.com/base/file.xml", b"//other.com/root.xml")
1412            .expect("should resolve");
1413        assert_eq!(result, b"http://other.com/root.xml");
1414    }
1415
1416    #[test]
1417    fn test_resolve_uri_parent_traversal() {
1418        let result = resolve_uri(b"http://example.com/a/b/c/file.xml", b"../../d/file.xml")
1419            .expect("should resolve");
1420        assert_eq!(result, b"http://example.com/a/d/file.xml");
1421    }
1422
1423    #[test]
1424    fn test_resolve_uri_with_query() {
1425        let result =
1426            resolve_uri(b"http://example.com/base/", b"file.xml?query=1").expect("should resolve");
1427        assert_eq!(result, b"http://example.com/base/file.xml?query=1");
1428    }
1429
1430    #[test]
1431    fn test_resolve_uri_with_fragment() {
1432        let result =
1433            resolve_uri(b"http://example.com/base/file.xml", b"#frag").expect("should resolve");
1434        // A fragment-only reference with no path should resolve to base's directory
1435        // with the fragment replaced.
1436        assert_eq!(result, b"http://example.com/base/#frag");
1437    }
1438
1439    #[test]
1440    fn test_resolve_uri_empty_base() {
1441        let result = resolve_uri(b"", b"relative.xml");
1442        assert_eq!(result, Some(b"relative.xml".to_vec()));
1443    }
1444
1445    #[test]
1446    fn test_resolve_uri_empty_relative() {
1447        let result = resolve_uri(b"http://example.com/base/", b"");
1448        assert!(result.is_some());
1449        // Should return base URI
1450        assert_eq!(result.unwrap(), b"http://example.com/base/");
1451    }
1452
1453    #[test]
1454    fn test_resolve_uri_both_empty() {
1455        assert!(resolve_uri(b"", b"").is_none());
1456    }
1457
1458    #[test]
1459    fn test_resolve_uri_file_scheme() {
1460        let result = resolve_uri(b"file:///base/dir/", b"file.xml").expect("should resolve");
1461        assert_eq!(result, b"file:///base/dir/file.xml");
1462    }
1463
1464    #[test]
1465    fn test_resolve_uri_deep_relative() {
1466        let result = resolve_uri(
1467            b"http://example.com/a/b/c/d/e/file.xml",
1468            b"../../../../x/y/z/file.xml",
1469        )
1470        .expect("should resolve");
1471        assert_eq!(result, b"http://example.com/a/x/y/z/file.xml");
1472    }
1473
1474    // ── C ABI wrapper functions ────────────────────────────────────────────
1475
1476    #[test]
1477    fn test_xml_create_and_free_uri() {
1478        unsafe {
1479            let uri = xmlCreateURI();
1480            assert!(!uri.is_null());
1481            xmlFreeURI(uri);
1482        }
1483    }
1484
1485    #[test]
1486    fn test_xml_parse_uri() {
1487        unsafe {
1488            let cstr = b"http://example.com/path\0".as_ptr() as *const c_char;
1489            let uri = xmlParseURI(cstr);
1490            assert!(!uri.is_null());
1491            let parts = &*(uri as *const CXmlUri);
1492            assert_eq!(from_c_str(parts.scheme), Some(b"http".to_vec()));
1493            assert_eq!(from_c_str(parts.server), Some(b"example.com".to_vec()));
1494            assert_eq!(from_c_str(parts.path), Some(b"/path".to_vec()));
1495            xmlFreeURI(uri);
1496        }
1497    }
1498
1499    #[test]
1500    fn test_xml_parse_uri_null() {
1501        unsafe {
1502            let uri = xmlParseURI(ptr::null());
1503            assert!(uri.is_null());
1504        }
1505    }
1506
1507    #[test]
1508    fn test_xml_save_uri() {
1509        unsafe {
1510            let cstr = b"http://example.com:8080/path?q=1#f\0".as_ptr() as *const c_char;
1511            let uri = xmlParseURI(cstr);
1512            assert!(!uri.is_null());
1513            let saved = xmlSaveUri(uri);
1514            assert!(!saved.is_null());
1515            let saved_str = std::ffi::CStr::from_ptr(saved as *const c_char);
1516            assert_eq!(saved_str.to_bytes(), b"http://example.com:8080/path?q=1#f");
1517            allocator::xmlFreeImpl(saved as *mut core::ffi::c_void);
1518            xmlFreeURI(uri);
1519        }
1520    }
1521
1522    #[test]
1523    fn test_xml_escape_str() {
1524        unsafe {
1525            let cstr = b"hello world\0".as_ptr() as *const xmlChar;
1526            let result = xmlURIEscapeStr(cstr, ptr::null());
1527            assert!(!result.is_null());
1528            let result_str = std::ffi::CStr::from_ptr(result as *const c_char);
1529            assert_eq!(result_str.to_bytes(), b"hello%20world");
1530            allocator::xmlFreeImpl(result as *mut core::ffi::c_void);
1531        }
1532    }
1533
1534    #[test]
1535    fn test_xml_escape_str_with_safe_list() {
1536        unsafe {
1537            let cstr = b"hello world\0".as_ptr() as *const xmlChar;
1538            let safe = b" \0".as_ptr() as *const xmlChar;
1539            let result = xmlURIEscapeStr(cstr, safe);
1540            assert!(!result.is_null());
1541            let result_str = std::ffi::CStr::from_ptr(result as *const c_char);
1542            assert_eq!(result_str.to_bytes(), b"hello world"); // space is in safe list
1543            allocator::xmlFreeImpl(result as *mut core::ffi::c_void);
1544        }
1545    }
1546
1547    #[test]
1548    fn test_xml_unescape_string() {
1549        unsafe {
1550            let cstr = b"hello%20world\0".as_ptr() as *const c_char;
1551            let result = xmlURIUnescapeString(cstr, -1, ptr::null_mut());
1552            assert!(!result.is_null());
1553            let result_str = std::ffi::CStr::from_ptr(result);
1554            assert_eq!(result_str.to_bytes(), b"hello world");
1555            allocator::xmlFreeImpl(result as *mut core::ffi::c_void);
1556        }
1557    }
1558
1559    #[test]
1560    fn test_xml_unescape_string_with_len() {
1561        unsafe {
1562            let cstr = b"hello%20world\0".as_ptr() as *const c_char;
1563            let result = xmlURIUnescapeString(cstr, 13, ptr::null_mut());
1564            assert!(!result.is_null());
1565            let result_str = std::ffi::CStr::from_ptr(result);
1566            assert_eq!(result_str.to_bytes(), b"hello world");
1567            allocator::xmlFreeImpl(result as *mut core::ffi::c_void);
1568        }
1569    }
1570
1571    #[test]
1572    fn test_xml_parse_uri_raw() {
1573        unsafe {
1574            let cstr = b"http://example.com\0".as_ptr() as *const c_char;
1575            let uri = xmlParseURIRaw(cstr, 0);
1576            assert!(!uri.is_null());
1577            let parts = &*(uri as *const CXmlUri);
1578            assert_eq!(from_c_str(parts.scheme), Some(b"http".to_vec()));
1579            assert_eq!(from_c_str(parts.server), Some(b"example.com".to_vec()));
1580            xmlFreeURI(uri);
1581        }
1582    }
1583
1584    #[test]
1585    fn test_xml_free_null() {
1586        unsafe {
1587            // Should not crash
1588            xmlFreeURI(ptr::null_mut());
1589        }
1590    }
1591
1592    // ── Edge cases ─────────────────────────────────────────────────────────
1593
1594    #[test]
1595    fn test_parse_uri_scheme_only() {
1596        let parts = parse_uri(b"http:").expect("should parse");
1597        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1598        assert!(parts.opaque.is_none() || parts.opaque.as_deref() == Some(b""));
1599    }
1600
1601    #[test]
1602    fn test_parse_uri_with_trailing_slash() {
1603        let parts = parse_uri(b"http://example.com/").expect("should parse");
1604        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1605        assert_eq!(parts.path, Some(b"/".to_vec()));
1606    }
1607
1608    #[test]
1609    fn test_parse_uri_with_double_slash_path() {
1610        let parts = parse_uri(b"http://example.com//path").expect("should parse");
1611        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1612        assert_eq!(parts.path, Some(b"//path".to_vec()));
1613    }
1614
1615    #[test]
1616    fn test_parse_uri_no_scheme_colon() {
1617        // A string with a colon but no valid scheme (doesn't start with letter)
1618        let parts = parse_uri(b"123:path");
1619        // This should be treated as a relative path, since '1' is not a letter
1620        assert!(parts.is_some());
1621        let p = parts.unwrap();
1622        assert!(p.scheme.is_none());
1623        assert_eq!(p.path, Some(b"123:path".to_vec()));
1624    }
1625
1626    #[test]
1627    fn test_parse_uri_ftp_with_home_dir() {
1628        let parts = parse_uri(b"ftp://host/home/user/file.txt").expect("should parse");
1629        assert_eq!(parts.scheme, Some(b"ftp".to_vec()));
1630        assert_eq!(parts.host, Some(b"host".to_vec()));
1631        assert_eq!(parts.path, Some(b"/home/user/file.txt".to_vec()));
1632    }
1633
1634    #[test]
1635    fn test_parse_uri_scheme_case() {
1636        let parts = parse_uri(b"HTTP://example.com/Path").expect("should parse");
1637        assert_eq!(parts.scheme, Some(b"HTTP".to_vec()));
1638        assert_eq!(parts.host, Some(b"example.com".to_vec()));
1639        assert_eq!(parts.path, Some(b"/Path".to_vec()));
1640    }
1641
1642    #[test]
1643    fn test_normalize_path_complex() {
1644        assert_eq!(normalize_uri_path(b"/a/b/c/./../../g"), b"/a/g");
1645        assert_eq!(normalize_uri_path(b"mid/content=5/../6"), b"mid/6");
1646    }
1647
1648    #[test]
1649    fn test_resolve_uri_same_directory() {
1650        let result =
1651            resolve_uri(b"http://example.com/a/b/c.html", b"d.html").expect("should resolve");
1652        assert_eq!(result, b"http://example.com/a/b/d.html");
1653    }
1654
1655    #[test]
1656    fn test_resolve_uri_complex_traversal() {
1657        let result = resolve_uri(b"http://a/b/c/d;p?q", b"g/h/../i/./j#f").expect("should resolve");
1658        let result_str = core::str::from_utf8(&result).unwrap_or("");
1659        assert!(result_str.contains("http://a/b/c/g/i/j"));
1660    }
1661
1662    // ── Hex value helper ───────────────────────────────────────────────────
1663
1664    #[test]
1665    fn test_hex_val() {
1666        assert_eq!(hex_val(b'0'), Some(0));
1667        assert_eq!(hex_val(b'9'), Some(9));
1668        assert_eq!(hex_val(b'a'), Some(10));
1669        assert_eq!(hex_val(b'f'), Some(15));
1670        assert_eq!(hex_val(b'A'), Some(10));
1671        assert_eq!(hex_val(b'F'), Some(15));
1672        assert_eq!(hex_val(b'g'), None);
1673        assert_eq!(hex_val(b'z'), None);
1674        assert_eq!(hex_val(b'%'), None);
1675    }
1676
1677    // ── Parse URI C string ─────────────────────────────────────────────────
1678
1679    #[test]
1680    fn test_parse_uri_cstr() {
1681        let cstr = b"http://example.com/path\0".as_ptr() as *const xmlChar;
1682        let ptr = parse_uri_cstr(cstr);
1683        assert!(!ptr.is_null());
1684        unsafe {
1685            assert_eq!((*ptr).scheme, Some(b"http".to_vec()));
1686            assert_eq!((*ptr).host, Some(b"example.com".to_vec()));
1687            free_uri_parts(ptr);
1688        }
1689    }
1690
1691    #[test]
1692    fn test_parse_uri_cstr_null() {
1693        let ptr = parse_uri_cstr(ptr::null());
1694        assert!(ptr.is_null());
1695    }
1696
1697    #[test]
1698    fn test_parse_uri_cstr_invalid() {
1699        let cstr = b"\0".as_ptr() as *const xmlChar;
1700        let ptr = parse_uri_cstr(cstr);
1701        assert!(ptr.is_null());
1702    }
1703}