1pub mod client;
19pub mod session;
20
21use std::sync::{Mutex as StdMutex, MutexGuard};
22use std::time::Duration;
23
24use anyhow::{anyhow, bail, Context, Result};
25use chrono::TimeDelta;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use tokio::task::JoinHandle;
29use tokio_util::sync::CancellationToken;
30
31use crate::utils::env::EnvSource;
32use crate::utils::secret::Secret;
33use crate::utils::settings::Settings;
34use client::{
35 AuthMethod, BrowserConfig, Error as ClientError, KeyPairConfig, Row, SnowflakeClient,
36 SnowflakeClientConfig, SnowflakeSession,
37};
38use session::{PoolRegistry, QueryContext, SessionInfo, SessionKey};
39
40const ENV_ACCOUNT: &str = "SNOWFLAKE_ACCOUNT";
42const ENV_USER: &str = "SNOWFLAKE_USER";
44const ENV_HOST: &str = "SNOWFLAKE_HOST";
48const ENV_WAREHOUSE: &str = "SNOWFLAKE_WAREHOUSE";
50const ENV_ROLE: &str = "SNOWFLAKE_ROLE";
52const ENV_DATABASE: &str = "SNOWFLAKE_DATABASE";
54const ENV_SCHEMA: &str = "SNOWFLAKE_SCHEMA";
56const ENV_POOL_SIZE: &str = "SNOWFLAKE_POOL_SIZE";
58const ENV_HTTP_TIMEOUT: &str = "SNOWFLAKE_HTTP_TIMEOUT";
60const ENV_AUTH_TIMEOUT: &str = "SNOWFLAKE_AUTH_TIMEOUT";
62const ENV_QUERY_TIMEOUT: &str = "SNOWFLAKE_QUERY_TIMEOUT";
64const ENV_HEARTBEAT_INTERVAL: &str = "SNOWFLAKE_HEARTBEAT_INTERVAL";
67const ENV_AUTHENTICATOR: &str = "SNOWFLAKE_AUTHENTICATOR";
70const ENV_TOKEN: &str = "SNOWFLAKE_TOKEN";
72const ENV_PRIVATE_KEY_PATH: &str = "SNOWFLAKE_PRIVATE_KEY_PATH";
74const ENV_PRIVATE_KEY: &str = "SNOWFLAKE_PRIVATE_KEY";
77const ENV_PRIVATE_KEY_PASSPHRASE: &str = "SNOWFLAKE_PRIVATE_KEY_PASSPHRASE";
80
81const DEFAULT_POOL_SIZE: usize = 4;
84const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(150);
87const SQL_PREVIEW_MAX: usize = 60;
89const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(900);
94const HEARTBEAT_RENEW_MARGIN_SECS: i64 = 60;
97
98#[derive(Clone, Debug)]
104pub struct SnowflakeEngineConfig {
105 pub default_account: Option<String>,
107 pub default_user: Option<String>,
109 pub default_host: Option<String>,
113 pub default_warehouse: Option<String>,
115 pub default_role: Option<String>,
117 pub default_database: Option<String>,
119 pub default_schema: Option<String>,
121 pub auth: AuthMethod,
125 pub pool_size: usize,
127 pub http_timeout: Duration,
129 pub auth_timeout: Duration,
132 pub query_timeout: Duration,
134 pub heartbeat_interval: Duration,
137}
138
139impl Default for SnowflakeEngineConfig {
140 fn default() -> Self {
141 Self {
142 default_account: None,
143 default_user: None,
144 default_host: None,
145 default_warehouse: None,
146 default_role: None,
147 default_database: None,
148 default_schema: None,
149 auth: AuthMethod::ExternalBrowser(BrowserConfig::default()),
150 pool_size: DEFAULT_POOL_SIZE,
151 http_timeout: client::config::DEFAULT_HTTP_TIMEOUT,
152 auth_timeout: DEFAULT_AUTH_TIMEOUT,
153 query_timeout: client::config::DEFAULT_QUERY_TIMEOUT,
154 heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
155 }
156 }
157}
158
159impl SnowflakeEngineConfig {
160 pub fn from_env_and_settings() -> Result<Self> {
169 let settings = Settings::load().unwrap_or_default();
170 let pool_size = settings
171 .get_env_var(ENV_POOL_SIZE)
172 .and_then(|s| s.trim().parse::<usize>().ok())
173 .filter(|&n| n >= 1)
174 .unwrap_or(DEFAULT_POOL_SIZE);
175 let secs = |key: &str| {
176 settings
177 .get_env_var(key)
178 .and_then(|s| s.trim().parse::<u64>().ok())
179 .filter(|&n| n >= 1)
180 .map(Duration::from_secs)
181 };
182 let private_key_pem = match settings.get_env_var(ENV_PRIVATE_KEY_PATH) {
183 Some(path) => Some(
184 std::fs::read_to_string(&path)
185 .with_context(|| format!("reading {ENV_PRIVATE_KEY_PATH} '{path}'"))?,
186 ),
187 None => settings.get_env_var(ENV_PRIVATE_KEY),
188 };
189 let auth = resolve_auth_method(
190 settings.get_env_var(ENV_AUTHENTICATOR).as_deref(),
191 settings.get_env_var(ENV_TOKEN),
192 private_key_pem,
193 settings.get_env_var(ENV_PRIVATE_KEY_PASSPHRASE),
194 )?;
195 Ok(Self {
196 default_account: settings.get_env_var(ENV_ACCOUNT),
197 default_user: settings.get_env_var(ENV_USER),
198 default_host: host_override_from(settings.get_env_var(ENV_HOST)),
199 default_warehouse: settings.get_env_var(ENV_WAREHOUSE),
200 default_role: settings.get_env_var(ENV_ROLE),
201 default_database: settings.get_env_var(ENV_DATABASE),
202 default_schema: settings.get_env_var(ENV_SCHEMA),
203 auth,
204 pool_size,
205 http_timeout: secs(ENV_HTTP_TIMEOUT).unwrap_or(client::config::DEFAULT_HTTP_TIMEOUT),
206 auth_timeout: secs(ENV_AUTH_TIMEOUT).unwrap_or(DEFAULT_AUTH_TIMEOUT),
207 query_timeout: secs(ENV_QUERY_TIMEOUT).unwrap_or(client::config::DEFAULT_QUERY_TIMEOUT),
208 heartbeat_interval: heartbeat_interval_from(
209 settings.get_env_var(ENV_HEARTBEAT_INTERVAL),
210 ),
211 })
212 }
213}
214
215fn host_override_from(raw: Option<String>) -> Option<String> {
219 raw.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
220}
221
222fn heartbeat_interval_from(raw: Option<String>) -> Duration {
227 raw.and_then(|s| s.trim().parse::<u64>().ok())
228 .map_or(DEFAULT_HEARTBEAT_INTERVAL, Duration::from_secs)
229}
230
231fn resolve_auth_method(
242 authenticator: Option<&str>,
243 token: Option<String>,
244 private_key_pem: Option<String>,
245 passphrase: Option<String>,
246) -> Result<AuthMethod> {
247 let selector = authenticator.unwrap_or("").trim().to_ascii_lowercase();
248 match selector.as_str() {
249 "" | "externalbrowser" | "external_browser" => {
250 Ok(AuthMethod::ExternalBrowser(BrowserConfig::default()))
251 }
252 "programmatic_access_token" | "pat" => {
253 let token = token
254 .map(|t| t.trim().to_string())
255 .filter(|t| !t.is_empty())
256 .ok_or_else(|| {
257 anyhow!("{ENV_AUTHENTICATOR}={selector} requires {ENV_TOKEN} to be set")
258 })?;
259 Ok(AuthMethod::ProgrammaticAccessToken {
260 token: Secret::from(token),
261 })
262 }
263 "snowflake_jwt" | "keypair" | "key_pair" | "jwt" => {
264 if passphrase.is_some_and(|p| !p.trim().is_empty()) {
265 bail!(
266 "{ENV_PRIVATE_KEY_PASSPHRASE} is set, but encrypted private keys are not yet \
267 supported; decrypt the key with `openssl pkcs8 -in key.p8 -out \
268 key_unencrypted.p8` and unset {ENV_PRIVATE_KEY_PASSPHRASE}"
269 );
270 }
271 let private_key_pem = private_key_pem
272 .filter(|k| !k.trim().is_empty())
273 .ok_or_else(|| {
274 anyhow!(
275 "{ENV_AUTHENTICATOR}={selector} requires {ENV_PRIVATE_KEY_PATH} or \
276 {ENV_PRIVATE_KEY}"
277 )
278 })?;
279 Ok(AuthMethod::KeyPairJwt(KeyPairConfig {
280 private_key_pem: Secret::from(private_key_pem),
281 }))
282 }
283 other => Err(anyhow!(
284 "unknown {ENV_AUTHENTICATOR} '{other}' \
285 (expected externalbrowser, programmatic_access_token, or snowflake_jwt)"
286 )),
287 }
288}
289
290#[derive(Clone, Debug, Default, Deserialize, Serialize)]
298#[serde(default)]
299pub struct QueryRequest {
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub account: Option<String>,
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub user: Option<String>,
306 #[serde(skip_serializing_if = "Option::is_none")]
308 pub warehouse: Option<String>,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub role: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub database: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub schema: Option<String>,
318 pub sql: String,
320}
321
322impl QueryRequest {
323 pub fn fill_defaults_from(&mut self, env: &impl EnvSource) {
332 let fill = |slot: &mut Option<String>, key: &str| {
333 if slot.is_none() {
334 *slot = env.var(key);
335 }
336 };
337 fill(&mut self.account, ENV_ACCOUNT);
338 fill(&mut self.user, ENV_USER);
339 fill(&mut self.warehouse, ENV_WAREHOUSE);
340 fill(&mut self.role, ENV_ROLE);
341 fill(&mut self.database, ENV_DATABASE);
342 fill(&mut self.schema, ENV_SCHEMA);
343 }
344 fn overrides(&self) -> QueryContext {
347 QueryContext {
348 warehouse: self.warehouse.clone(),
349 role: self.role.clone(),
350 database: self.database.clone(),
351 schema: self.schema.clone(),
352 }
353 }
354}
355
356struct HeartbeatTask {
358 token: CancellationToken,
359 handle: JoinHandle<()>,
360}
361
362pub struct SnowflakeEngine {
369 config: SnowflakeEngineConfig,
370 registry: PoolRegistry,
371 heartbeat: StdMutex<Option<HeartbeatTask>>,
372}
373
374impl SnowflakeEngine {
375 #[must_use]
377 pub fn new(config: SnowflakeEngineConfig) -> Self {
378 Self {
379 config,
380 registry: PoolRegistry::new(),
381 heartbeat: StdMutex::new(None),
382 }
383 }
384
385 pub fn start_heartbeat(&self) {
396 let interval = self.config.heartbeat_interval;
397 if interval.is_zero() {
398 return;
399 }
400 if tokio::runtime::Handle::try_current().is_err() {
401 tracing::debug!("no tokio runtime; Snowflake keep-alive heartbeat not started");
402 return;
403 }
404 let mut guard = self.lock_heartbeat();
405 if guard.is_some() {
406 return;
407 }
408 let token = CancellationToken::new();
409 let loop_token = token.clone();
410 let registry = self.registry.clone();
411 let handle = tokio::spawn(async move {
412 loop {
413 tokio::select! {
414 () = loop_token.cancelled() => break,
415 () = tokio::time::sleep(interval) => {
416 heartbeat_all_pools(®istry, interval).await;
417 }
418 }
419 }
420 });
421 *guard = Some(HeartbeatTask { token, handle });
422 }
423
424 fn lock_heartbeat(&self) -> MutexGuard<'_, Option<HeartbeatTask>> {
425 self.heartbeat
426 .lock()
427 .unwrap_or_else(std::sync::PoisonError::into_inner)
428 }
429
430 #[must_use]
432 pub fn sessions(&self) -> Vec<SessionInfo> {
433 self.registry.snapshot()
434 }
435
436 #[must_use]
438 pub fn pool_count(&self) -> usize {
439 self.registry.len()
440 }
441
442 pub fn disconnect(&self, account: &str, user: &str) -> bool {
444 let key = SessionKey::new(normalize_account(account), user.trim());
445 self.registry.remove(&key).is_some()
446 }
447
448 pub fn disconnect_by_id(&self, id: u64) -> bool {
450 self.registry.remove_by_id(id).is_some()
451 }
452
453 pub fn disconnect_all(&self) -> usize {
455 self.registry.take_all().len()
456 }
457
458 pub async fn shutdown(&self) {
460 let task = self.lock_heartbeat().take();
461 if let Some(task) = task {
462 task.token.cancel();
463 let _ = task.handle.await;
464 }
465 let pools = self.registry.take_all();
466 drop(pools);
467 }
468
469 pub async fn query(&self, req: QueryRequest) -> Result<Value> {
481 let account = normalize_account(
482 req.account
483 .as_deref()
484 .or(self.config.default_account.as_deref())
485 .ok_or_else(|| {
486 anyhow!("no Snowflake account: pass --account or set SNOWFLAKE_ACCOUNT")
487 })?,
488 );
489 let user = req
490 .user
491 .as_deref()
492 .or(self.config.default_user.as_deref())
493 .ok_or_else(|| anyhow!("no Snowflake user: pass --user or set SNOWFLAKE_USER"))?
494 .trim()
495 .to_string();
496 validate_context(&req)?;
497
498 let key = SessionKey::new(account, user);
499 let overrides = req.overrides();
500 let pool = self.registry.get_or_create(&key, self.config.pool_size);
501
502 let cfg = self.config.clone();
508 let create_key = key.clone();
509 let auth_timeout = self.config.auth_timeout;
510 let checkout = pool
511 .checkout(move || async move {
512 match tokio::time::timeout(
514 auth_timeout,
515 create_session_with_base(&create_key, &cfg),
516 )
517 .await
518 {
519 Ok(result) => result,
520 Err(_) => Err(ClientError::Auth(format!(
521 "Snowflake sign-in timed out after {auth_timeout:?}"
522 ))),
523 }
524 })
525 .await
526 .map_err(|e| {
527 anyhow::Error::new(e).context(format!(
528 "failed to authenticate Snowflake session for {} / {}",
529 key.account, key.user
530 ))
531 })?;
532
533 if checkout
535 .session()
536 .session_expiring_within(TimeDelta::seconds(120))
537 && checkout.session().renew().await.is_err()
538 {
539 pool.discard(checkout);
540 return Err(anyhow!(
541 "Snowflake session expired and was discarded — re-run the query to re-authenticate"
542 ));
543 }
544
545 pool.start_query(checkout.id(), sql_preview(&req.sql, SQL_PREVIEW_MAX));
547
548 let target = checkout.base().overlay(&overrides);
551 match run_with_renew(checkout.session(), checkout.current(), &target, &req.sql).await {
552 Ok(rows) => {
553 pool.touch();
554 pool.checkin(checkout, target);
555 Ok(client::rows_to_payload(&rows))
556 }
557 Err(e) if e.is_session_expired() => {
558 pool.discard(checkout);
559 Err(anyhow!(
560 "Snowflake session expired and was discarded — re-run the query to re-authenticate"
561 ))
562 }
563 Err(e) => {
564 tracing::warn!("Snowflake query failed: {e}");
567 pool.checkin(checkout, QueryContext::default());
570 Err(anyhow::Error::new(e).context("Snowflake query failed"))
571 }
572 }
573 }
574}
575
576impl Drop for SnowflakeEngine {
577 fn drop(&mut self) {
578 let task = self.lock_heartbeat().take();
582 if let Some(task) = task {
583 task.token.cancel();
584 }
585 }
586}
587
588async fn heartbeat_all_pools(registry: &PoolRegistry, interval: Duration) {
591 for pool in registry.pools() {
592 let checkouts = pool.checkout_all_idle();
593 if checkouts.is_empty() {
594 continue;
595 }
596 let total = checkouts.len();
597 let mut kept = 0usize;
598 for checkout in checkouts {
599 if keep_session_alive(checkout.session(), interval).await {
600 pool.restore(checkout);
601 kept += 1;
602 } else {
603 pool.discard(checkout);
604 }
605 }
606 tracing::debug!(
607 pool = pool.id(),
608 kept,
609 total,
610 "Snowflake keep-alive heartbeat round"
611 );
612 }
613}
614
615async fn keep_session_alive(session: &SnowflakeSession, interval: Duration) -> bool {
623 let margin_secs = i64::try_from(interval.as_secs())
624 .unwrap_or(i64::MAX)
625 .saturating_add(HEARTBEAT_RENEW_MARGIN_SECS);
626 let margin = TimeDelta::try_seconds(margin_secs).unwrap_or(TimeDelta::MAX);
627 if session.session_expiring_within(margin) {
628 match session.renew().await {
629 Ok(()) => {}
630 Err(e) if e.is_session_expired() => {
631 tracing::warn!("Snowflake keep-alive: master token expired; discarding session");
632 return false;
633 }
634 Err(e) => {
635 tracing::warn!("Snowflake keep-alive renew failed: {e}");
637 return true;
638 }
639 }
640 }
641 match session.heartbeat().await {
642 Ok(()) => true,
643 Err(e) if e.is_session_expired() => match session.renew().await {
644 Ok(()) => true,
645 Err(renew_err) if renew_err.is_session_expired() => {
646 tracing::warn!("Snowflake keep-alive: master token expired; discarding session");
647 false
648 }
649 Err(renew_err) => {
650 tracing::warn!("Snowflake keep-alive renew failed: {renew_err}");
651 true
652 }
653 },
654 Err(e) => {
655 tracing::warn!("Snowflake keep-alive heartbeat failed: {e}");
656 true
657 }
658 }
659}
660
661async fn create_session_with_base(
664 key: &SessionKey,
665 config: &SnowflakeEngineConfig,
666) -> client::Result<(SnowflakeSession, QueryContext)> {
667 let mut cfg = SnowflakeClientConfig::external_browser(&key.account, &key.user);
668 cfg.auth = config.auth.clone();
669 cfg.host = config.default_host.clone();
670 cfg.warehouse = config.default_warehouse.clone();
671 cfg.role = config.default_role.clone();
672 cfg.database = config.default_database.clone();
673 cfg.schema = config.default_schema.clone();
674 cfg.http_timeout = config.http_timeout;
675 cfg.query_timeout = config.query_timeout;
676
677 let client = SnowflakeClient::new(cfg)?;
678 let session = client.create_session().await?;
679 session
680 .query("ALTER SESSION SET CLIENT_SESSION_KEEP_ALIVE = true")
681 .await?;
682 let base = capture_base_context(&session).await?;
683 Ok((session, base))
684}
685
686async fn capture_base_context(session: &SnowflakeSession) -> client::Result<QueryContext> {
689 let rows = session
690 .query("SELECT CURRENT_WAREHOUSE(), CURRENT_ROLE(), CURRENT_DATABASE(), CURRENT_SCHEMA()")
691 .await?;
692 let Some(row) = rows.first() else {
693 return Ok(QueryContext::default());
694 };
695 Ok(QueryContext {
696 warehouse: row.raw_at(0).map(str::to_string),
697 role: row.raw_at(1).map(str::to_string),
698 database: row.raw_at(2).map(str::to_string),
699 schema: row.raw_at(3).map(str::to_string),
700 })
701}
702
703async fn run_with_renew(
706 session: &SnowflakeSession,
707 current: &QueryContext,
708 target: &QueryContext,
709 sql: &str,
710) -> client::Result<Vec<Row>> {
711 match apply_and_query(session, current, target, sql).await {
712 Err(e) if e.is_session_expired() => {
713 session.renew().await?;
714 apply_and_query(session, &QueryContext::default(), target, sql).await
716 }
717 other => other,
718 }
719}
720
721async fn apply_and_query(
723 session: &SnowflakeSession,
724 current: &QueryContext,
725 target: &QueryContext,
726 sql: &str,
727) -> client::Result<Vec<Row>> {
728 apply_context(session, current, target).await?;
729 session.query(sql).await
730}
731
732async fn apply_context(
736 session: &SnowflakeSession,
737 current: &QueryContext,
738 target: &QueryContext,
739) -> client::Result<()> {
740 for (keyword, cur, tgt) in [
741 (
742 "WAREHOUSE",
743 current.warehouse.as_deref(),
744 target.warehouse.as_deref(),
745 ),
746 ("ROLE", current.role.as_deref(), target.role.as_deref()),
747 (
748 "DATABASE",
749 current.database.as_deref(),
750 target.database.as_deref(),
751 ),
752 (
753 "SCHEMA",
754 current.schema.as_deref(),
755 target.schema.as_deref(),
756 ),
757 ] {
758 if let Some(name) = tgt {
759 if cur != Some(name) {
760 session
761 .query(format!("USE {keyword} {name}").as_str())
762 .await?;
763 }
764 }
765 }
766 Ok(())
767}
768
769fn normalize_account(account: &str) -> String {
771 account.trim().to_ascii_uppercase()
772}
773
774fn sql_preview(sql: &str, max: usize) -> String {
777 let collapsed = sql.split_whitespace().collect::<Vec<_>>().join(" ");
778 if collapsed.chars().count() > max {
779 let head: String = collapsed.chars().take(max).collect();
780 format!("{}…", head.trim_end())
781 } else {
782 collapsed
783 }
784}
785
786fn validate_context(req: &QueryRequest) -> Result<()> {
789 for (name, value) in [
790 ("warehouse", req.warehouse.as_deref()),
791 ("role", req.role.as_deref()),
792 ("database", req.database.as_deref()),
793 ("schema", req.schema.as_deref()),
794 ] {
795 if let Some(value) = value {
796 validate_identifier(name, value)?;
797 }
798 }
799 Ok(())
800}
801
802fn validate_identifier(field: &str, value: &str) -> Result<()> {
806 if value.is_empty() {
807 bail!("--{field} must not be empty");
808 }
809 if !value
810 .chars()
811 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '.'))
812 {
813 bail!(
814 "--{field} '{value}' is not a valid Snowflake identifier \
815 (allowed: letters, digits, '_', '$', '.')"
816 );
817 }
818 Ok(())
819}
820
821#[cfg(test)]
822#[allow(clippy::unwrap_used, clippy::expect_used)]
823mod tests {
824 use super::*;
825 use crate::test_support::env::MapEnv;
826
827 #[test]
828 fn default_config_has_a_nonzero_pool_size() {
829 assert!(SnowflakeEngineConfig::default().pool_size >= 1);
830 }
831
832 #[test]
833 fn heartbeat_interval_from_parses_seconds_zero_and_garbage() {
834 assert_eq!(heartbeat_interval_from(None), DEFAULT_HEARTBEAT_INTERVAL);
835 assert_eq!(
836 heartbeat_interval_from(Some("300".to_string())),
837 Duration::from_secs(300)
838 );
839 assert_eq!(
841 heartbeat_interval_from(Some(" 0 ".to_string())),
842 Duration::ZERO
843 );
844 assert_eq!(
845 heartbeat_interval_from(Some("garbage".to_string())),
846 DEFAULT_HEARTBEAT_INTERVAL
847 );
848 }
849
850 #[test]
851 fn host_override_from_trims_and_treats_blank_as_unset() {
852 assert_eq!(host_override_from(None), None);
853 assert_eq!(host_override_from(Some(" ".to_string())), None);
854 assert_eq!(
855 host_override_from(Some(
856 " acct.privatelink.snowflakecomputing.com\n".to_string()
857 )),
858 Some("acct.privatelink.snowflakecomputing.com".to_string())
859 );
860 }
861
862 #[test]
863 fn resolve_auth_method_defaults_to_external_browser() {
864 for selector in [
866 None,
867 Some(""),
868 Some(" "),
869 Some("externalbrowser"),
870 Some("EXTERNALBROWSER"),
871 ] {
872 assert!(matches!(
873 resolve_auth_method(selector, None, None, None).unwrap(),
874 AuthMethod::ExternalBrowser(_)
875 ));
876 }
877 }
878
879 #[test]
880 fn resolve_auth_method_reads_pat_and_trims_it() {
881 let auth = resolve_auth_method(
882 Some("programmatic_access_token"),
883 Some(" tok-123\n".to_string()),
884 None,
885 None,
886 )
887 .unwrap();
888 let AuthMethod::ProgrammaticAccessToken { token } = auth else {
889 panic!("expected a PAT auth method");
890 };
891 assert_eq!(token.expose_secret(), "tok-123");
892 }
893
894 #[test]
895 fn resolve_auth_method_accepts_the_pat_alias() {
896 assert!(matches!(
897 resolve_auth_method(Some("pat"), Some("t".to_string()), None, None).unwrap(),
898 AuthMethod::ProgrammaticAccessToken { .. }
899 ));
900 }
901
902 #[test]
903 fn resolve_auth_method_errors_when_pat_is_missing_or_blank() {
904 assert!(resolve_auth_method(Some("programmatic_access_token"), None, None, None).is_err());
905 assert!(resolve_auth_method(Some("pat"), Some(" ".to_string()), None, None).is_err());
906 }
907
908 #[test]
909 fn resolve_auth_method_reads_key_pair_pem() {
910 let auth = resolve_auth_method(
911 Some("snowflake_jwt"),
912 None,
913 Some("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----".to_string()),
914 None,
915 )
916 .unwrap();
917 let AuthMethod::KeyPairJwt(cfg) = auth else {
918 panic!("expected a key-pair auth method");
919 };
920 assert!(cfg
921 .private_key_pem
922 .expose_secret()
923 .contains("BEGIN PRIVATE KEY"));
924 }
925
926 #[test]
927 fn resolve_auth_method_accepts_key_pair_aliases() {
928 for selector in ["snowflake_jwt", "keypair", "key_pair", "jwt"] {
929 assert!(matches!(
930 resolve_auth_method(Some(selector), None, Some("pem".to_string()), None).unwrap(),
931 AuthMethod::KeyPairJwt(_)
932 ));
933 }
934 }
935
936 #[test]
937 fn resolve_auth_method_errors_when_key_is_missing() {
938 assert!(resolve_auth_method(Some("snowflake_jwt"), None, None, None).is_err());
939 assert!(
940 resolve_auth_method(Some("snowflake_jwt"), None, Some(" ".to_string()), None).is_err()
941 );
942 }
943
944 #[test]
945 fn resolve_auth_method_rejects_an_encrypted_key_passphrase() {
946 let err = resolve_auth_method(
947 Some("snowflake_jwt"),
948 None,
949 Some("pem".to_string()),
950 Some("hunter2".to_string()),
951 )
952 .unwrap_err();
953 assert!(err.to_string().contains(ENV_PRIVATE_KEY_PASSPHRASE));
954 }
955
956 #[test]
957 fn resolve_auth_method_rejects_an_unknown_selector() {
958 let err = resolve_auth_method(Some("carrier-pigeon"), None, None, None).unwrap_err();
959 assert!(err.to_string().contains("carrier-pigeon"));
960 }
961
962 #[tokio::test]
963 async fn start_heartbeat_is_a_noop_when_disabled() {
964 let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
965 heartbeat_interval: Duration::ZERO,
966 ..SnowflakeEngineConfig::default()
967 });
968 engine.start_heartbeat();
969 assert!(engine.lock_heartbeat().is_none());
970 }
971
972 #[test]
973 fn start_heartbeat_is_a_noop_outside_a_runtime() {
974 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
975 engine.start_heartbeat();
976 assert!(engine.lock_heartbeat().is_none());
977 }
978
979 #[tokio::test]
980 async fn start_heartbeat_is_idempotent_and_shutdown_stops_it() {
981 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
982 engine.start_heartbeat();
983 engine.lock_heartbeat().as_ref().unwrap().token.cancel();
986 engine.start_heartbeat();
987 assert!(
988 engine
989 .lock_heartbeat()
990 .as_ref()
991 .unwrap()
992 .token
993 .is_cancelled(),
994 "second start must not replace the running task"
995 );
996 engine.shutdown().await;
997 assert!(engine.lock_heartbeat().is_none());
998 }
999
1000 #[test]
1001 fn normalize_account_uppercases_and_trims() {
1002 assert_eq!(normalize_account(" my-org.acct "), "MY-ORG.ACCT");
1003 }
1004
1005 #[test]
1006 fn validate_identifier_accepts_bare_identifiers() {
1007 for value in ["WH", "my_wh", "DB.SCHEMA", "wh$1"] {
1008 assert!(validate_identifier("warehouse", value).is_ok(), "{value}");
1009 }
1010 }
1011
1012 #[test]
1013 fn validate_identifier_rejects_injection_and_empty() {
1014 for value in ["", "wh; DROP TABLE t", "wh OR 1=1", "wh'", "a b"] {
1015 assert!(validate_identifier("warehouse", value).is_err(), "{value}");
1016 }
1017 }
1018
1019 #[test]
1020 fn fill_defaults_from_fills_unset_fields() {
1021 let env = MapEnv::new()
1022 .with(ENV_ACCOUNT, "ACCT")
1023 .with(ENV_USER, "me")
1024 .with(ENV_WAREHOUSE, "WH")
1025 .with(ENV_ROLE, "R")
1026 .with(ENV_DATABASE, "DB")
1027 .with(ENV_SCHEMA, "S");
1028 let mut req = QueryRequest {
1029 sql: "SELECT 1".to_string(),
1030 ..QueryRequest::default()
1031 };
1032 req.fill_defaults_from(&env);
1033 assert_eq!(req.account.as_deref(), Some("ACCT"));
1034 assert_eq!(req.user.as_deref(), Some("me"));
1035 assert_eq!(req.warehouse.as_deref(), Some("WH"));
1036 assert_eq!(req.role.as_deref(), Some("R"));
1037 assert_eq!(req.database.as_deref(), Some("DB"));
1038 assert_eq!(req.schema.as_deref(), Some("S"));
1039 }
1040
1041 #[test]
1042 fn fill_defaults_from_keeps_explicit_values() {
1043 let env = MapEnv::new()
1044 .with(ENV_ACCOUNT, "ENV_ACCT")
1045 .with(ENV_USER, "env_user");
1046 let mut req = QueryRequest {
1047 account: Some("FLAG_ACCT".to_string()),
1048 sql: "SELECT 1".to_string(),
1049 ..QueryRequest::default()
1050 };
1051 req.fill_defaults_from(&env);
1052 assert_eq!(req.account.as_deref(), Some("FLAG_ACCT"));
1053 assert_eq!(req.user.as_deref(), Some("env_user"));
1054 }
1055
1056 #[test]
1057 fn fill_defaults_from_leaves_unresolved_fields_none() {
1058 let mut req = QueryRequest {
1059 sql: "SELECT 1".to_string(),
1060 ..QueryRequest::default()
1061 };
1062 req.fill_defaults_from(&MapEnv::new());
1063 assert!(req.account.is_none());
1064 assert!(req.user.is_none());
1065 assert!(req.warehouse.is_none());
1066 }
1067
1068 #[test]
1069 fn query_request_serializes_without_none_fields() {
1070 let req = QueryRequest {
1071 account: Some("ACCT".to_string()),
1072 sql: "SELECT 1".to_string(),
1073 ..QueryRequest::default()
1074 };
1075 let value = serde_json::to_value(&req).unwrap();
1076 assert_eq!(
1077 value,
1078 serde_json::json!({ "account": "ACCT", "sql": "SELECT 1" })
1079 );
1080 }
1081
1082 #[test]
1083 fn validate_context_checks_each_present_flag() {
1084 let mut req = QueryRequest {
1085 sql: "SELECT 1".to_string(),
1086 ..QueryRequest::default()
1087 };
1088 assert!(validate_context(&req).is_ok());
1089 req.role = Some("good_role".to_string());
1090 assert!(validate_context(&req).is_ok());
1091 req.database = Some("bad; drop".to_string());
1092 assert!(validate_context(&req).is_err());
1093 }
1094
1095 #[tokio::test]
1096 async fn query_without_account_errors_without_network() {
1097 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1098 let err = engine
1099 .query(QueryRequest {
1100 sql: "SELECT 1".to_string(),
1101 ..QueryRequest::default()
1102 })
1103 .await
1104 .unwrap_err();
1105 assert!(err.to_string().contains("account"));
1106 }
1107
1108 #[tokio::test]
1109 async fn query_without_user_errors_without_network() {
1110 let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1111 default_account: Some("ACCT".to_string()),
1112 ..SnowflakeEngineConfig::default()
1113 });
1114 let err = engine
1115 .query(QueryRequest {
1116 sql: "SELECT 1".to_string(),
1117 ..QueryRequest::default()
1118 })
1119 .await
1120 .unwrap_err();
1121 assert!(err.to_string().contains("user"));
1122 }
1123
1124 #[test]
1125 fn disconnect_and_sessions_on_empty_engine() {
1126 let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1127 assert_eq!(engine.pool_count(), 0);
1128 assert!(engine.sessions().is_empty());
1129 assert!(!engine.disconnect("ACCT", "user"));
1130 assert!(!engine.disconnect_by_id(1));
1131 assert_eq!(engine.disconnect_all(), 0);
1132 }
1133
1134 #[test]
1135 fn sql_preview_collapses_whitespace_and_truncates() {
1136 assert_eq!(sql_preview("SELECT 1\n FROM t", 60), "SELECT 1 FROM t");
1138 let long = format!("SELECT {}", "a".repeat(100));
1140 let preview = sql_preview(&long, 20);
1141 assert!(preview.ends_with('…'));
1142 assert!(preview.chars().count() <= 21, "{preview}");
1143 }
1144
1145 #[test]
1146 fn overrides_extracts_only_the_set_dimensions() {
1147 let req = QueryRequest {
1148 warehouse: Some("WH".to_string()),
1149 schema: Some("S".to_string()),
1150 sql: "SELECT 1".to_string(),
1151 ..QueryRequest::default()
1152 };
1153 let overrides = req.overrides();
1154 assert_eq!(overrides.warehouse.as_deref(), Some("WH"));
1155 assert_eq!(overrides.schema.as_deref(), Some("S"));
1156 assert!(overrides.role.is_none());
1157 assert!(overrides.database.is_none());
1158 }
1159
1160 mod orchestration {
1161 use super::*;
1162 use serde_json::json;
1163 use wiremock::matchers::{method, path};
1164 use wiremock::{Mock, MockServer, ResponseTemplate};
1165
1166 async fn mount_query(server: &MockServer, data: serde_json::Value) {
1168 Mock::given(method("POST"))
1169 .and(path("/queries/v1/query-request"))
1170 .respond_with(
1171 ResponseTemplate::new(200)
1172 .set_body_json(json!({ "success": true, "data": data })),
1173 )
1174 .mount(server)
1175 .await;
1176 }
1177
1178 #[tokio::test]
1179 async fn capture_base_context_reads_current_context() {
1180 let server = MockServer::start().await;
1181 mount_query(
1182 &server,
1183 json!({
1184 "rowtype": [
1185 { "name": "CURRENT_WAREHOUSE()", "type": "text" },
1186 { "name": "CURRENT_ROLE()", "type": "text" },
1187 { "name": "CURRENT_DATABASE()", "type": "text" },
1188 { "name": "CURRENT_SCHEMA()", "type": "text" },
1189 ],
1190 "rowset": [["WH", "R", "DB", "S"]],
1191 }),
1192 )
1193 .await;
1194 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1195 let base = capture_base_context(&session).await.unwrap();
1196 assert_eq!(base.warehouse.as_deref(), Some("WH"));
1197 assert_eq!(base.role.as_deref(), Some("R"));
1198 assert_eq!(base.database.as_deref(), Some("DB"));
1199 assert_eq!(base.schema.as_deref(), Some("S"));
1200 }
1201
1202 #[tokio::test]
1203 async fn capture_base_context_defaults_when_no_rows() {
1204 let server = MockServer::start().await;
1205 mount_query(
1206 &server,
1207 json!({ "rowtype": [{ "name": "X", "type": "text" }], "rowset": [] }),
1208 )
1209 .await;
1210 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1211 assert_eq!(
1212 capture_base_context(&session).await.unwrap(),
1213 QueryContext::default()
1214 );
1215 }
1216
1217 #[tokio::test]
1218 async fn apply_context_issues_use_only_for_differing_dimensions() {
1219 let server = MockServer::start().await;
1220 mount_query(&server, json!({ "rowtype": [], "rowset": [] })).await;
1221 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1222
1223 let current = QueryContext {
1224 warehouse: Some("WH".to_string()),
1225 ..QueryContext::default()
1226 };
1227 let target = QueryContext {
1228 warehouse: Some("WH".to_string()), role: Some("R2".to_string()), ..QueryContext::default()
1231 };
1232 apply_context(&session, ¤t, &target).await.unwrap();
1233
1234 let reqs = server.received_requests().await.unwrap();
1235 assert_eq!(reqs.len(), 1, "only the differing dimension issues a USE");
1236 assert!(String::from_utf8_lossy(&reqs[0].body).contains("USE ROLE R2"));
1237 }
1238
1239 #[tokio::test]
1240 async fn run_with_renew_runs_without_renew_when_not_expired() {
1241 let server = MockServer::start().await;
1242 mount_query(
1243 &server,
1244 json!({ "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }),
1245 )
1246 .await;
1247 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1248 let ctx = QueryContext::default();
1249 let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
1250 .await
1251 .unwrap();
1252 assert_eq!(rows.len(), 1);
1253 }
1254
1255 #[tokio::test]
1256 async fn run_with_renew_renews_and_retries_once_on_expiry() {
1257 let server = MockServer::start().await;
1258 Mock::given(method("POST"))
1260 .and(path("/queries/v1/query-request"))
1261 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1262 "success": false, "code": "390112", "message": "expired", "data": {}
1263 })))
1264 .up_to_n_times(1)
1265 .with_priority(1)
1266 .mount(&server)
1267 .await;
1268 Mock::given(method("POST"))
1270 .and(path("/session/token-request"))
1271 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1272 "success": true,
1273 "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
1274 })))
1275 .mount(&server)
1276 .await;
1277 Mock::given(method("POST"))
1279 .and(path("/queries/v1/query-request"))
1280 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1281 "success": true,
1282 "data": { "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }
1283 })))
1284 .with_priority(2)
1285 .mount(&server)
1286 .await;
1287
1288 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1289 let ctx = QueryContext::default();
1290 let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
1291 .await
1292 .unwrap();
1293 assert_eq!(rows.len(), 1, "renewed and retried transparently");
1294 }
1295 }
1296
1297 mod keep_alive {
1298 use super::*;
1299 use serde_json::json;
1300 use wiremock::matchers::{method, path};
1301 use wiremock::{Mock, MockServer, ResponseTemplate};
1302
1303 const INTERVAL: Duration = Duration::from_secs(60);
1306
1307 async fn mount_heartbeat(server: &MockServer, body: serde_json::Value) {
1309 Mock::given(method("POST"))
1310 .and(path("/session/heartbeat"))
1311 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1312 .mount(server)
1313 .await;
1314 }
1315
1316 async fn mount_renew(server: &MockServer, body: serde_json::Value) {
1318 Mock::given(method("POST"))
1319 .and(path("/session/token-request"))
1320 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1321 .mount(server)
1322 .await;
1323 }
1324
1325 fn ok_body() -> serde_json::Value {
1326 json!({ "success": true, "data": {} })
1327 }
1328
1329 fn renew_ok_body() -> serde_json::Value {
1330 json!({
1331 "success": true,
1332 "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
1333 })
1334 }
1335
1336 fn expired_body() -> serde_json::Value {
1337 json!({ "success": false, "code": "390112", "message": "expired", "data": {} })
1338 }
1339
1340 #[tokio::test]
1341 async fn keep_session_alive_heartbeats_a_healthy_session() {
1342 let server = MockServer::start().await;
1343 mount_heartbeat(&server, ok_body()).await;
1344 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1345 assert!(keep_session_alive(&session, INTERVAL).await);
1346 let reqs = server.received_requests().await.unwrap();
1347 assert_eq!(reqs.len(), 1, "one heartbeat, no renew");
1348 }
1349
1350 #[tokio::test]
1351 async fn keep_session_alive_renews_when_the_heartbeat_reports_expiry() {
1352 let server = MockServer::start().await;
1353 mount_heartbeat(&server, expired_body()).await;
1354 mount_renew(&server, renew_ok_body()).await;
1355 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1356 assert!(keep_session_alive(&session, INTERVAL).await);
1357 let reqs = server.received_requests().await.unwrap();
1358 assert!(
1359 reqs.iter()
1360 .any(|r| r.url.path() == "/session/token-request"),
1361 "renewed after the expired heartbeat"
1362 );
1363 }
1364
1365 #[tokio::test]
1366 async fn keep_session_alive_discards_when_the_master_token_is_dead() {
1367 let server = MockServer::start().await;
1368 mount_heartbeat(&server, expired_body()).await;
1369 mount_renew(&server, expired_body()).await;
1370 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1371 assert!(!keep_session_alive(&session, INTERVAL).await);
1372 }
1373
1374 #[tokio::test]
1375 async fn keep_session_alive_keeps_the_session_on_transient_errors() {
1376 let server = MockServer::start().await;
1377 mount_heartbeat(
1378 &server,
1379 json!({ "success": false, "code": "390001", "message": "hiccup", "data": {} }),
1380 )
1381 .await;
1382 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1383 assert!(keep_session_alive(&session, INTERVAL).await);
1384 }
1385
1386 #[tokio::test]
1387 async fn keep_session_alive_proactively_renews_a_token_expiring_before_the_next_tick() {
1388 let server = MockServer::start().await;
1389 mount_heartbeat(&server, ok_body()).await;
1390 mount_renew(&server, renew_ok_body()).await;
1391 let session = client::test_session(&server.uri(), Duration::from_secs(5));
1392 assert!(keep_session_alive(&session, Duration::from_secs(7200)).await);
1394 let reqs = server.received_requests().await.unwrap();
1395 assert_eq!(
1396 reqs[0].url.path(),
1397 "/session/token-request",
1398 "renew ran first"
1399 );
1400 assert_eq!(reqs[1].url.path(), "/session/heartbeat");
1401 }
1402
1403 #[tokio::test]
1404 async fn engine_heartbeat_loop_beats_idle_sessions_and_stops_on_shutdown() {
1405 let server = MockServer::start().await;
1406 mount_heartbeat(&server, ok_body()).await;
1407
1408 let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1409 heartbeat_interval: Duration::from_millis(50),
1410 ..SnowflakeEngineConfig::default()
1411 });
1412 let pool = engine
1414 .registry
1415 .get_or_create(&SessionKey::new("ACCT", "user"), 2);
1416 let uri = server.uri();
1417 let checkout = pool
1418 .checkout(|| async {
1419 Ok::<_, std::convert::Infallible>((
1420 client::test_session(&uri, Duration::from_secs(5)),
1421 QueryContext::default(),
1422 ))
1423 })
1424 .await
1425 .unwrap();
1426 pool.checkin(checkout, QueryContext::default());
1427
1428 engine.start_heartbeat();
1429 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1430 while server.received_requests().await.unwrap().is_empty() {
1431 assert!(
1432 tokio::time::Instant::now() < deadline,
1433 "no heartbeat within the deadline"
1434 );
1435 tokio::time::sleep(Duration::from_millis(10)).await;
1436 }
1437 assert_eq!(pool.live(), 1);
1439
1440 engine.shutdown().await;
1441 let after = server.received_requests().await.unwrap().len();
1442 tokio::time::sleep(Duration::from_millis(150)).await;
1443 assert_eq!(
1444 server.received_requests().await.unwrap().len(),
1445 after,
1446 "no heartbeats after shutdown"
1447 );
1448 assert_eq!(engine.pool_count(), 0, "pools drained on shutdown");
1449 }
1450 }
1451}