zc2 0.0.27

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Parses `zc://` model addresses apart from node/worker/host addresses.
//!
//! A published catalog model is addressed by UUID, in one of:
//!
//!   - `zc://{uuid}`
//!   - `zc://model-{uuid}`
//!   - bare `{uuid}`
//!
//! Anything else (node fingerprints, worker slots, host:port, hostnames) is not
//! a model address and returns `None`.

use uuid::Uuid;

/// Strip an optional `zc://` prefix, then an optional `model-` prefix, and
/// validate the remainder is a canonical UUID (8-4-4-4-12 hex). Returns the
/// lower-cased canonical UUID string on success, `None` otherwise.
pub fn parse_model_uri(s: &str) -> Option<String> {
    let s = s.strip_prefix("zc://").unwrap_or(s);
    let s = s.strip_prefix("model-").unwrap_or(s);
    let uuid = Uuid::parse_str(s).ok()?;
    Some(uuid.hyphenated().to_string())
}

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

    const UUID: &str = "ae5f3db4-437a-40d1-93ec-c8258315d69a";

    #[test]
    fn zc_prefixed_uuid() {
        assert_eq!(
            parse_model_uri(&format!("zc://{UUID}")),
            Some(UUID.to_string())
        );
    }

    #[test]
    fn zc_model_prefixed_uuid() {
        assert_eq!(
            parse_model_uri(&format!("zc://model-{UUID}")),
            Some(UUID.to_string())
        );
    }

    #[test]
    fn bare_uuid() {
        assert_eq!(parse_model_uri(UUID), Some(UUID.to_string()));
    }

    #[test]
    fn node_uri_rejected() {
        assert_eq!(parse_model_uri("zc://node-abc123"), None);
    }

    #[test]
    fn worker_uri_rejected() {
        assert_eq!(parse_model_uri("zc://worker-abc-0"), None);
    }

    #[test]
    fn host_uri_rejected() {
        assert_eq!(parse_model_uri("zc://my.zakuro-ai.com:9000"), None);
    }

    #[test]
    fn empty_string_rejected() {
        assert_eq!(parse_model_uri(""), None);
    }

    #[test]
    fn uppercase_normalizes_to_lowercase() {
        let upper = UUID.to_uppercase();
        assert_eq!(
            parse_model_uri(&format!("zc://{upper}")),
            Some(UUID.to_string())
        );
    }
}

/// A `zc://` model address in either of the two forms a person may type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelAddress {
    /// `zc://{uuid}` — already concrete, no lookup needed.
    Uuid(String),
    /// `zc://{owner}/{name}[@sha256:{digest}]` — needs resolving against the
    /// marketplace before anything can be dispatched.
    Named {
        owner: String,
        name: String,
        digest: Option<String>,
    },
}

/// Parse either address form.
///
/// The two-segment form is distinguished by the `/` alone: handles and artifact
/// names never contain `/` or `@`, and the single-segment forms this also
/// accepts (`zc://node-…`, `zc://worker-…`) never contain `/` either, so the
/// split is unambiguous without a general URI parser. Same reasoning, and the
/// same expression, as the marketplace's `_REF_RE`.
pub fn parse_model_address(s: &str) -> Option<ModelAddress> {
    if let Some(uuid) = parse_model_uri(s) {
        return Some(ModelAddress::Uuid(uuid));
    }
    let rest = s.strip_prefix("zc://")?;
    let (owner, tail) = rest.split_once('/')?;
    if owner.is_empty() || owner.contains('@') {
        return None;
    }
    let (name, digest) = match tail.split_once("@sha256:") {
        Some((n, d)) => {
            if d.len() != 64 || !d.bytes().all(|b| b.is_ascii_hexdigit()) {
                return None;
            }
            (n, Some(d.to_ascii_lowercase()))
        }
        None => (tail, None),
    };
    if name.is_empty() || name.contains('/') || name.contains('@') {
        return None;
    }
    Some(ModelAddress::Named {
        owner: owner.to_string(),
        name: name.to_string(),
        digest,
    })
}

