Skip to main content

llmtxt_core/
native_signed_url.rs

1use crate::compute_signature_with_length;
2
3/// Parameters extracted from a verified signed URL.
4#[derive(Debug)]
5pub struct SignedUrlParams {
6    /// Document slug.
7    pub slug: String,
8    /// Agent that was granted access.
9    pub agent_id: String,
10    /// Conversation scope.
11    pub conversation_id: String,
12    /// Expiration timestamp in milliseconds.
13    pub expires_at: u64,
14}
15
16/// Errors that can occur during signed URL verification.
17#[derive(Debug)]
18pub enum VerifyError {
19    /// Required query parameters are missing or malformed.
20    MissingParams,
21    /// The URL has expired.
22    Expired,
23    /// The HMAC signature does not match.
24    InvalidSignature,
25}
26
27/// Input for generating a signed URL in native Rust consumers.
28pub struct SignedUrlBuildRequest<'a> {
29    /// Base API origin such as `https://api.example.com`.
30    pub base_url: &'a str,
31    /// Optional resource path prefix such as `attachments`.
32    pub path_prefix: &'a str,
33    /// Document slug.
34    pub slug: &'a str,
35    /// Agent that is granting access.
36    pub agent_id: &'a str,
37    /// Conversation scope.
38    pub conversation_id: &'a str,
39    /// Expiration timestamp in milliseconds.
40    pub expires_at: u64,
41    /// Signing secret.
42    pub secret: &'a str,
43    /// Signature length in hex characters.
44    pub sig_length: usize,
45}
46
47/// Generate a signed URL with an optional resource path prefix. Native Rust API only.
48///
49/// Use `path_prefix` such as `"attachments"` to produce
50/// `https://host/attachments/{slug}?agent=...`.
51///
52/// # Errors
53/// Returns an error string if `base_url` is invalid.
54pub fn generate_signed_url(request: &SignedUrlBuildRequest<'_>) -> Result<String, String> {
55    let mut url =
56        url::Url::parse(request.base_url).map_err(|e| format!("invalid base url: {e}"))?;
57    let normalized_prefix = request.path_prefix.trim_matches('/');
58    let path = if normalized_prefix.is_empty() {
59        format!("/{}", request.slug)
60    } else {
61        format!("/{normalized_prefix}/{}", request.slug)
62    };
63    url.set_path(&path);
64
65    let signature = compute_signature_with_length(
66        request.slug,
67        request.agent_id,
68        request.conversation_id,
69        request.expires_at as f64,
70        request.secret,
71        request.sig_length,
72    );
73
74    url.query_pairs_mut()
75        .append_pair("agent", request.agent_id)
76        .append_pair("conv", request.conversation_id)
77        .append_pair("exp", &request.expires_at.to_string())
78        .append_pair("sig", &signature);
79
80    Ok(url.into())
81}
82
83/// Verify a signed URL. Native Rust API only.
84///
85/// # Errors
86/// Returns `VerifyError` if the URL is invalid, expired, or has a bad signature.
87pub fn verify_signed_url(input: &str, secret: &str) -> Result<SignedUrlParams, VerifyError> {
88    let parsed = url::Url::parse(input).map_err(|_| VerifyError::MissingParams)?;
89
90    let slug = parsed
91        .path_segments()
92        .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
93        .map(str::to_string)
94        .ok_or(VerifyError::MissingParams)?;
95
96    let get_param = |name: &str| -> Result<String, VerifyError> {
97        parsed
98            .query_pairs()
99            .find(|(k, _)| k == name)
100            .map(|(_, v)| v.to_string())
101            .ok_or(VerifyError::MissingParams)
102    };
103
104    let agent = get_param("agent")?;
105    let conv = get_param("conv")?;
106    let exp_str = get_param("exp")?;
107    let sig = get_param("sig")?;
108
109    let expires_at: u64 = exp_str.parse().map_err(|_| VerifyError::MissingParams)?;
110
111    let now = std::time::SystemTime::now()
112        .duration_since(std::time::UNIX_EPOCH)
113        .map(|d| d.as_millis() as u64)
114        .unwrap_or(0);
115    if expires_at != 0 && now > expires_at {
116        return Err(VerifyError::Expired);
117    }
118
119    let expected =
120        compute_signature_with_length(&slug, &agent, &conv, expires_at as f64, secret, sig.len());
121    if sig != expected {
122        return Err(VerifyError::InvalidSignature);
123    }
124
125    Ok(SignedUrlParams {
126        slug,
127        agent_id: agent,
128        conversation_id: conv,
129        expires_at,
130    })
131}