1use std::collections::HashMap;
4use std::io::Read as _;
5use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
6use std::time::Duration;
7
8use chrono::{DateTime, TimeDelta, Utc};
9use serde_json::{json, Value};
10
11use crate::utils::secret::Secret;
12
13use super::error::{Error, Result};
14use super::row::{Column, Row};
15use super::transport::{request_id, Transport};
16
17const POLL_INTERVAL: Duration = Duration::from_secs(3);
19
20pub(crate) struct LoginTokens {
22 pub session_token: Secret,
24 pub master_token: Secret,
26 pub session_validity_secs: i64,
28 pub master_validity_secs: i64,
30}
31
32#[derive(Clone, Debug)]
34struct Tokens {
35 session_token: Secret,
36 master_token: Secret,
37 session_expires_at: DateTime<Utc>,
38 master_expires_at: DateTime<Utc>,
39}
40
41pub struct SnowflakeSession {
48 transport: Arc<Transport>,
49 tokens: StdMutex<Tokens>,
50 query_timeout: Duration,
52}
53
54impl SnowflakeSession {
55 pub(crate) fn new(
57 transport: Arc<Transport>,
58 tokens: LoginTokens,
59 query_timeout: Duration,
60 ) -> Self {
61 let now = Utc::now();
62 Self {
63 transport,
64 tokens: StdMutex::new(Tokens {
65 session_token: tokens.session_token,
66 master_token: tokens.master_token,
67 session_expires_at: now + TimeDelta::seconds(tokens.session_validity_secs),
68 master_expires_at: now + TimeDelta::seconds(tokens.master_validity_secs),
69 }),
70 query_timeout,
71 }
72 }
73
74 #[must_use]
76 pub fn session_expiring_within(&self, within: TimeDelta) -> bool {
77 Utc::now() + within >= self.lock().session_expires_at
78 }
79
80 #[must_use]
83 pub fn master_expires_at(&self) -> DateTime<Utc> {
84 self.lock().master_expires_at
85 }
86
87 pub async fn query(&self, sql: &str) -> Result<Vec<Row>> {
94 let token = self.lock().session_token.clone();
95 let rid = request_id();
96 let body = json!({ "sqlText": sql });
97 let data = self
101 .transport
102 .post_statement(
103 &[("requestId", rid.as_str())],
104 &body,
105 token.expose_secret(),
106 self.query_timeout,
107 POLL_INTERVAL,
108 )
109 .await?;
110
111 let (columns, index, mut rows) = parse_result(&data)?;
112 if let Some(chunks) = data.get("chunks").and_then(Value::as_array) {
115 let headers = parse_chunk_headers(&data);
116 for chunk in chunks {
117 if let Some(url) = chunk.get("url").and_then(Value::as_str) {
118 let bytes = self.transport.get_bytes(url, &headers).await?;
119 rows.extend(decode_chunk_rows(&bytes, &columns, &index)?);
120 }
121 }
122 }
123 Ok(rows)
124 }
125
126 pub async fn renew(&self) -> Result<()> {
133 let (old_session, master) = {
134 let tokens = self.lock();
135 (tokens.session_token.clone(), tokens.master_token.clone())
136 };
137 let rid = request_id();
138 let body =
139 json!({ "oldSessionToken": old_session.expose_secret(), "requestType": "RENEW" });
140 let data = self
141 .transport
142 .post(
143 "session/token-request",
144 &[("requestId", rid.as_str())],
145 &body,
146 Some(master.expose_secret()),
147 )
148 .await?;
149
150 let new_session = data
151 .get("sessionToken")
152 .or_else(|| data.get("token"))
153 .and_then(Value::as_str)
154 .ok_or_else(|| Error::Protocol("token-request returned no sessionToken".into()))?
155 .to_string();
156 let st_validity = data
157 .get("validityInSecondsST")
158 .or_else(|| data.get("validityInSeconds"))
159 .and_then(Value::as_i64)
160 .unwrap_or(3600);
161
162 let mut tokens = self.lock();
163 tokens.session_token = new_session.into();
164 tokens.session_expires_at = Utc::now() + TimeDelta::seconds(st_validity);
165 if let Some(master_token) = data.get("masterToken").and_then(Value::as_str) {
166 tokens.master_token = master_token.into();
167 }
168 if let Some(mt_validity) = data.get("validityInSecondsMT").and_then(Value::as_i64) {
169 tokens.master_expires_at = Utc::now() + TimeDelta::seconds(mt_validity);
170 }
171 Ok(())
172 }
173
174 pub async fn heartbeat(&self) -> Result<()> {
181 let token = self.lock().session_token.clone();
182 let rid = request_id();
183 self.transport
184 .post(
185 "session/heartbeat",
186 &[("requestId", rid.as_str())],
187 &json!({}),
188 Some(token.expose_secret()),
189 )
190 .await?;
191 Ok(())
194 }
195
196 pub async fn close(&self) -> Result<()> {
202 let token = self.lock().session_token.clone();
203 let rid = request_id();
204 self.transport
205 .post(
206 "session",
207 &[("delete", "true"), ("requestId", rid.as_str())],
208 &json!({}),
209 Some(token.expose_secret()),
210 )
211 .await?;
212 Ok(())
213 }
214
215 fn lock(&self) -> MutexGuard<'_, Tokens> {
216 self.tokens
217 .lock()
218 .unwrap_or_else(std::sync::PoisonError::into_inner)
219 }
220}
221
222type ParsedResult = (Arc<Vec<Column>>, Arc<HashMap<String, usize>>, Vec<Row>);
224
225fn parse_result(data: &Value) -> Result<ParsedResult> {
228 if let Some(format) = data.get("queryResultFormat").and_then(Value::as_str) {
229 if !format.eq_ignore_ascii_case("json") {
230 return Err(Error::Unsupported(format!(
231 "result format '{format}' (only JSON is supported)"
232 )));
233 }
234 }
235
236 let rowtype = data
237 .get("rowtype")
238 .and_then(Value::as_array)
239 .ok_or_else(|| Error::Protocol("query response missing rowtype".into()))?;
240 let columns: Arc<Vec<Column>> = Arc::new(rowtype.iter().map(parse_column).collect());
241
242 let mut index = HashMap::new();
243 for (i, col) in columns.iter().enumerate() {
244 index.entry(col.name.to_ascii_uppercase()).or_insert(i);
245 }
246 let index = Arc::new(index);
247
248 let rows = data
249 .get("rowset")
250 .and_then(Value::as_array)
251 .map(|rows| {
252 rows.iter()
253 .map(|row| row_from_cells(row, &columns, &index))
254 .collect()
255 })
256 .unwrap_or_default();
257 Ok((columns, index, rows))
258}
259
260fn row_from_cells(
262 row: &Value,
263 columns: &Arc<Vec<Column>>,
264 index: &Arc<HashMap<String, usize>>,
265) -> Row {
266 let cells = row
267 .as_array()
268 .map(|cells| cells.iter().map(cell_to_string).collect())
269 .unwrap_or_default();
270 Row::new(cells, Arc::clone(columns), Arc::clone(index))
271}
272
273fn parse_chunk_headers(data: &Value) -> Vec<(String, String)> {
275 data.get("chunkHeaders")
276 .and_then(Value::as_object)
277 .map(|headers| {
278 headers
279 .iter()
280 .filter_map(|(k, v)| v.as_str().map(|v| (k.clone(), v.to_string())))
281 .collect()
282 })
283 .unwrap_or_default()
284}
285
286fn decode_chunk_rows(
288 bytes: &[u8],
289 columns: &Arc<Vec<Column>>,
290 index: &Arc<HashMap<String, usize>>,
291) -> Result<Vec<Row>> {
292 let json = gunzip_if_needed(bytes)?;
293 let mut framed = Vec::with_capacity(json.len() + 2);
297 framed.push(b'[');
298 framed.extend_from_slice(&json);
299 framed.push(b']');
300 let rows: Vec<Value> = serde_json::from_slice(&framed)
301 .map_err(|e| Error::Protocol(format!("invalid result chunk JSON: {e}")))?;
302 Ok(rows
303 .iter()
304 .map(|row| row_from_cells(row, columns, index))
305 .collect())
306}
307
308fn gunzip_if_needed(bytes: &[u8]) -> Result<Vec<u8>> {
310 if bytes.starts_with(&[0x1f, 0x8b]) {
311 let mut decoder = flate2::read::GzDecoder::new(bytes);
312 let mut out = Vec::new();
313 decoder.read_to_end(&mut out).map_err(Error::Io)?;
314 Ok(out)
315 } else {
316 Ok(bytes.to_vec())
317 }
318}
319
320fn parse_column(value: &Value) -> Column {
322 Column {
323 name: value
324 .get("name")
325 .and_then(Value::as_str)
326 .unwrap_or_default()
327 .to_string(),
328 ty: value
329 .get("type")
330 .and_then(Value::as_str)
331 .unwrap_or_default()
332 .to_ascii_lowercase(),
333 nullable: value
334 .get("nullable")
335 .and_then(Value::as_bool)
336 .unwrap_or(true),
337 length: value.get("length").and_then(Value::as_i64),
338 precision: value.get("precision").and_then(Value::as_i64),
339 scale: value.get("scale").and_then(Value::as_i64),
340 }
341}
342
343fn cell_to_string(value: &Value) -> Option<String> {
345 match value {
346 Value::Null => None,
347 Value::String(s) => Some(s.clone()),
348 other => Some(other.to_string()),
349 }
350}
351
352#[cfg(test)]
353#[allow(clippy::unwrap_used, clippy::expect_used)]
354mod tests {
355 use super::*;
356 use crate::snowflake::client::row::rows_to_payload;
357 use url::Url;
358 use wiremock::matchers::{method, path};
359 use wiremock::{Mock, MockServer, ResponseTemplate};
360
361 fn live_session(server: &MockServer, session_validity_secs: i64) -> SnowflakeSession {
364 let base = Url::parse(&server.uri()).unwrap().join("/").unwrap();
365 let transport = Arc::new(Transport::with_base_url(base, Duration::from_secs(5)).unwrap());
366 SnowflakeSession::new(
367 transport,
368 LoginTokens {
369 session_token: "sess".into(),
370 master_token: "mast".into(),
371 session_validity_secs,
372 master_validity_secs: 14_400,
373 },
374 Duration::from_secs(5),
375 )
376 }
377
378 #[test]
379 fn token_accessors_reflect_validities() {
380 let base = Url::parse("https://acct.example/").unwrap();
382 let transport = Arc::new(Transport::with_base_url(base, Duration::from_secs(5)).unwrap());
383 let session = SnowflakeSession::new(
384 transport,
385 LoginTokens {
386 session_token: "s".into(),
387 master_token: "m".into(),
388 session_validity_secs: 3600,
389 master_validity_secs: 14_400,
390 },
391 Duration::from_secs(5),
392 );
393 assert!(session.master_expires_at() > Utc::now());
394 assert!(!session.session_expiring_within(TimeDelta::seconds(60)));
395 assert!(session.session_expiring_within(TimeDelta::seconds(7200)));
396 }
397
398 #[test]
399 fn tokens_debug_redacts_secrets() {
400 let tokens = Tokens {
401 session_token: "sekret-session-token".into(),
402 master_token: "sekret-master-token".into(),
403 session_expires_at: Utc::now(),
404 master_expires_at: Utc::now(),
405 };
406 let debug = format!("{tokens:?}");
408 assert!(
409 !debug.contains("sekret-session-token"),
410 "leaked session token: {debug}"
411 );
412 assert!(
413 !debug.contains("sekret-master-token"),
414 "leaked master token: {debug}"
415 );
416 assert!(debug.contains("session_token: <redacted>"));
417 assert!(debug.contains("master_token: <redacted>"));
418 }
419
420 #[tokio::test]
421 async fn query_returns_inline_rows() {
422 let server = MockServer::start().await;
423 Mock::given(method("POST"))
424 .and(path("/queries/v1/query-request"))
425 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
426 "success": true,
427 "data": {
428 "queryResultFormat": "json",
429 "rowtype": [{ "name": "N", "type": "fixed", "precision": 38, "scale": 0 }],
430 "rowset": [["1"], ["2"]],
431 }
432 })))
433 .mount(&server)
434 .await;
435
436 let rows = live_session(&server, 3600).query("SELECT 1").await.unwrap();
437 assert_eq!(rows.len(), 2);
438 }
439
440 #[tokio::test]
441 async fn query_downloads_and_appends_external_chunks() {
442 let server = MockServer::start().await;
443 let chunk_url = format!("{}/chunk0", server.uri());
444 Mock::given(method("POST"))
445 .and(path("/queries/v1/query-request"))
446 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
447 "success": true,
448 "data": {
449 "queryResultFormat": "json",
450 "rowtype": [{ "name": "N", "type": "text" }],
451 "rowset": [["a"]],
452 "chunks": [{ "url": chunk_url }],
453 }
454 })))
455 .mount(&server)
456 .await;
457 Mock::given(method("GET"))
459 .and(path("/chunk0"))
460 .respond_with(ResponseTemplate::new(200).set_body_bytes(br#"["b"],["c"]"#.to_vec()))
461 .mount(&server)
462 .await;
463
464 let rows = live_session(&server, 3600).query("SELECT 1").await.unwrap();
465 assert_eq!(rows.len(), 3, "1 inline + 2 chunked");
466 }
467
468 #[tokio::test]
469 async fn query_surfaces_session_expired() {
470 let server = MockServer::start().await;
471 Mock::given(method("POST"))
472 .and(path("/queries/v1/query-request"))
473 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
474 "success": false, "code": "390112", "message": "expired", "data": {}
475 })))
476 .mount(&server)
477 .await;
478
479 let err = live_session(&server, 3600)
480 .query("SELECT 1")
481 .await
482 .unwrap_err();
483 assert!(matches!(err, Error::SessionExpired));
484 }
485
486 #[tokio::test]
487 async fn renew_swaps_in_a_fresh_session_token() {
488 let server = MockServer::start().await;
489 Mock::given(method("POST"))
490 .and(path("/session/token-request"))
491 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
492 "success": true,
493 "data": { "sessionToken": "new-sess", "validityInSecondsST": 3600 }
494 })))
495 .mount(&server)
496 .await;
497
498 let session = live_session(&server, 1);
500 assert!(session.session_expiring_within(TimeDelta::seconds(120)));
501 session.renew().await.unwrap();
502 assert!(!session.session_expiring_within(TimeDelta::seconds(120)));
503 }
504
505 #[tokio::test]
506 async fn renew_errors_when_response_lacks_a_token() {
507 let server = MockServer::start().await;
508 Mock::given(method("POST"))
509 .and(path("/session/token-request"))
510 .respond_with(
511 ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
512 )
513 .mount(&server)
514 .await;
515
516 let err = live_session(&server, 3600).renew().await.unwrap_err();
517 assert!(matches!(err, Error::Protocol(_)));
518 }
519
520 #[tokio::test]
521 async fn heartbeat_and_close_post_successfully() {
522 let server = MockServer::start().await;
523 Mock::given(method("POST"))
524 .and(path("/session/heartbeat"))
525 .respond_with(
526 ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
527 )
528 .mount(&server)
529 .await;
530 Mock::given(method("POST"))
531 .and(path("/session"))
532 .respond_with(
533 ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
534 )
535 .mount(&server)
536 .await;
537
538 let session = live_session(&server, 3600);
539 session.heartbeat().await.unwrap();
540 session.close().await.unwrap();
541 }
542
543 #[test]
544 fn parse_result_builds_columns_and_inline_rows() {
545 let data = json!({
546 "queryResultFormat": "json",
547 "rowtype": [
548 { "name": "ID", "type": "fixed", "nullable": false, "precision": 38, "scale": 0 },
549 { "name": "NAME", "type": "text", "nullable": true, "length": 16_777_216 },
550 ],
551 "rowset": [["1", "alice"], ["2", null]],
552 });
553 let (_columns, _index, rows) = parse_result(&data).unwrap();
554 assert_eq!(rows.len(), 2);
555 let payload = rows_to_payload(&rows);
556 assert_eq!(payload["columns"][0]["name"], "ID");
557 assert_eq!(payload["columns"][0]["type"], "fixed(38,0)");
558 assert_eq!(payload["rows"][0]["ID"], 1);
559 assert_eq!(payload["rows"][0]["NAME"], "alice");
560 assert_eq!(payload["rows"][1]["NAME"], Value::Null);
561 }
562
563 #[test]
564 fn parse_result_rejects_arrow_but_allows_chunked() {
565 let arrow = json!({ "queryResultFormat": "arrow", "rowtype": [], "rowset": [] });
566 assert!(matches!(parse_result(&arrow), Err(Error::Unsupported(_))));
567 let chunked = json!({
570 "queryResultFormat": "json",
571 "rowtype": [{ "name": "C", "type": "text" }],
572 "rowset": [["a"]],
573 "chunks": [{ "url": "https://example/chunk0" }],
574 });
575 let (_c, _i, rows) = parse_result(&chunked).unwrap();
576 assert_eq!(rows.len(), 1);
577 }
578
579 #[test]
580 fn parse_result_requires_rowtype() {
581 assert!(matches!(
582 parse_result(&json!({ "rowset": [] })),
583 Err(Error::Protocol(_))
584 ));
585 }
586
587 #[test]
588 fn decode_chunk_rows_handles_gzip_and_plain_json() {
589 use std::io::Write as _;
590 let (columns, index, _rows) = parse_result(&json!({
591 "queryResultFormat": "json",
592 "rowtype": [
593 { "name": "ID", "type": "fixed", "precision": 38, "scale": 0 },
594 { "name": "NAME", "type": "text" },
595 ],
596 "rowset": [],
597 }))
598 .unwrap();
599
600 let chunk_json = br#"["3","carol"],["4",null]"#;
603
604 let rows = decode_chunk_rows(chunk_json, &columns, &index).unwrap();
606 assert_eq!(rows.len(), 2);
607 assert_eq!(rows[0].to_json_object().get("NAME"), Some(&json!("carol")));
608 assert_eq!(rows[1].to_json_object().get("NAME"), Some(&Value::Null));
609
610 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
612 encoder.write_all(chunk_json).unwrap();
613 let gzipped = encoder.finish().unwrap();
614 let rows = decode_chunk_rows(&gzipped, &columns, &index).unwrap();
615 assert_eq!(rows.len(), 2);
616 assert_eq!(rows[0].to_json_object().get("ID"), Some(&json!(3)));
617 }
618
619 #[test]
620 fn parse_chunk_headers_reads_string_pairs() {
621 let data = json!({ "chunkHeaders": { "x-amz-server-side-encryption": "AES256" } });
622 let headers = parse_chunk_headers(&data);
623 assert_eq!(
624 headers,
625 vec![(
626 "x-amz-server-side-encryption".to_string(),
627 "AES256".to_string()
628 )]
629 );
630 }
631}