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// ── URI parsing ─────────────────────────────────────────────────────────────
122
123/// Find the scheme in a URI string.
124/// Returns `(start_of_scheme, end_of_scheme)` if found.
125/// The scheme must start with a letter and be followed by "://" or ":" (non-hierarchical).
126fn find_scheme(uri: &[u8]) -> Option<(usize, usize)> {
127    if uri.is_empty() {
128        return None;
129    }
130    // Scheme must start with a letter
131    if !uri[0].is_ascii_alphabetic() {
132        return None;
133    }
134    // Scan for ':' or end
135    let mut i = 1;
136    while i < uri.len() && is_scheme_char(uri[i]) {
137        i += 1;
138    }
139    if i < uri.len() && uri[i] == b':' {
140        // Check if it's "://" (hierarchical) or just ":" (opaque)
141        Some((0, i))
142    } else {
143        None
144    }
145}
146
147/// Parse the authority part of a URI.
148/// Input is the authority string (e.g., "user@host:port").
149/// Returns (user, host, port).
150fn parse_authority(auth: &[u8]) -> (Option<Vec<u8>>, Option<Vec<u8>>, c_int) {
151    let mut user: Option<Vec<u8>> = None;
152    let mut host: Option<Vec<u8>> = None;
153    let mut port: c_int = 0;
154
155    if auth.is_empty() {
156        return (None, None, 0);
157    }
158
159    // Split on '@' for user info
160    let (user_part, host_part) = if let Some(at_pos) = auth.iter().position(|&b| b == b'@') {
161        user = Some(auth[..at_pos].to_vec());
162        (&auth[at_pos + 1..], true)
163    } else {
164        (auth, false)
165    };
166
167    // The remaining part is host:port
168    // Check for IPv6 literal [::1]
169    if user_part.starts_with(b"[") {
170        // Find the closing bracket
171        if let Some(close_bracket) = user_part.iter().position(|&b| b == b']') {
172            let host_end = close_bracket + 1;
173            host = Some(user_part[..host_end].to_vec());
174            // Check for port after the closing bracket
175            if host_end < user_part.len() && user_part[host_end] == b':' {
176                let port_str = &user_part[host_end + 1..];
177                if !port_str.is_empty() {
178                    let port_str_decoded = core::str::from_utf8(port_str).unwrap_or("");
179                    port = port_str_decoded.parse::<c_int>().unwrap_or(0);
180                }
181            }
182        } else {
183            // No closing bracket, take everything as host
184            host = Some(user_part.to_vec());
185        }
186    } else {
187        // Split on ':' for port
188        if let Some(colon_pos) = user_part.iter().position(|&b| b == b':') {
189            host = Some(user_part[..colon_pos].to_vec());
190            let port_str = &user_part[colon_pos + 1..];
191            if !port_str.is_empty() {
192                let port_str_decoded = core::str::from_utf8(port_str).unwrap_or("");
193                port = port_str_decoded.parse::<c_int>().unwrap_or(0);
194            }
195        } else {
196            host = Some(user_part.to_vec());
197        }
198    }
199
200    (user, host, port)
201}
202
203/// Parse a URI string into its components.
204///
205/// This implements libxml2's own URI parsing logic, following the patterns
206/// used in the upstream `xmlParseURI` function.
207///
208/// Returns `None` on failure.
209pub(crate) fn parse_uri(str: &[u8]) -> Option<UriParts> {
210    if str.is_empty() {
211        return None;
212    }
213
214    let mut parts = UriParts::default();
215    let mut remaining = str;
216
217    // 1. Extract scheme
218    if let Some((_start, end)) = find_scheme(remaining) {
219        parts.scheme = Some(remaining[..end].to_vec());
220        remaining = &remaining[end + 1..]; // skip ':'
221
222        // Check if it's hierarchical (://)
223        if remaining.starts_with(b"//") {
224            remaining = &remaining[2..];
225            // Parse authority: everything up to '/', '?', or '#'
226            let auth_end = remaining
227                .iter()
228                .position(|&b| b == b'/' || b == b'?' || b == b'#')
229                .unwrap_or(remaining.len());
230            let authority = &remaining[..auth_end];
231            // Always store authority (even if empty) to preserve "file:///" style URIs
232            parts.authority = if authority.is_empty() {
233                Some(Vec::new())
234            } else {
235                Some(authority.to_vec())
236            };
237            if !authority.is_empty() {
238                let (user, host, port) = parse_authority(authority);
239                parts.user = user;
240                parts.host = host;
241                parts.port = port;
242                if let Some(ref host_val) = parts.host {
243                    // Reconstruct server part (without user@)
244                    let mut server = host_val.clone();
245                    if port != 0 {
246                        server.extend_from_slice(format!(":{}", port).as_bytes());
247                    }
248                    parts.server = Some(server);
249                }
250            }
251            remaining = &remaining[auth_end..];
252        } else {
253            // Opaque URI: scheme:rest
254            // The opaque part is everything up to '#' or end
255            if let Some(frag_pos) = remaining.iter().position(|&b| b == b'#') {
256                parts.opaque = Some(remaining[..frag_pos].to_vec());
257                parts.fragment = Some(remaining[frag_pos + 1..].to_vec());
258            } else {
259                parts.opaque = Some(remaining.to_vec());
260            }
261            // For opaque URIs, the "path" is the opaque part
262            parts.path = parts.opaque.clone();
263            return Some(parts);
264        }
265    }
266
267    // 2. Extract path
268    // Path is everything up to '?' or '#'
269    let query_pos = remaining.iter().position(|&b| b == b'?');
270    let frag_pos = remaining.iter().position(|&b| b == b'#');
271
272    let path_end = match (query_pos, frag_pos) {
273        (Some(q), Some(f)) => q.min(f),
274        (Some(q), None) => q,
275        (None, Some(f)) => f,
276        (None, None) => remaining.len(),
277    };
278
279    if path_end > 0 {
280        let path = remaining[..path_end].to_vec();
281        parts.path = Some(path.clone());
282        parts.path_raw = Some(path);
283    }
284
285    // 3. Extract query
286    if let Some(qpos) = query_pos {
287        let qstart = qpos + 1;
288        let qend = frag_pos.unwrap_or(remaining.len());
289        if qstart < qend {
290            parts.query = Some(remaining[qstart..qend].to_vec());
291        }
292    }
293
294    // 4. Extract fragment
295    if let Some(fpos) = frag_pos {
296        let fstart = fpos + 1;
297        if fstart < remaining.len() {
298            parts.fragment = Some(remaining[fstart..].to_vec());
299        }
300    }
301
302    Some(parts)
303}
304
305/// Parse a URI from a null-terminated C string.
306///
307/// Returns a heap-allocated `UriParts`, or null on failure.
308/// The caller must free the returned pointer with [`free_uri_parts`].
309pub(crate) fn parse_uri_cstr(str: *const xmlChar) -> *mut UriParts {
310    if str.is_null() {
311        return ptr::null_mut();
312    }
313
314    let len = unsafe { libc::strlen(str as *const libc::c_char) };
315    let slice = unsafe { core::slice::from_raw_parts(str, len) };
316
317    match parse_uri(slice) {
318        Some(parts) => {
319            let boxed = Box::new(parts);
320            Box::into_raw(boxed)
321        }
322        None => ptr::null_mut(),
323    }
324}
325
326/// Free a heap-allocated `UriParts` that was created by [`parse_uri_cstr`].
327///
328/// # Safety
329///
330/// `parts` must have been allocated by [`parse_uri_cstr`] and not yet freed.
331pub(crate) unsafe fn free_uri_parts(parts: *mut UriParts) {
332    if !parts.is_null() {
333        drop(Box::from_raw(parts));
334    }
335}
336
337// ── URI operations ──────────────────────────────────────────────────────────
338
339/// Build a URI string from its components.
340pub(crate) fn build_uri(parts: &UriParts) -> Vec<u8> {
341    let mut result = Vec::new();
342
343    // Scheme
344    if let Some(ref scheme) = parts.scheme {
345        result.extend_from_slice(scheme);
346        result.push(b':');
347    }
348
349    // Authority
350    if let Some(ref authority) = parts.authority {
351        result.extend_from_slice(b"//");
352        result.extend_from_slice(authority);
353    } else if parts.host.is_some() {
354        // Reconstruct authority from components
355        result.extend_from_slice(b"//");
356        if let Some(ref user) = parts.user {
357            result.extend_from_slice(user);
358            result.push(b'@');
359        }
360        if let Some(ref host) = parts.host {
361            result.extend_from_slice(host);
362        }
363        if parts.port != 0 {
364            result.push(b':');
365            result.extend_from_slice(format!("{}", parts.port).as_bytes());
366        }
367    }
368
369    // Path
370    if let Some(ref path) = parts.path {
371        result.extend_from_slice(path);
372    } else if let Some(ref opaque) = parts.opaque {
373        result.extend_from_slice(opaque);
374    }
375
376    // Query
377    if let Some(ref query) = parts.query {
378        result.push(b'?');
379        result.extend_from_slice(query);
380    }
381
382    // Fragment
383    if let Some(ref fragment) = parts.fragment {
384        result.push(b'#');
385        result.extend_from_slice(fragment);
386    }
387
388    result
389}
390
391/// Normalize a URI path (remove "." and ".." segments).
392///
393/// This implements the same logic as libxml2's `xmlNormalizeURIPath`.
394/// It processes path segments and resolves "." and ".." references.
395pub(crate) fn normalize_uri_path(uri: &[u8]) -> Vec<u8> {
396    if uri.is_empty() {
397        return Vec::new();
398    }
399
400    let absolute = uri.starts_with(b"/");
401    let ends_with_slash = uri.ends_with(b"/");
402
403    let parts: Vec<&[u8]> = uri.split(|&b| b == b'/').collect();
404    let mut segments: Vec<&[u8]> = Vec::new();
405
406    for segment in parts {
407        if segment == b"." || segment.is_empty() {
408            // Skip "." segments and empty segments (from leading/trailing/double slashes)
409            continue;
410        }
411        if segment == b".." {
412            // Remove the last segment if possible
413            segments.pop();
414        } else {
415            segments.push(segment);
416        }
417    }
418
419    let mut result = Vec::new();
420    if absolute {
421        result.push(b'/');
422    }
423    for (i, seg) in segments.iter().enumerate() {
424        if i > 0 {
425            result.push(b'/');
426        }
427        result.extend_from_slice(seg);
428    }
429
430    // Preserve trailing slash if original had one
431    if ends_with_slash && !segments.is_empty() {
432        result.push(b'/');
433    }
434
435    // If the result is empty and the path was absolute, return "/"
436    if result.is_empty() && absolute {
437        result.push(b'/');
438    }
439
440    result
441}
442
443/// Get the scheme part of a URI.
444pub(crate) fn get_scheme(uri: &[u8]) -> Option<Vec<u8>> {
445    if let Some((_start, end)) = find_scheme(uri) {
446        Some(uri[_start..end].to_vec())
447    } else {
448        None
449    }
450}
451
452/// Check if a URI is absolute (has a scheme).
453pub(crate) fn is_absolute(uri: &[u8]) -> bool {
454    find_scheme(uri).is_some()
455}
456
457/// Resolve a relative URI against a base URI.
458///
459/// Both are byte slices. Returns the resolved absolute URI.
460///
461/// This implements the resolution algorithm from RFC 3986 §5.3,
462/// matching libxml2's `xmlBuildURI` behavior.
463pub(crate) fn resolve_uri(base: &[u8], relative: &[u8]) -> Option<Vec<u8>> {
464    if base.is_empty() {
465        return if relative.is_empty() {
466            None
467        } else {
468            Some(relative.to_vec())
469        };
470    }
471
472    // If the relative URI is absolute, return it as-is
473    if is_absolute(relative) {
474        return Some(relative.to_vec());
475    }
476
477    // Parse the base URI
478    let base_parts = parse_uri(base)?;
479
480    // If the relative URI is empty, return the base
481    if relative.is_empty() {
482        return Some(build_uri(&base_parts));
483    }
484
485    // Parse the relative URI parts manually (simpler than full parse)
486    let rel_str = relative;
487
488    let mut result = UriParts {
489        scheme: base_parts.scheme.clone(),
490        ..Default::default()
491    };
492
493    if rel_str.starts_with(b"//") {
494        // Network-path reference: starts with "//"
495        // Authority is everything up to '/' or end
496        let rest = &rel_str[2..];
497        let auth_end = rest.iter().position(|&b| b == b'/').unwrap_or(rest.len());
498        let auth = rest[..auth_end].to_vec();
499        let (user, host, port) = parse_authority(&auth);
500        result.authority = Some(auth);
501        result.user = user;
502        result.host = host;
503        result.port = port;
504
505        let path_rest = if auth_end < rest.len() {
506            &rest[auth_end..]
507        } else {
508            b""
509        };
510        // Parse path, query, fragment from remaining
511        parse_path_query_fragment(path_rest, &mut result);
512    } else if rel_str.starts_with(b"/") {
513        // Absolute path reference
514        parse_path_query_fragment(rel_str, &mut result);
515        // Inherit authority from base
516        result.authority = base_parts.authority.clone();
517        result.user = base_parts.user.clone();
518        result.host = base_parts.host.clone();
519        result.port = base_parts.port;
520    } else {
521        // Relative path reference
522        // Start with base path's directory
523        let base_path = base_parts.path.as_deref().unwrap_or(b"");
524        let base_dir = if let Some(last_slash) = base_path.iter().rposition(|&b| b == b'/') {
525            &base_path[..=last_slash]
526        } else {
527            b""
528        };
529
530        // Parse the relative part
531        let mut combined = Vec::from(base_dir);
532        combined.extend_from_slice(rel_str);
533        parse_path_query_fragment(&combined, &mut result);
534
535        // Inherit authority from base
536        result.authority = base_parts.authority.clone();
537        result.user = base_parts.user.clone();
538        result.host = base_parts.host.clone();
539        result.port = base_parts.port;
540    }
541
542    // Normalize the path
543    if let Some(ref path) = result.path {
544        let normalized = normalize_uri_path(path);
545        result.path = Some(normalized);
546    }
547
548    Some(build_uri(&result))
549}
550
551/// Helper: parse path, query, and fragment from the remainder of a URI.
552fn parse_path_query_fragment(input: &[u8], parts: &mut UriParts) {
553    // Find '?' and '#'
554    let query_pos = input.iter().position(|&b| b == b'?');
555    let frag_pos = input.iter().position(|&b| b == b'#');
556
557    let path_end = match (query_pos, frag_pos) {
558        (Some(q), Some(f)) => q.min(f),
559        (Some(q), None) => q,
560        (None, Some(f)) => f,
561        (None, None) => input.len(),
562    };
563
564    if path_end > 0 {
565        parts.path = Some(input[..path_end].to_vec());
566        parts.path_raw = parts.path.clone();
567    }
568
569    // Query
570    if let Some(qpos) = query_pos {
571        let qstart = qpos + 1;
572        let qend = frag_pos.unwrap_or(input.len());
573        if qstart < qend {
574            parts.query = Some(input[qstart..qend].to_vec());
575        }
576    }
577
578    // Fragment
579    if let Some(fpos) = frag_pos {
580        let fstart = fpos + 1;
581        if fstart < input.len() {
582            parts.fragment = Some(input[fstart..].to_vec());
583        }
584    }
585}
586
587// ── C ABI-compatible wrapper functions ──────────────────────────────────────
588
589/// `xmlURIPtr xmlParseURI(const char *str)`
590///
591/// Parse a URI from a C string. Returns an opaque pointer to a heap-allocated
592/// `UriParts`, or null on failure.
593///
594/// The caller must free the result with [`xmlFreeURI`].
595///
596/// # Safety
597///
598/// `str` must be a valid null-terminated C string.
599pub(crate) unsafe fn xmlParseURI(str: *const c_char) -> *mut c_void {
600    if str.is_null() {
601        return ptr::null_mut();
602    }
603    let len = libc::strlen(str);
604    let slice = unsafe { core::slice::from_raw_parts(str as *const u8, len) };
605    match parse_uri(slice) {
606        Some(parts) => {
607            let boxed = Box::new(parts);
608            Box::into_raw(boxed) as *mut c_void
609        }
610        None => ptr::null_mut(),
611    }
612}
613
614/// `void xmlFreeURI(xmlURIPtr uri)`
615///
616/// Free a URI structure previously returned by [`xmlParseURI`] or [`xmlCreateURI`].
617///
618/// # Safety
619///
620/// `uri` must have been allocated by [`xmlParseURI`] or [`xmlCreateURI`] and not yet freed.
621pub(crate) unsafe fn xmlFreeURI(uri: *mut c_void) {
622    if !uri.is_null() {
623        drop(Box::from_raw(uri as *mut UriParts));
624    }
625}
626
627/// `xmlURIPtr xmlCreateURI(void)`
628///
629/// Create an empty URI structure.
630/// Returns an opaque pointer to a heap-allocated, zero-initialized `UriParts`.
631///
632/// The caller must free the result with [`xmlFreeURI`].
633pub(crate) fn xmlCreateURI() -> *mut c_void {
634    let parts = UriParts::default();
635    let boxed = Box::new(parts);
636    Box::into_raw(boxed) as *mut c_void
637}
638
639/// `xmlChar *xmlSaveUri(xmlURIPtr uri)`
640///
641/// Serialize a URI structure back to a string.
642/// Returns a null-terminated `xmlChar*` string allocated with `xmlMalloc`,
643/// or null on failure.
644///
645/// The caller must free the result with `xmlFree`.
646///
647/// # Safety
648///
649/// `uri` must be a valid pointer to a `UriParts` previously created by
650/// [`xmlParseURI`] or [`xmlCreateURI`].
651pub(crate) unsafe fn xmlSaveUri(uri: *mut c_void) -> *mut xmlChar {
652    if uri.is_null() {
653        return ptr::null_mut();
654    }
655    let parts = unsafe { &*(uri as *const UriParts) };
656    let result = build_uri(parts);
657    if result.is_empty() {
658        return ptr::null_mut();
659    }
660    // Allocate with xmlMalloc and copy
661    let len = result.len();
662    let ptr = unsafe { allocator::xmlMalloc(len + 1) as *mut u8 };
663    if ptr.is_null() {
664        return ptr::null_mut();
665    }
666    unsafe {
667        ptr::copy_nonoverlapping(result.as_ptr(), ptr, len);
668        *ptr.add(len) = 0; // null terminator
669    }
670    ptr as *mut xmlChar
671}
672
673/// `xmlChar *xmlURIEscapeStr(unsigned char *str, unsigned char *list)`
674///
675/// Percent-escape a string for use in a URI.
676/// Characters in `list` are NOT escaped (they're treated as safe).
677///
678/// Returns a null-terminated `xmlChar*` string allocated with `xmlMalloc`,
679/// or null on failure.
680///
681/// # Safety
682///
683/// `str` must be a valid null-terminated C string. `list` may be null.
684pub(crate) unsafe fn xmlURIEscapeStr(str: *const xmlChar, list: *const xmlChar) -> *mut xmlChar {
685    if str.is_null() {
686        return ptr::null_mut();
687    }
688    let str_len = unsafe { libc::strlen(str as *const libc::c_char) };
689    let str_slice = unsafe { core::slice::from_raw_parts(str, str_len) };
690
691    // Build the safe-set: unreserved + reserved + chars in `list`
692    let mut safe_set = [false; 256];
693    for b in 0u8..=255 {
694        if is_unreserved(b) || b == b'%' {
695            safe_set[b as usize] = true;
696        }
697    }
698    if !list.is_null() {
699        let list_len = unsafe { libc::strlen(list as *const libc::c_char) };
700        let list_slice = unsafe { core::slice::from_raw_parts(list, list_len) };
701        for &b in list_slice {
702            safe_set[b as usize] = true;
703        }
704    }
705
706    // Build the result
707    let mut result = Vec::with_capacity(str_slice.len() * 3);
708    for &b in str_slice {
709        if safe_set[b as usize] {
710            result.push(b);
711        } else {
712            result.extend_from_slice(format!("%{:02X}", b).as_bytes());
713        }
714    }
715
716    let len = result.len();
717    let ptr = unsafe { allocator::xmlMalloc(len + 1) as *mut u8 };
718    if ptr.is_null() {
719        return ptr::null_mut();
720    }
721    unsafe {
722        ptr::copy_nonoverlapping(result.as_ptr(), ptr, len);
723        *ptr.add(len) = 0;
724    }
725    ptr as *mut xmlChar
726}
727
728/// `xmlChar *xmlURIUnescapeString(const char *str, int len, char *target)`
729///
730/// Unescape a percent-encoded URI string.
731///
732/// If `len` is negative, the string is assumed to be null-terminated.
733/// If `target` is not null, the result is written there (and returned).
734/// Otherwise, a new buffer is allocated with `xmlMalloc`.
735///
736/// Returns the unescaped string, or null on failure.
737///
738/// # Safety
739///
740/// `str` must be a valid C string (null-terminated if `len` < 0).
741/// `target` must be large enough to hold the result if not null.
742pub(crate) unsafe fn xmlURIUnescapeString(
743    str: *const c_char,
744    len: c_int,
745    target: *mut c_char,
746) -> *mut c_char {
747    if str.is_null() {
748        return ptr::null_mut();
749    }
750    let slice = if len < 0 {
751        let cstr_len = unsafe { libc::strlen(str) };
752        unsafe { core::slice::from_raw_parts(str as *const u8, cstr_len) }
753    } else {
754        unsafe { core::slice::from_raw_parts(str as *const u8, len as usize) }
755    };
756
757    let decoded = percent_decode(slice);
758
759    if !target.is_null() {
760        unsafe {
761            ptr::copy_nonoverlapping(decoded.as_ptr(), target as *mut u8, decoded.len());
762            *((target as *mut u8).add(decoded.len())) = 0;
763        }
764        return target;
765    }
766
767    let out_len = decoded.len();
768    let ptr = unsafe { allocator::xmlMalloc(out_len + 1) as *mut u8 };
769    if ptr.is_null() {
770        return ptr::null_mut();
771    }
772    unsafe {
773        ptr::copy_nonoverlapping(decoded.as_ptr(), ptr, out_len);
774        *ptr.add(out_len) = 0;
775    }
776    ptr as *mut c_char
777}
778
779/// `xmlURIPtr xmlParseURIRaw(const char *str, int raw)`
780///
781/// Parse a URI from a C string.
782/// The `raw` flag is currently unused (reserved for future behavior).
783///
784/// Returns an opaque pointer to a heap-allocated `UriParts`, or null on failure.
785///
786/// The caller must free the result with [`xmlFreeURI`].
787///
788/// # Safety
789///
790/// `str` must be a valid null-terminated C string.
791pub(crate) unsafe fn xmlParseURIRaw(str: *const c_char, _raw: c_int) -> *mut c_void {
792    unsafe { xmlParseURI(str) }
793}
794
795// ── Tests ───────────────────────────────────────────────────────────────────
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    // ── URI character classification ───────────────────────────────────────
802
803    #[test]
804    fn test_is_unreserved() {
805        assert!(is_unreserved(b'a'));
806        assert!(is_unreserved(b'Z'));
807        assert!(is_unreserved(b'0'));
808        assert!(is_unreserved(b'-'));
809        assert!(is_unreserved(b'.'));
810        assert!(is_unreserved(b'_'));
811        assert!(is_unreserved(b'~'));
812        assert!(!is_unreserved(b':'));
813        assert!(!is_unreserved(b'/'));
814        assert!(!is_unreserved(b'%'));
815        assert!(!is_unreserved(b' '));
816    }
817
818    #[test]
819    fn test_is_reserved() {
820        assert!(is_reserved(b':'));
821        assert!(is_reserved(b'/'));
822        assert!(is_reserved(b'?'));
823        assert!(is_reserved(b'#'));
824        assert!(is_reserved(b'@'));
825        assert!(is_reserved(b'!'));
826        assert!(is_reserved(b'$'));
827        assert!(is_reserved(b'&'));
828        assert!(is_reserved(b'('));
829        assert!(is_reserved(b')'));
830        assert!(!is_reserved(b'a'));
831        assert!(!is_reserved(b' '));
832    }
833
834    #[test]
835    fn test_is_scheme_char() {
836        assert!(is_scheme_char(b'a'));
837        assert!(is_scheme_char(b'Z'));
838        assert!(is_scheme_char(b'0'));
839        assert!(is_scheme_char(b'+'));
840        assert!(is_scheme_char(b'-'));
841        assert!(is_scheme_char(b'.'));
842        assert!(!is_scheme_char(b':'));
843        assert!(!is_scheme_char(b'/'));
844        assert!(!is_scheme_char(b' '));
845    }
846
847    // ── Percent encoding / decoding ────────────────────────────────────────
848
849    #[test]
850    fn test_percent_decode_simple() {
851        assert_eq!(percent_decode(b"hello"), b"hello");
852        assert_eq!(percent_decode(b"%68%65%6C%6C%6F"), b"hello");
853        assert_eq!(percent_decode(b"%48%65%6C%6C%6F"), b"Hello");
854        assert_eq!(percent_decode(b"a%20b"), b"a b");
855    }
856
857    #[test]
858    fn test_percent_decode_invalid() {
859        // Invalid percent sequence: keep as-is
860        assert_eq!(percent_decode(b"%XX"), b"%XX");
861        assert_eq!(percent_decode(b"%2"), b"%2");
862        assert_eq!(percent_decode(b"%"), b"%");
863        assert_eq!(percent_decode(b"%%20"), b"% ");
864    }
865
866    #[test]
867    fn test_percent_decode_empty() {
868        assert_eq!(percent_decode(b""), b"");
869    }
870
871    #[test]
872    fn test_percent_encode() {
873        assert_eq!(percent_encode(b"hello"), b"hello");
874        assert_eq!(percent_encode(b"hello world"), b"hello%20world");
875        assert_eq!(percent_encode(b"a/b"), b"a/b"); // '/' is reserved, keep as-is
876    }
877
878    // ── URI parsing ────────────────────────────────────────────────────────
879
880    #[test]
881    fn test_parse_http_uri() {
882        let parts =
883            parse_uri(b"http://example.com/path/to/file.xml?query=1#frag").expect("should parse");
884        assert_eq!(parts.scheme, Some(b"http".to_vec()));
885        assert_eq!(parts.authority, Some(b"example.com".to_vec()));
886        assert_eq!(parts.host, Some(b"example.com".to_vec()));
887        assert_eq!(parts.port, 0);
888        assert_eq!(parts.path, Some(b"/path/to/file.xml".to_vec()));
889        assert_eq!(parts.query, Some(b"query=1".to_vec()));
890        assert_eq!(parts.fragment, Some(b"frag".to_vec()));
891    }
892
893    #[test]
894    fn test_parse_https_uri() {
895        let parts = parse_uri(b"https://example.com:443/path").expect("should parse");
896        assert_eq!(parts.scheme, Some(b"https".to_vec()));
897        assert_eq!(parts.host, Some(b"example.com".to_vec()));
898        assert_eq!(parts.port, 443);
899        assert_eq!(parts.path, Some(b"/path".to_vec()));
900    }
901
902    #[test]
903    fn test_parse_file_uri() {
904        let parts = parse_uri(b"file:///etc/hosts").expect("should parse");
905        assert_eq!(parts.scheme, Some(b"file".to_vec()));
906        assert!(parts.authority.is_none() || parts.authority.as_deref() == Some(b""));
907        assert_eq!(parts.path, Some(b"/etc/hosts".to_vec()));
908    }
909
910    #[test]
911    fn test_parse_file_uri_with_host() {
912        // file://hostname/path is also valid
913        let parts = parse_uri(b"file://localhost/etc/hosts").expect("should parse");
914        assert_eq!(parts.scheme, Some(b"file".to_vec()));
915        assert_eq!(parts.host, Some(b"localhost".to_vec()));
916        assert_eq!(parts.path, Some(b"/etc/hosts".to_vec()));
917    }
918
919    #[test]
920    fn test_parse_relative_uri() {
921        let parts = parse_uri(b"/path/to/file.xml").expect("should parse");
922        assert!(parts.scheme.is_none());
923        assert_eq!(parts.path, Some(b"/path/to/file.xml".to_vec()));
924    }
925
926    #[test]
927    fn test_parse_relative_uri_with_query() {
928        let parts = parse_uri(b"file.xml?query=1").expect("should parse");
929        assert!(parts.scheme.is_none());
930        assert_eq!(parts.path, Some(b"file.xml".to_vec()));
931        assert_eq!(parts.query, Some(b"query=1".to_vec()));
932    }
933
934    #[test]
935    fn test_parse_uri_with_user_info() {
936        let parts = parse_uri(b"ftp://user@host.com:21/path").expect("should parse");
937        assert_eq!(parts.scheme, Some(b"ftp".to_vec()));
938        assert_eq!(parts.user, Some(b"user".to_vec()));
939        assert_eq!(parts.host, Some(b"host.com".to_vec()));
940        assert_eq!(parts.port, 21);
941        assert_eq!(parts.path, Some(b"/path".to_vec()));
942    }
943
944    #[test]
945    fn test_parse_uri_with_user_password() {
946        let parts = parse_uri(b"ftp://user:pass@host.com/path").expect("should parse");
947        assert_eq!(parts.scheme, Some(b"ftp".to_vec()));
948        assert_eq!(parts.user, Some(b"user:pass".to_vec()));
949        assert_eq!(parts.host, Some(b"host.com".to_vec()));
950        assert_eq!(parts.path, Some(b"/path".to_vec()));
951    }
952
953    #[test]
954    fn test_parse_opaque_uri() {
955        let parts = parse_uri(b"mailto:user@example.com").expect("should parse");
956        assert_eq!(parts.scheme, Some(b"mailto".to_vec()));
957        assert_eq!(parts.opaque, Some(b"user@example.com".to_vec()));
958        assert!(parts.authority.is_none());
959    }
960
961    #[test]
962    fn test_parse_opaque_uri_with_fragment() {
963        let parts = parse_uri(b"urn:isbn:0-395-36341-1#frag").expect("should parse");
964        assert_eq!(parts.scheme, Some(b"urn".to_vec()));
965        assert_eq!(parts.opaque, Some(b"isbn:0-395-36341-1".to_vec()));
966        assert_eq!(parts.fragment, Some(b"frag".to_vec()));
967    }
968
969    #[test]
970    fn test_parse_empty_uri() {
971        assert!(parse_uri(b"").is_none());
972    }
973
974    #[test]
975    fn test_parse_uri_fragment_only() {
976        let parts = parse_uri(b"#fragment").expect("should parse");
977        assert!(parts.scheme.is_none());
978        assert!(parts.path.is_none());
979        assert_eq!(parts.fragment, Some(b"fragment".to_vec()));
980    }
981
982    #[test]
983    fn test_parse_uri_query_only() {
984        let parts = parse_uri(b"?query").expect("should parse");
985        assert!(parts.scheme.is_none());
986        assert!(parts.path.is_none());
987        assert_eq!(parts.query, Some(b"query".to_vec()));
988    }
989
990    #[test]
991    fn test_parse_uri_with_ipv6_host() {
992        let parts = parse_uri(b"http://[::1]:8080/path").expect("should parse");
993        assert_eq!(parts.scheme, Some(b"http".to_vec()));
994        assert_eq!(parts.host, Some(b"[::1]".to_vec()));
995        assert_eq!(parts.port, 8080);
996        assert_eq!(parts.path, Some(b"/path".to_vec()));
997    }
998
999    #[test]
1000    fn test_parse_uri_no_path() {
1001        let parts = parse_uri(b"http://example.com").expect("should parse");
1002        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1003        assert_eq!(parts.host, Some(b"example.com".to_vec()));
1004        assert!(parts.path.is_none());
1005    }
1006
1007    #[test]
1008    fn test_parse_uri_no_path_with_query() {
1009        let parts = parse_uri(b"http://example.com?query").expect("should parse");
1010        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1011        assert_eq!(parts.host, Some(b"example.com".to_vec()));
1012        assert!(parts.path.is_none());
1013        assert_eq!(parts.query, Some(b"query".to_vec()));
1014    }
1015
1016    // ── URI building ───────────────────────────────────────────────────────
1017
1018    #[test]
1019    fn test_build_uri() {
1020        let parts = UriParts {
1021            scheme: Some(b"http".to_vec()),
1022            host: Some(b"example.com".to_vec()),
1023            port: 8080,
1024            path: Some(b"/path".to_vec()),
1025            query: Some(b"q=1".to_vec()),
1026            fragment: Some(b"frag".to_vec()),
1027            ..Default::default()
1028        };
1029        assert_eq!(build_uri(&parts), b"http://example.com:8080/path?q=1#frag");
1030    }
1031
1032    #[test]
1033    fn test_build_uri_simple() {
1034        let parts = UriParts {
1035            scheme: Some(b"http".to_vec()),
1036            host: Some(b"example.com".to_vec()),
1037            path: Some(b"/".to_vec()),
1038            ..Default::default()
1039        };
1040        assert_eq!(build_uri(&parts), b"http://example.com/");
1041    }
1042
1043    #[test]
1044    fn test_build_uri_opaque() {
1045        let parts = UriParts {
1046            scheme: Some(b"mailto".to_vec()),
1047            opaque: Some(b"user@example.com".to_vec()),
1048            ..Default::default()
1049        };
1050        assert_eq!(build_uri(&parts), b"mailto:user@example.com");
1051    }
1052
1053    #[test]
1054    fn test_build_uri_relative() {
1055        let parts = UriParts {
1056            path: Some(b"/relative/path".to_vec()),
1057            ..Default::default()
1058        };
1059        assert_eq!(build_uri(&parts), b"/relative/path");
1060    }
1061
1062    // ── URI normalization ──────────────────────────────────────────────────
1063
1064    #[test]
1065    fn test_normalize_uri_path_simple() {
1066        assert_eq!(normalize_uri_path(b"/foo/bar"), b"/foo/bar");
1067        assert_eq!(normalize_uri_path(b"/foo/./bar"), b"/foo/bar");
1068        assert_eq!(normalize_uri_path(b"/foo/../bar"), b"/bar");
1069        assert_eq!(normalize_uri_path(b"/foo/bar/.."), b"/foo");
1070        assert_eq!(normalize_uri_path(b"/"), b"/");
1071    }
1072
1073    #[test]
1074    fn test_normalize_uri_path_relative() {
1075        assert_eq!(normalize_uri_path(b"foo/bar"), b"foo/bar");
1076        assert_eq!(normalize_uri_path(b"foo/./bar"), b"foo/bar");
1077        assert_eq!(normalize_uri_path(b"foo/../bar"), b"bar");
1078    }
1079
1080    #[test]
1081    fn test_normalize_uri_path_double_dot_overflow() {
1082        // ".." above root should just be removed
1083        assert_eq!(normalize_uri_path(b"/a/../../b"), b"/b");
1084        assert_eq!(normalize_uri_path(b"/../b"), b"/b");
1085    }
1086
1087    #[test]
1088    fn test_normalize_uri_path_empty() {
1089        assert_eq!(normalize_uri_path(b""), b"");
1090    }
1091
1092    #[test]
1093    fn test_normalize_uri_path_dots_only() {
1094        assert_eq!(normalize_uri_path(b"./././."), b"");
1095        assert_eq!(normalize_uri_path(b"/./././"), b"/");
1096    }
1097
1098    // ── URI scheme / absolute check ────────────────────────────────────────
1099
1100    #[test]
1101    fn test_get_scheme() {
1102        assert_eq!(get_scheme(b"http://example.com"), Some(b"http".to_vec()));
1103        assert_eq!(get_scheme(b"https://example.com"), Some(b"https".to_vec()));
1104        assert_eq!(get_scheme(b"file:///path"), Some(b"file".to_vec()));
1105        assert_eq!(get_scheme(b"ftp://host"), Some(b"ftp".to_vec()));
1106        assert_eq!(get_scheme(b"mailto:user@host"), Some(b"mailto".to_vec()));
1107        assert_eq!(get_scheme(b"urn:isbn:1234"), Some(b"urn".to_vec()));
1108        assert_eq!(get_scheme(b"/path"), None);
1109        assert_eq!(get_scheme(b"relative"), None);
1110        assert_eq!(get_scheme(b""), None);
1111    }
1112
1113    #[test]
1114    fn test_is_absolute() {
1115        assert!(is_absolute(b"http://example.com"));
1116        assert!(is_absolute(b"file:///path"));
1117        assert!(is_absolute(b"mailto:user@host"));
1118        assert!(!is_absolute(b"/path"));
1119        assert!(!is_absolute(b"relative"));
1120        assert!(!is_absolute(b""));
1121    }
1122
1123    // ── URI resolution ─────────────────────────────────────────────────────
1124
1125    #[test]
1126    fn test_resolve_uri_absolute_relative() {
1127        let result =
1128            resolve_uri(b"http://example.com/base/", b"relative.xml").expect("should resolve");
1129        assert_eq!(result, b"http://example.com/base/relative.xml");
1130    }
1131
1132    #[test]
1133    fn test_resolve_uri_absolute_absolute() {
1134        let result = resolve_uri(
1135            b"http://example.com/base/",
1136            b"http://other.com/absolute.xml",
1137        )
1138        .expect("should resolve");
1139        assert_eq!(result, b"http://other.com/absolute.xml");
1140    }
1141
1142    #[test]
1143    fn test_resolve_uri_root_relative() {
1144        let result =
1145            resolve_uri(b"http://example.com/base/file.xml", b"/root.xml").expect("should resolve");
1146        assert_eq!(result, b"http://example.com/root.xml");
1147    }
1148
1149    #[test]
1150    fn test_resolve_uri_network_path() {
1151        let result = resolve_uri(b"http://example.com/base/file.xml", b"//other.com/root.xml")
1152            .expect("should resolve");
1153        assert_eq!(result, b"http://other.com/root.xml");
1154    }
1155
1156    #[test]
1157    fn test_resolve_uri_parent_traversal() {
1158        let result = resolve_uri(b"http://example.com/a/b/c/file.xml", b"../../d/file.xml")
1159            .expect("should resolve");
1160        assert_eq!(result, b"http://example.com/a/d/file.xml");
1161    }
1162
1163    #[test]
1164    fn test_resolve_uri_with_query() {
1165        let result =
1166            resolve_uri(b"http://example.com/base/", b"file.xml?query=1").expect("should resolve");
1167        assert_eq!(result, b"http://example.com/base/file.xml?query=1");
1168    }
1169
1170    #[test]
1171    fn test_resolve_uri_with_fragment() {
1172        let result =
1173            resolve_uri(b"http://example.com/base/file.xml", b"#frag").expect("should resolve");
1174        // A fragment-only reference with no path should resolve to base's directory
1175        // with the fragment replaced.
1176        assert_eq!(result, b"http://example.com/base/#frag");
1177    }
1178
1179    #[test]
1180    fn test_resolve_uri_empty_base() {
1181        let result = resolve_uri(b"", b"relative.xml");
1182        assert_eq!(result, Some(b"relative.xml".to_vec()));
1183    }
1184
1185    #[test]
1186    fn test_resolve_uri_empty_relative() {
1187        let result = resolve_uri(b"http://example.com/base/", b"");
1188        assert!(result.is_some());
1189        // Should return base URI
1190        assert_eq!(result.unwrap(), b"http://example.com/base/");
1191    }
1192
1193    #[test]
1194    fn test_resolve_uri_both_empty() {
1195        assert!(resolve_uri(b"", b"").is_none());
1196    }
1197
1198    #[test]
1199    fn test_resolve_uri_file_scheme() {
1200        let result = resolve_uri(b"file:///base/dir/", b"file.xml").expect("should resolve");
1201        assert_eq!(result, b"file:///base/dir/file.xml");
1202    }
1203
1204    #[test]
1205    fn test_resolve_uri_deep_relative() {
1206        let result = resolve_uri(
1207            b"http://example.com/a/b/c/d/e/file.xml",
1208            b"../../../../x/y/z/file.xml",
1209        )
1210        .expect("should resolve");
1211        assert_eq!(result, b"http://example.com/a/x/y/z/file.xml");
1212    }
1213
1214    // ── C ABI wrapper functions ────────────────────────────────────────────
1215
1216    #[test]
1217    fn test_xml_create_and_free_uri() {
1218        unsafe {
1219            let uri = xmlCreateURI();
1220            assert!(!uri.is_null());
1221            xmlFreeURI(uri);
1222        }
1223    }
1224
1225    #[test]
1226    fn test_xml_parse_uri() {
1227        unsafe {
1228            let cstr = b"http://example.com/path\0".as_ptr() as *const c_char;
1229            let uri = xmlParseURI(cstr);
1230            assert!(!uri.is_null());
1231            let parts = &*(uri as *const UriParts);
1232            assert_eq!(parts.scheme, Some(b"http".to_vec()));
1233            assert_eq!(parts.host, Some(b"example.com".to_vec()));
1234            xmlFreeURI(uri);
1235        }
1236    }
1237
1238    #[test]
1239    fn test_xml_parse_uri_null() {
1240        unsafe {
1241            let uri = xmlParseURI(ptr::null());
1242            assert!(uri.is_null());
1243        }
1244    }
1245
1246    #[test]
1247    fn test_xml_save_uri() {
1248        unsafe {
1249            let cstr = b"http://example.com:8080/path?q=1#f\0".as_ptr() as *const c_char;
1250            let uri = xmlParseURI(cstr);
1251            assert!(!uri.is_null());
1252            let saved = xmlSaveUri(uri);
1253            assert!(!saved.is_null());
1254            let saved_str = std::ffi::CStr::from_ptr(saved as *const c_char);
1255            assert_eq!(saved_str.to_bytes(), b"http://example.com:8080/path?q=1#f");
1256            allocator::xmlFree(saved as *mut core::ffi::c_void);
1257            xmlFreeURI(uri);
1258        }
1259    }
1260
1261    #[test]
1262    fn test_xml_escape_str() {
1263        unsafe {
1264            let cstr = b"hello world\0".as_ptr() as *const xmlChar;
1265            let result = xmlURIEscapeStr(cstr, ptr::null());
1266            assert!(!result.is_null());
1267            let result_str = std::ffi::CStr::from_ptr(result as *const c_char);
1268            assert_eq!(result_str.to_bytes(), b"hello%20world");
1269            allocator::xmlFree(result as *mut core::ffi::c_void);
1270        }
1271    }
1272
1273    #[test]
1274    fn test_xml_escape_str_with_safe_list() {
1275        unsafe {
1276            let cstr = b"hello world\0".as_ptr() as *const xmlChar;
1277            let safe = b" \0".as_ptr() as *const xmlChar;
1278            let result = xmlURIEscapeStr(cstr, safe);
1279            assert!(!result.is_null());
1280            let result_str = std::ffi::CStr::from_ptr(result as *const c_char);
1281            assert_eq!(result_str.to_bytes(), b"hello world"); // space is in safe list
1282            allocator::xmlFree(result as *mut core::ffi::c_void);
1283        }
1284    }
1285
1286    #[test]
1287    fn test_xml_unescape_string() {
1288        unsafe {
1289            let cstr = b"hello%20world\0".as_ptr() as *const c_char;
1290            let result = xmlURIUnescapeString(cstr, -1, ptr::null_mut());
1291            assert!(!result.is_null());
1292            let result_str = std::ffi::CStr::from_ptr(result);
1293            assert_eq!(result_str.to_bytes(), b"hello world");
1294            allocator::xmlFree(result as *mut core::ffi::c_void);
1295        }
1296    }
1297
1298    #[test]
1299    fn test_xml_unescape_string_with_len() {
1300        unsafe {
1301            let cstr = b"hello%20world\0".as_ptr() as *const c_char;
1302            let result = xmlURIUnescapeString(cstr, 13, ptr::null_mut());
1303            assert!(!result.is_null());
1304            let result_str = std::ffi::CStr::from_ptr(result);
1305            assert_eq!(result_str.to_bytes(), b"hello world");
1306            allocator::xmlFree(result as *mut core::ffi::c_void);
1307        }
1308    }
1309
1310    #[test]
1311    fn test_xml_parse_uri_raw() {
1312        unsafe {
1313            let cstr = b"http://example.com\0".as_ptr() as *const c_char;
1314            let uri = xmlParseURIRaw(cstr, 0);
1315            assert!(!uri.is_null());
1316            let parts = &*(uri as *const UriParts);
1317            assert_eq!(parts.scheme, Some(b"http".to_vec()));
1318            xmlFreeURI(uri);
1319        }
1320    }
1321
1322    #[test]
1323    fn test_xml_free_null() {
1324        unsafe {
1325            // Should not crash
1326            xmlFreeURI(ptr::null_mut());
1327        }
1328    }
1329
1330    // ── Edge cases ─────────────────────────────────────────────────────────
1331
1332    #[test]
1333    fn test_parse_uri_scheme_only() {
1334        let parts = parse_uri(b"http:").expect("should parse");
1335        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1336        assert!(parts.opaque.is_none() || parts.opaque.as_deref() == Some(b""));
1337    }
1338
1339    #[test]
1340    fn test_parse_uri_with_trailing_slash() {
1341        let parts = parse_uri(b"http://example.com/").expect("should parse");
1342        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1343        assert_eq!(parts.path, Some(b"/".to_vec()));
1344    }
1345
1346    #[test]
1347    fn test_parse_uri_with_double_slash_path() {
1348        let parts = parse_uri(b"http://example.com//path").expect("should parse");
1349        assert_eq!(parts.scheme, Some(b"http".to_vec()));
1350        assert_eq!(parts.path, Some(b"//path".to_vec()));
1351    }
1352
1353    #[test]
1354    fn test_parse_uri_no_scheme_colon() {
1355        // A string with a colon but no valid scheme (doesn't start with letter)
1356        let parts = parse_uri(b"123:path");
1357        // This should be treated as a relative path, since '1' is not a letter
1358        assert!(parts.is_some());
1359        let p = parts.unwrap();
1360        assert!(p.scheme.is_none());
1361        assert_eq!(p.path, Some(b"123:path".to_vec()));
1362    }
1363
1364    #[test]
1365    fn test_parse_uri_ftp_with_home_dir() {
1366        let parts = parse_uri(b"ftp://host/home/user/file.txt").expect("should parse");
1367        assert_eq!(parts.scheme, Some(b"ftp".to_vec()));
1368        assert_eq!(parts.host, Some(b"host".to_vec()));
1369        assert_eq!(parts.path, Some(b"/home/user/file.txt".to_vec()));
1370    }
1371
1372    #[test]
1373    fn test_parse_uri_scheme_case() {
1374        let parts = parse_uri(b"HTTP://example.com/Path").expect("should parse");
1375        assert_eq!(parts.scheme, Some(b"HTTP".to_vec()));
1376        assert_eq!(parts.host, Some(b"example.com".to_vec()));
1377        assert_eq!(parts.path, Some(b"/Path".to_vec()));
1378    }
1379
1380    #[test]
1381    fn test_normalize_path_complex() {
1382        assert_eq!(normalize_uri_path(b"/a/b/c/./../../g"), b"/a/g");
1383        assert_eq!(normalize_uri_path(b"mid/content=5/../6"), b"mid/6");
1384    }
1385
1386    #[test]
1387    fn test_resolve_uri_same_directory() {
1388        let result =
1389            resolve_uri(b"http://example.com/a/b/c.html", b"d.html").expect("should resolve");
1390        assert_eq!(result, b"http://example.com/a/b/d.html");
1391    }
1392
1393    #[test]
1394    fn test_resolve_uri_complex_traversal() {
1395        let result = resolve_uri(b"http://a/b/c/d;p?q", b"g/h/../i/./j#f").expect("should resolve");
1396        let result_str = core::str::from_utf8(&result).unwrap_or("");
1397        assert!(result_str.contains("http://a/b/c/g/i/j"));
1398    }
1399
1400    // ── Hex value helper ───────────────────────────────────────────────────
1401
1402    #[test]
1403    fn test_hex_val() {
1404        assert_eq!(hex_val(b'0'), Some(0));
1405        assert_eq!(hex_val(b'9'), Some(9));
1406        assert_eq!(hex_val(b'a'), Some(10));
1407        assert_eq!(hex_val(b'f'), Some(15));
1408        assert_eq!(hex_val(b'A'), Some(10));
1409        assert_eq!(hex_val(b'F'), Some(15));
1410        assert_eq!(hex_val(b'g'), None);
1411        assert_eq!(hex_val(b'z'), None);
1412        assert_eq!(hex_val(b'%'), None);
1413    }
1414
1415    // ── Parse URI C string ─────────────────────────────────────────────────
1416
1417    #[test]
1418    fn test_parse_uri_cstr() {
1419        let cstr = b"http://example.com/path\0".as_ptr() as *const xmlChar;
1420        let ptr = parse_uri_cstr(cstr);
1421        assert!(!ptr.is_null());
1422        unsafe {
1423            assert_eq!((*ptr).scheme, Some(b"http".to_vec()));
1424            assert_eq!((*ptr).host, Some(b"example.com".to_vec()));
1425            free_uri_parts(ptr);
1426        }
1427    }
1428
1429    #[test]
1430    fn test_parse_uri_cstr_null() {
1431        let ptr = parse_uri_cstr(ptr::null());
1432        assert!(ptr.is_null());
1433    }
1434
1435    #[test]
1436    fn test_parse_uri_cstr_invalid() {
1437        let cstr = b"\0".as_ptr() as *const xmlChar;
1438        let ptr = parse_uri_cstr(cstr);
1439        assert!(ptr.is_null());
1440    }
1441}