Skip to main content

rigg_client/
cosmos.rs

1//! Azure Cosmos DB REST sampling.
2//!
3//! Phase 1 of the Cosmos KS wizard: library-only. Used by Phase 2's
4//! `rigg analyze cosmos` CLI command.
5
6use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
7use chrono::Utc;
8use hmac::{Hmac, Mac};
9use serde_json::Value;
10use sha2::Sha256;
11use thiserror::Error;
12
13type HmacSha256 = Hmac<Sha256>;
14
15#[derive(Debug, Error)]
16pub enum CosmosError {
17    #[error("Invalid Cosmos connection string: {0}")]
18    InvalidConnectionString(String),
19    #[error("Cosmos REST network error: {0}")]
20    Network(String),
21}
22
23/// Parse an Azure Cosmos DB connection string.
24///
25/// Expected format:
26/// `AccountEndpoint=https://acct.documents.azure.com:443/;AccountKey=<base64-key>;`
27///
28/// Returns `(endpoint, master_key)`. Trailing semicolon is optional.
29/// Additional fields (e.g., `DatabaseName=`) are ignored.
30pub fn parse_connection_string(s: &str) -> Result<(String, String), CosmosError> {
31    let mut endpoint: Option<String> = None;
32    let mut key: Option<String> = None;
33
34    for part in s.split(';') {
35        let part = part.trim();
36        if part.is_empty() {
37            continue;
38        }
39        let (k, v) = part.split_once('=').ok_or_else(|| {
40            CosmosError::InvalidConnectionString(format!("missing '=' in part '{part}'"))
41        })?;
42        match k {
43            "AccountEndpoint" => endpoint = Some(v.to_string()),
44            "AccountKey" => key = Some(v.to_string()),
45            _ => {} // ignore unknown keys
46        }
47    }
48
49    let endpoint = endpoint
50        .ok_or_else(|| CosmosError::InvalidConnectionString("missing AccountEndpoint".into()))?;
51    let key =
52        key.ok_or_else(|| CosmosError::InvalidConnectionString("missing AccountKey".into()))?;
53    Ok((endpoint, key))
54}
55
56/// Build a Cosmos REST `Authorization` header value using a master key.
57///
58/// The returned string is already URL-encoded (`%3D` for `=`, `%26` for `&`).
59///
60/// `verb`: HTTP method (e.g., `"POST"`).
61/// `resource_type`: resource type segment (e.g., `"docs"`, `"colls"`, `"dbs"`).
62/// `resource_link`: lowercase-sensitive resource path (e.g., `"dbs/mydb/colls/mycoll"`).
63/// `date`: RFC1123-formatted date string (the same one sent in the `x-ms-date` header).
64/// `master_key_b64`: base64-encoded master key from the connection string.
65///
66/// See https://learn.microsoft.com/en-us/rest/api/cosmos-db/access-control-on-cosmosdb-resources
67pub fn build_master_key_authorization_token(
68    verb: &str,
69    resource_type: &str,
70    resource_link: &str,
71    date: &str,
72    master_key_b64: &str,
73) -> Result<String, CosmosError> {
74    let key_bytes = B64.decode(master_key_b64).map_err(|e| {
75        CosmosError::InvalidConnectionString(format!("invalid base64 master key: {e}"))
76    })?;
77
78    let string_to_sign = format!(
79        "{}\n{}\n{}\n{}\n\n",
80        verb.to_lowercase(),
81        resource_type.to_lowercase(),
82        resource_link, // case-sensitive — DO NOT lowercase
83        date.to_lowercase(),
84    );
85
86    let mut mac = HmacSha256::new_from_slice(&key_bytes)
87        .map_err(|e| CosmosError::InvalidConnectionString(format!("hmac key error: {e}")))?;
88    mac.update(string_to_sign.as_bytes());
89    let signature_b64 = B64.encode(mac.finalize().into_bytes());
90
91    let token = format!("type=master&ver=1.0&sig={signature_b64}");
92    Ok(url_encode_token(&token))
93}
94
95/// URL-encode the small set of characters that appear in a Cosmos auth token
96/// (`=` and `&`). Cosmos does not require general percent-encoding here.
97fn url_encode_token(s: &str) -> String {
98    s.replace('=', "%3D").replace('&', "%26")
99}
100
101/// Authentication mode for Cosmos REST calls.
102#[derive(Debug, Clone)]
103pub enum CosmosAuth {
104    /// AAD bearer token (acquired via `AzCliAuth::for_cosmos()`).
105    Bearer(String),
106    /// Cosmos master key (base64-encoded). Use with care; secret material.
107    MasterKey(String),
108}
109
110/// A built (but not sent) HTTP request to Cosmos. Used by `sample_documents`
111/// and exposed for unit testing the construction logic without hitting the network.
112#[derive(Debug)]
113pub struct CosmosRequest {
114    pub method: &'static str,
115    pub url: String,
116    pub headers: Vec<(&'static str, String)>,
117    pub body: String,
118}
119
120/// Build a Cosmos `query documents` REST request. Pure: no network I/O.
121pub fn build_query_request(
122    endpoint: &str,
123    database: &str,
124    container: &str,
125    auth: &CosmosAuth,
126    sample_size: u32,
127    rfc1123_date: &str,
128) -> Result<CosmosRequest, CosmosError> {
129    let endpoint = endpoint.trim_end_matches('/');
130    let url = format!("{endpoint}/dbs/{database}/colls/{container}/docs");
131
132    let resource_link = format!("dbs/{database}/colls/{container}");
133    let auth_value = match auth {
134        CosmosAuth::Bearer(token) => format!("Bearer {token}"),
135        CosmosAuth::MasterKey(key) => {
136            build_master_key_authorization_token("POST", "docs", &resource_link, rfc1123_date, key)?
137        }
138    };
139
140    let body = format!(
141        r#"{{"query":"SELECT TOP @n * FROM c","parameters":[{{"name":"@n","value":{sample_size}}}]}}"#
142    );
143
144    let headers = vec![
145        ("Authorization", auth_value),
146        ("x-ms-version", "2018-12-31".to_string()),
147        ("x-ms-date", rfc1123_date.to_string()),
148        ("x-ms-documentdb-isquery", "True".to_string()),
149        (
150            "x-ms-documentdb-query-enablecrosspartition",
151            "True".to_string(),
152        ),
153        ("Content-Type", "application/query+json".to_string()),
154        ("Accept", "application/json".to_string()),
155    ];
156
157    Ok(CosmosRequest {
158        method: "POST",
159        url,
160        headers,
161        body,
162    })
163}
164
165/// Sample up to `sample_size` documents from a Cosmos container.
166///
167/// AAD via `CosmosAuth::Bearer` is preferred; `CosmosAuth::MasterKey` is the
168/// connection-string fallback path. Caller is responsible for choosing.
169pub async fn sample_documents(
170    endpoint: &str,
171    database: &str,
172    container: &str,
173    auth: &CosmosAuth,
174    sample_size: u32,
175) -> Result<Vec<Value>, CosmosError> {
176    // RFC1123 in GMT, e.g., "Fri, 10 May 2026 12:00:00 GMT".
177    let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
178    let req = build_query_request(endpoint, database, container, auth, sample_size, &date)?;
179
180    let client = reqwest::Client::new();
181    let mut request_builder = client
182        .request(reqwest::Method::POST, &req.url)
183        .body(req.body);
184    for (k, v) in &req.headers {
185        request_builder = request_builder.header(*k, v);
186    }
187
188    let resp = request_builder
189        .send()
190        .await
191        .map_err(|e| CosmosError::Network(format!("request failed: {e}")))?;
192    let status = resp.status();
193    let body = resp
194        .text()
195        .await
196        .map_err(|e| CosmosError::Network(format!("read body: {e}")))?;
197
198    if !status.is_success() {
199        return Err(CosmosError::Network(format!(
200            "Cosmos returned {status}: {body}"
201        )));
202    }
203
204    let parsed: Value = serde_json::from_str(&body)
205        .map_err(|e| CosmosError::Network(format!("invalid JSON response: {e}")))?;
206
207    let docs = parsed
208        .get("Documents")
209        .and_then(|d| d.as_array())
210        .cloned()
211        .unwrap_or_default();
212
213    Ok(docs)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn parse_connection_string_basic() {
222        let s = "AccountEndpoint=https://acct.documents.azure.com:443/;AccountKey=ZmFrZWtleQ==;";
223        let (endpoint, key) = parse_connection_string(s).unwrap();
224        assert_eq!(endpoint, "https://acct.documents.azure.com:443/");
225        assert_eq!(key, "ZmFrZWtleQ==");
226    }
227
228    #[test]
229    fn parse_connection_string_no_trailing_semicolon() {
230        let s = "AccountEndpoint=https://x.documents.azure.com:443/;AccountKey=AAAA";
231        let (endpoint, key) = parse_connection_string(s).unwrap();
232        assert_eq!(endpoint, "https://x.documents.azure.com:443/");
233        assert_eq!(key, "AAAA");
234    }
235
236    #[test]
237    fn parse_connection_string_ignores_extra_fields() {
238        let s =
239            "AccountEndpoint=https://x.documents.azure.com:443/;AccountKey=AAAA;DatabaseName=mydb;";
240        let (endpoint, key) = parse_connection_string(s).unwrap();
241        assert_eq!(endpoint, "https://x.documents.azure.com:443/");
242        assert_eq!(key, "AAAA");
243    }
244
245    #[test]
246    fn parse_connection_string_missing_endpoint() {
247        let s = "AccountKey=AAAA;";
248        let err = parse_connection_string(s).unwrap_err();
249        assert!(format!("{err}").contains("AccountEndpoint"));
250    }
251
252    #[test]
253    fn parse_connection_string_missing_key() {
254        let s = "AccountEndpoint=https://x.documents.azure.com:443/;";
255        let err = parse_connection_string(s).unwrap_err();
256        assert!(format!("{err}").contains("AccountKey"));
257    }
258
259    #[test]
260    fn build_master_key_authorization_token_is_deterministic() {
261        let master_key_b64 = "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Ng==";
262        let date = "Fri, 10 May 2026 12:00:00 GMT";
263        let token = build_master_key_authorization_token(
264            "POST",
265            "docs",
266            "dbs/mydb/colls/mycoll",
267            date,
268            master_key_b64,
269        )
270        .unwrap();
271
272        assert!(token.starts_with("type%3Dmaster%26ver%3D1.0%26sig%3D"));
273
274        let token2 = build_master_key_authorization_token(
275            "POST",
276            "docs",
277            "dbs/mydb/colls/mycoll",
278            date,
279            master_key_b64,
280        )
281        .unwrap();
282        assert_eq!(token, token2);
283
284        let token3 = build_master_key_authorization_token(
285            "GET",
286            "docs",
287            "dbs/mydb/colls/mycoll",
288            date,
289            master_key_b64,
290        )
291        .unwrap();
292        assert_ne!(token, token3);
293    }
294
295    #[test]
296    fn build_master_key_authorization_token_rejects_invalid_base64() {
297        let err = build_master_key_authorization_token(
298            "POST",
299            "docs",
300            "dbs/mydb/colls/mycoll",
301            "Fri, 10 May 2026 12:00:00 GMT",
302            "!!!not-base64!!!",
303        )
304        .unwrap_err();
305        assert!(format!("{err}").contains("base64"));
306    }
307
308    #[test]
309    fn build_query_request_master_key_sets_required_headers() {
310        let req = build_query_request(
311            "https://acct.documents.azure.com:443/",
312            "mydb",
313            "mycoll",
314            &CosmosAuth::MasterKey("MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Ng==".into()),
315            20,
316            "Fri, 10 May 2026 12:00:00 GMT",
317        )
318        .unwrap();
319
320        assert_eq!(
321            req.url,
322            "https://acct.documents.azure.com:443/dbs/mydb/colls/mycoll/docs"
323        );
324        assert_eq!(req.method, "POST");
325        assert!(req.headers.iter().any(|(k, _)| *k == "x-ms-version"));
326        assert!(
327            req.headers
328                .iter()
329                .any(|(k, _)| *k == "x-ms-documentdb-isquery")
330        );
331        assert!(
332            req.headers
333                .iter()
334                .any(|(k, _)| *k == "x-ms-documentdb-query-enablecrosspartition")
335        );
336        assert!(req.headers.iter().any(|(k, _)| *k == "x-ms-date"));
337        assert!(
338            req.headers
339                .iter()
340                .any(|(k, v)| *k == "Authorization" && v.starts_with("type%3Dmaster"))
341        );
342        assert_eq!(
343            req.body,
344            r#"{"query":"SELECT TOP @n * FROM c","parameters":[{"name":"@n","value":20}]}"#
345        );
346    }
347
348    #[test]
349    fn build_query_request_bearer_uses_aad_header() {
350        let req = build_query_request(
351            "https://acct.documents.azure.com:443/",
352            "mydb",
353            "mycoll",
354            &CosmosAuth::Bearer("eyFAKE.TOKEN".into()),
355            5,
356            "Fri, 10 May 2026 12:00:00 GMT",
357        )
358        .unwrap();
359
360        let auth_value = req
361            .headers
362            .iter()
363            .find(|(k, _)| *k == "Authorization")
364            .map(|(_, v)| v.as_str())
365            .unwrap();
366        assert_eq!(auth_value, "Bearer eyFAKE.TOKEN");
367        assert!(req.body.contains("\"value\":5"));
368    }
369
370    #[test]
371    fn build_query_request_handles_endpoint_without_trailing_slash() {
372        let req = build_query_request(
373            "https://acct.documents.azure.com:443",
374            "mydb",
375            "mycoll",
376            &CosmosAuth::Bearer("t".into()),
377            1,
378            "Fri, 10 May 2026 12:00:00 GMT",
379        )
380        .unwrap();
381        assert_eq!(
382            req.url,
383            "https://acct.documents.azure.com:443/dbs/mydb/colls/mycoll/docs"
384        );
385    }
386}