/// Render an address back to its canonical `zc://` string, for sending to the
/// marketplace's `/resolve` route.
pub fn address_to_ref(addr: &ModelAddress) -> String {
    match addr {
        ModelAddress::Uuid(u) => format!("zc://{u}"),
        ModelAddress::Named {
            owner,
            name,
            digest,
        } => match digest {
            Some(d) => format!("zc://{owner}/{name}@sha256:{d}"),
            None => format!("zc://{owner}/{name}"),
        },
    }
}

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

    const UUID: &str = "ae5f3db4-437a-40d1-93ec-c8258315d69a";

    #[test]
    fn uuid_form_needs_no_lookup() {
        assert_eq!(
            parse_model_address(&format!("zc://{UUID}")),
            Some(ModelAddress::Uuid(UUID.to_string()))
        );
    }

    #[test]
    fn owner_name_form() {
        assert_eq!(
            parse_model_address("zc://alice/qwen3-4b"),
            Some(ModelAddress::Named {
                owner: "alice".into(),
                name: "qwen3-4b".into(),
                digest: None
            })
        );
    }

    #[test]
    fn owner_name_pinned() {
        let d = "a".repeat(64);
        assert_eq!(
            parse_model_address(&format!("zc://alice/qwen3-4b@sha256:{d}")),
            Some(ModelAddress::Named {
                owner: "alice".into(),
                name: "qwen3-4b".into(),
                digest: Some(d)
            })
        );
    }

    #[test]
    fn a_short_digest_is_rejected_rather_than_truncated() {
        // Accepting it would dispatch an unpinned job for a request that asked
        // to be pinned -- worse than refusing.
        assert_eq!(parse_model_address("zc://alice/m@sha256:abc"), None);
    }

    #[test]
    fn node_and_worker_addresses_are_still_not_models() {
        // They have no `/`, so they cannot be mistaken for the named form --
        // this pins that, since the broker relies on it.
        assert_eq!(parse_model_address("zc://node-abc123"), None);
        assert_eq!(parse_model_address("zc://worker-abc-0"), None);
    }

    #[test]
    fn empty_segments_are_rejected() {
        assert_eq!(parse_model_address("zc:///qwen"), None);
        assert_eq!(parse_model_address("zc://alice/"), None);
    }

    #[test]
    fn a_three_segment_path_is_not_an_address() {
        assert_eq!(parse_model_address("zc://alice/team/qwen"), None);
    }

    #[test]
    fn round_trips_through_a_ref_string() {
        for s in [
            &format!("zc://{UUID}"),
            "zc://alice/qwen3-4b",
            &format!("zc://alice/qwen3-4b@sha256:{}", "b".repeat(64)),
        ] {
            let addr = parse_model_address(s).expect(s);
            assert_eq!(address_to_ref(&addr), *s, "round trip for {s}");
        }
    }
}

/// Turn any address into the concrete model UUID everything downstream needs.
///
/// The UUID form returns immediately; only the named form costs a request. That
/// asymmetry is deliberate and load-bearing: the broker resolves models by UUID
/// on the dispatch path (`workers.resolve_model`) and has no marketplace
/// credentials there, so a name must be resolved by the CLIENT before a job is
/// ever offered. `zc://owner/name` is a convenience for people typing
/// commands, not a wire format the mesh understands.
///
/// The reply's `model_id` is what we return, not its `digest`: a digest names
/// bytes, while the broker's routing table is keyed on the model.
pub fn resolve_address(
    addr: &ModelAddress,
    api_url: &str,
    api_key: Option<&str>,
) -> Result<String, String> {
    if let ModelAddress::Uuid(u) = addr {
        return Ok(u.clone());
    }
    let reference = address_to_ref(addr);
    let url = format!(
        "{}/api/models/resolve?ref={}",
        api_url.trim_end_matches('/'),
        urlencode(&reference)
    );
    let agent = ureq::Agent::new_with_config(
        ureq::Agent::config_builder()
            .timeout_connect(Some(std::time::Duration::from_secs(10)))
            .timeout_recv_response(Some(std::time::Duration::from_secs(30)))
            // Read the status ourselves so a 404 becomes an explanation rather
            // than a transport error with the reason buried in it.
            .http_status_as_error(false)
            .build(),
    );
    let mut req = agent.get(&url);
    // A private model resolves only for its owner, so send the credential when
    // there is one. Without it the same reference is a 404, which is the same
    // answer a stranger gets -- deliberately indistinguishable.
    if let Some(key) = api_key.filter(|k| !k.is_empty()) {
        req = req.header("Authorization", &format!("Bearer {key}"));
    }
    let mut resp = req
        .call()
        .map_err(|e| format!("could not reach the marketplace at {api_url}: {e}"))?;
    let status = resp.status().as_u16();
    let body = resp.body_mut().read_to_string().unwrap_or_default();
    match status {
        200 => {}
        404 => {
            return Err(format!(
                "no such model: {reference}\n  \
                 (a private model resolves only for its owner -- check `zc init`)"
            ))
        }
        422 => return Err(format!("not a model address: {reference}")),
        s => return Err(format!("marketplace answered {s} for {reference}: {body}")),
    }
    // Parsed by field name, not positionally: this is a contract with
    // api/schemas.py::ModelRefView.
    let v: serde_json::Value =
        serde_json::from_str(&body).map_err(|e| format!("unreadable resolve reply: {e}"))?;
    v.get("model_id")
        .and_then(|m| m.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| format!("resolve reply carried no model_id (marketplace too old?): {body}"))
}

/// Percent-encode the few characters a `zc://` reference can contain that a
/// query string must not. Deliberately not a general-purpose encoder -- the
/// alphabet here is bounded by `parse_model_address`, which already rejects
/// anything with a space or a control character in it.
fn urlencode(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 8);
    for c in s.chars() {
        match c {
            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
            _ => out.push_str(&format!("%{:02X}", c as u32)),
        }
    }
    out
}

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

    #[test]
    fn a_uuid_address_needs_no_network() {
        // The api_url is deliberately unreachable: if this ever made a request
        // it would fail, which is the point.
        let uuid = "ae5f3db4-437a-40d1-93ec-c8258315d69a";
        assert_eq!(
            resolve_address(
                &ModelAddress::Uuid(uuid.into()),
                "http://127.0.0.1:1/nope",
                None
            ),
            Ok(uuid.to_string())
        );
    }

    #[test]
    fn encodes_the_reference_for_a_query_string() {
        // `:` and `/` must not travel raw in a query value.
        assert_eq!(
            urlencode("zc://alice/qwen3-4b"),
            "zc%3A%2F%2Falice%2Fqwen3-4b"
        );
    }

    #[test]
    fn leaves_unreserved_characters_alone() {
        assert_eq!(urlencode("a-z_0.9~"), "a-z_0.9~");
    }
}