//! glTF companion-resource discovery and the SSRF/path-containment guard.
//!
//! `three-d-asset`'s glTF dependency discovery is private, so Polymodel scans the
//! primary `.gltf` JSON itself for the non-`data:` URIs it must fetch (external buffers
//! and images). The guard ensures only safe, in-directory, relative URIs are ever
//! fetched: absolute (`https://…`, `/etc/…`), arbitrary-scheme (`file:`, `blob:`),
//! protocol-relative (`//host`), backslash, and `..`-traversal URIs are rejected —
//! including percent-encoded or backslash-obfuscated traversal.

use serde_json::Value;

/// Discover companion-resource URIs referenced by a `.gltf` document: every non-`data:`
/// `buffers[].uri` and `images[].uri`.
///
/// Returns empty if `primary` is not a JSON object (e.g. a GLB, which is binary), so
/// callers can feed any primary without branching on format.
pub fn discover_companion_uris(primary: &[u8]) -> Vec<String> {
    let Ok(Value::Object(root)) = serde_json::from_slice::<Value>(primary) else {
        return Vec::new();
    };
    let mut uris = Vec::new();
    for key in ["buffers", "images"] {
        if let Some(Value::Array(items)) = root.get(key) {
            for item in items {
                if let Some(Value::String(uri)) = item.get("uri")
                    && !uri.starts_with("data:")
                {
                    uris.push(uri.clone());
                }
            }
        }
    }
    uris
}

/// A companion URI is fetchable only if it is safe and relative: no scheme, no
/// backslash, not an absolute or protocol-relative path, and its normalized path does
/// not escape the asset's directory via `..`.
///
/// Validation runs on a **percent-decoded** copy so encoded traversal (`%2e%2e`,
/// `..%2f`, `%5c`) cannot bypass it; any residual `%` after one decode (double encoding
/// like `%252e`) or invalid encoding is rejected. Only the decoded form is validated;
/// callers keep the original raw URI as the companion key.
pub fn is_safe_relative_uri(uri: &str) -> bool {
    if uri.contains('\\') {
        return false;
    }
    let Some(decoded) = percent_decode_once(uri) else {
        return false;
    };
    if decoded.contains('%') {
        return false;
    }
    if decoded.contains('\\') || decoded.contains(':') || decoded.starts_with('/') {
        return false;
    }
    let mut depth = 0isize;
    for segment in decoded.split('/') {
        match segment {
            "" | "." => {}
            ".." => {
                depth -= 1;
                if depth < 0 {
                    return false;
                }
            }
            _ => depth += 1,
        }
    }
    true
}

fn percent_decode_once(input: &str) -> Option<String> {
    let bytes = input.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' {
            let h = hex_digit(bytes.get(i + 1).copied())?;
            let l = hex_digit(bytes.get(i + 2).copied())?;
            out.push((h << 4) | l);
            i += 3;
        } else {
            out.push(bytes[i]);
            i += 1;
        }
    }
    String::from_utf8(out).ok()
}

fn hex_digit(b: Option<u8>) -> Option<u8> {
    let b = b?;
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(b - b'a' + 10),
        b'A'..=b'F' => Some(b - b'A' + 10),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn discovers_buffer_and_image_uris_skipping_data_urls() {
        let gltf = br#"{
            "asset": {"version": "2.0"},
            "buffers": [
                {"uri": "cube.bin"},
                {"uri": "data:application/octet-stream;base64,AAAA"}
            ],
            "images": [{"uri": "tex.png"}, {"bufferView": 0}]
        }"#;
        let mut uris = discover_companion_uris(gltf);
        uris.sort();
        assert_eq!(uris, vec!["cube.bin".to_string(), "tex.png".to_string()]);
    }

    #[test]
    fn discovers_no_uris_from_non_json() {
        assert!(discover_companion_uris(b"glTF\x00\x00\x00\x00").is_empty());
        assert!(discover_companion_uris(b"not json at all").is_empty());
    }

    #[test]
    fn accepts_safe_relative_uris() {
        assert!(is_safe_relative_uri("cube.bin"));
        assert!(is_safe_relative_uri("textures/foo.png"));
        assert!(is_safe_relative_uri("a/b/c.bin"));
        assert!(is_safe_relative_uri("./cube.bin"));
        assert!(is_safe_relative_uri("a/../b.bin"));
        assert!(is_safe_relative_uri("my%20file.bin"));
    }

    #[test]
    fn rejects_absolute_scheme_protocol_relative_and_traversal_uris() {
        assert!(!is_safe_relative_uri("https://evil.example/x.bin"));
        assert!(!is_safe_relative_uri("http://evil.example/x.bin"));
        assert!(!is_safe_relative_uri("file:///etc/passwd"));
        assert!(!is_safe_relative_uri("blob:abc"));
        assert!(!is_safe_relative_uri("/etc/passwd"));
        assert!(!is_safe_relative_uri("//evil.example/x.bin"));
        assert!(!is_safe_relative_uri("../escape.bin"));
        assert!(!is_safe_relative_uri("a/../../escape.bin"));
    }

    #[test]
    fn rejects_encoded_and_backslash_traversal() {
        assert!(!is_safe_relative_uri("%2e%2e/escape.bin"));
        assert!(!is_safe_relative_uri("..%2fescape.bin"));
        assert!(!is_safe_relative_uri("%2e%2e%2fescape.bin"));
        assert!(!is_safe_relative_uri("textures/%2e%2e/%2e%2e/escape.bin"));
        assert!(!is_safe_relative_uri("%252e%252e/escape.bin"));
        assert!(!is_safe_relative_uri("..\\escape.bin"));
        assert!(!is_safe_relative_uri("textures\\..\\escape.bin"));
        assert!(!is_safe_relative_uri("%5c%5e.."));
        assert!(!is_safe_relative_uri("ab%2"));
        assert!(!is_safe_relative_uri("ab%zz"));
    }
}
