1use 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
23pub 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 _ => {} }
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
56pub 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, 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
95fn url_encode_token(s: &str) -> String {
98 s.replace('=', "%3D").replace('&', "%26")
99}
100
101#[derive(Debug, Clone)]
103pub enum CosmosAuth {
104 Bearer(String),
106 MasterKey(String),
108}
109
110#[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
120pub 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
165pub 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 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}