Skip to main content

omni_dev/
snowflake.rs

1//! Account-agnostic Snowflake query engine hosted by the daemon.
2//!
3//! Each `(account, user)` keeps a **bounded pool** of authenticated sessions
4//! (see [`session`]). A query checks one out, applies any per-query context with
5//! `USE` (skipping `USE`s already in effect), runs concurrently with other
6//! checkouts, and returns it. This gives **concurrent queries on a single
7//! authentication identity** while still honoring per-query
8//! `warehouse`/`role`/`database`/`schema`, with the number of browser auths
9//! capped at the pool size and grown lazily.
10//!
11//! A background keep-alive heartbeat ([`SnowflakeEngine::start_heartbeat`])
12//! periodically heartbeats idle sessions so their master tokens stay valid and
13//! an idle pool never re-prompts browser SSO.
14//!
15//! This is the standalone engine, analogous to [`crate::browser`]; the daemon
16//! adapter lives in [`crate::daemon::services::snowflake`].
17
18pub 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, BrowserLaunch, Error as ClientError, KeyPairConfig, Row,
36    SnowflakeClient, SnowflakeClientConfig, SnowflakeSession,
37};
38use session::{PoolRegistry, QueryContext, SessionInfo, SessionKey};
39
40/// Env var (with `~/.omni-dev/settings.json` fallback) for the default account.
41const ENV_ACCOUNT: &str = "SNOWFLAKE_ACCOUNT";
42/// Env var for the default user.
43const ENV_USER: &str = "SNOWFLAKE_USER";
44/// Env var overriding the API host (verbatim), instead of deriving
45/// `<account>.snowflakecomputing.com`. Needed for AWS/Azure PrivateLink
46/// endpoints and gov/custom hosts.
47const ENV_HOST: &str = "SNOWFLAKE_HOST";
48/// Env var for the default warehouse.
49const ENV_WAREHOUSE: &str = "SNOWFLAKE_WAREHOUSE";
50/// Env var for the default role.
51const ENV_ROLE: &str = "SNOWFLAKE_ROLE";
52/// Env var for the default database.
53const ENV_DATABASE: &str = "SNOWFLAKE_DATABASE";
54/// Env var for the default schema.
55const ENV_SCHEMA: &str = "SNOWFLAKE_SCHEMA";
56/// Env var for the per-`(account, user)` pool size (max concurrent sessions).
57const ENV_POOL_SIZE: &str = "SNOWFLAKE_POOL_SIZE";
58/// Env var for the per-request HTTP timeout (seconds).
59const ENV_HTTP_TIMEOUT: &str = "SNOWFLAKE_HTTP_TIMEOUT";
60/// Env var for the overall sign-in deadline (seconds).
61const ENV_AUTH_TIMEOUT: &str = "SNOWFLAKE_AUTH_TIMEOUT";
62/// Env var for the overall per-query deadline incl. async polling (seconds).
63const ENV_QUERY_TIMEOUT: &str = "SNOWFLAKE_QUERY_TIMEOUT";
64/// Env var for the idle-session keep-alive heartbeat interval (seconds; `0`
65/// disables the heartbeat).
66const ENV_HEARTBEAT_INTERVAL: &str = "SNOWFLAKE_HEARTBEAT_INTERVAL";
67/// Env var selecting the auth method: `externalbrowser` (default; interactive
68/// SSO), `programmatic_access_token`, or `snowflake_jwt` (both non-interactive).
69const ENV_AUTHENTICATOR: &str = "SNOWFLAKE_AUTHENTICATOR";
70/// Env var for the programmatic access token (PAT auth).
71const ENV_TOKEN: &str = "SNOWFLAKE_TOKEN";
72/// Env var for the path to an unencrypted PKCS#8 PEM private key (JWT auth).
73const ENV_PRIVATE_KEY_PATH: &str = "SNOWFLAKE_PRIVATE_KEY_PATH";
74/// Env var for an inline unencrypted PKCS#8 PEM private key (alternative to the
75/// path).
76const ENV_PRIVATE_KEY: &str = "SNOWFLAKE_PRIVATE_KEY";
77/// Env var for an encrypted key's passphrase. Recognized but not yet supported;
78/// setting it with the JWT method is a clear error.
79const ENV_PRIVATE_KEY_PASSPHRASE: &str = "SNOWFLAKE_PRIVATE_KEY_PASSPHRASE";
80/// Env var for the external-browser SSO launch command: a single command line
81/// with a `{url}` placeholder (or the URL is appended as a trailing arg when the
82/// placeholder is absent). Quote-aware (single/double quotes, backslash escapes)
83/// so program paths and argument values may contain spaces, e.g.
84/// `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
85/// --profile-directory="Profile 1" --new-window {url}`. Blank/unset opens the OS
86/// default handler ([`BrowserLaunch::Auto`]). Ignored by the non-interactive
87/// auth methods (PAT / key-pair JWT), which open no browser.
88const ENV_BROWSER_COMMAND: &str = "SNOWFLAKE_BROWSER_COMMAND";
89
90/// Default pool size when `SNOWFLAKE_POOL_SIZE` is unset: the max concurrent
91/// queries (and max browser auths) per `(account, user)`.
92const DEFAULT_POOL_SIZE: usize = 4;
93/// Default overall sign-in deadline: comfortably over the SSO callback wait so a
94/// genuine sign-in completes, but bounded so a hung auth can't hold the gate.
95const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(150);
96/// Max characters of SQL shown in the "running" preview for a busy session.
97const SQL_PREVIEW_MAX: usize = 60;
98/// Default keep-alive heartbeat interval: a quarter of the default 3600s
99/// session-token validity (Snowflake's own drivers clamp their heartbeat
100/// frequency to 900–3600s), so an idle session renews comfortably before
101/// either token lapses.
102const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(900);
103/// Extra margin past the heartbeat interval when deciding to proactively renew
104/// an idle session's token before it would lapse mid-cycle.
105const HEARTBEAT_RENEW_MARGIN_SECS: i64 = 60;
106
107/// Engine defaults, resolved from environment variables and then
108/// `~/.omni-dev/settings.json` (the Atlassian credential-resolution pattern).
109///
110/// Account/user/context are optional; a request supplies its own and falls back
111/// to these. There is **no** hardcoded account list or alias map.
112#[derive(Clone, Debug)]
113pub struct SnowflakeEngineConfig {
114    /// Default account when a request omits `--account`.
115    pub default_account: Option<String>,
116    /// Default user when a request omits `--user`.
117    pub default_user: Option<String>,
118    /// Override the API host (verbatim) instead of deriving
119    /// `<account>.snowflakecomputing.com`. Set for PrivateLink / gov / custom
120    /// hosts; applies to every session this engine creates.
121    pub default_host: Option<String>,
122    /// Default warehouse applied at session creation.
123    pub default_warehouse: Option<String>,
124    /// Default role applied at session creation.
125    pub default_role: Option<String>,
126    /// Default database applied at session creation.
127    pub default_database: Option<String>,
128    /// Default schema applied at session creation.
129    pub default_schema: Option<String>,
130    /// How sessions authenticate (SSO by default; PAT or key-pair JWT for
131    /// non-interactive/headless use). The credential is the same for every
132    /// session in every pool this engine creates.
133    pub auth: AuthMethod,
134    /// Max concurrent sessions (and browser auths) per `(account, user)`.
135    pub pool_size: usize,
136    /// Per-request HTTP timeout for REST calls.
137    pub http_timeout: Duration,
138    /// Overall deadline for one sign-in (SSO + login) so a hung auth can't hold
139    /// the shared auth gate indefinitely.
140    pub auth_timeout: Duration,
141    /// Overall deadline for one query (submit + async-result polling).
142    pub query_timeout: Duration,
143    /// How often the background task heartbeats idle sessions to keep their
144    /// master token alive. Zero disables the heartbeat.
145    pub heartbeat_interval: Duration,
146}
147
148impl Default for SnowflakeEngineConfig {
149    fn default() -> Self {
150        Self {
151            default_account: None,
152            default_user: None,
153            default_host: None,
154            default_warehouse: None,
155            default_role: None,
156            default_database: None,
157            default_schema: None,
158            auth: AuthMethod::ExternalBrowser(BrowserConfig::default()),
159            pool_size: DEFAULT_POOL_SIZE,
160            http_timeout: client::config::DEFAULT_HTTP_TIMEOUT,
161            auth_timeout: DEFAULT_AUTH_TIMEOUT,
162            query_timeout: client::config::DEFAULT_QUERY_TIMEOUT,
163            heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
164        }
165    }
166}
167
168impl SnowflakeEngineConfig {
169    /// Resolves defaults from env vars (then settings.json). Cheap and
170    /// side-effect-free; never authenticates.
171    ///
172    /// # Errors
173    ///
174    /// If `SNOWFLAKE_AUTHENTICATOR` names an unknown method or a selected
175    /// non-interactive method is missing its credential (see
176    /// [`resolve_auth_method`]).
177    pub fn from_env_and_settings() -> Result<Self> {
178        let settings = Settings::load().unwrap_or_default();
179        let pool_size = settings
180            .get_env_var(ENV_POOL_SIZE)
181            .and_then(|s| s.trim().parse::<usize>().ok())
182            .filter(|&n| n >= 1)
183            .unwrap_or(DEFAULT_POOL_SIZE);
184        let secs = |key: &str| {
185            settings
186                .get_env_var(key)
187                .and_then(|s| s.trim().parse::<u64>().ok())
188                .filter(|&n| n >= 1)
189                .map(Duration::from_secs)
190        };
191        let private_key_pem = match settings.get_env_var(ENV_PRIVATE_KEY_PATH) {
192            Some(path) => Some(
193                std::fs::read_to_string(&path)
194                    .with_context(|| format!("reading {ENV_PRIVATE_KEY_PATH} '{path}'"))?,
195            ),
196            None => settings.get_env_var(ENV_PRIVATE_KEY),
197        };
198        let auth = resolve_auth_method(
199            settings.get_env_var(ENV_AUTHENTICATOR).as_deref(),
200            settings.get_env_var(ENV_BROWSER_COMMAND),
201            settings.get_env_var(ENV_TOKEN),
202            private_key_pem,
203            settings.get_env_var(ENV_PRIVATE_KEY_PASSPHRASE),
204        )?;
205        Ok(Self {
206            default_account: settings.get_env_var(ENV_ACCOUNT),
207            default_user: settings.get_env_var(ENV_USER),
208            default_host: host_override_from(settings.get_env_var(ENV_HOST)),
209            default_warehouse: settings.get_env_var(ENV_WAREHOUSE),
210            default_role: settings.get_env_var(ENV_ROLE),
211            default_database: settings.get_env_var(ENV_DATABASE),
212            default_schema: settings.get_env_var(ENV_SCHEMA),
213            auth,
214            pool_size,
215            http_timeout: secs(ENV_HTTP_TIMEOUT).unwrap_or(client::config::DEFAULT_HTTP_TIMEOUT),
216            auth_timeout: secs(ENV_AUTH_TIMEOUT).unwrap_or(DEFAULT_AUTH_TIMEOUT),
217            query_timeout: secs(ENV_QUERY_TIMEOUT).unwrap_or(client::config::DEFAULT_QUERY_TIMEOUT),
218            heartbeat_interval: heartbeat_interval_from(
219                settings.get_env_var(ENV_HEARTBEAT_INTERVAL),
220            ),
221        })
222    }
223}
224
225/// Normalizes the `SNOWFLAKE_HOST` override: trims surrounding whitespace (e.g. a
226/// trailing newline from a `$(cat …)`-style value) and treats a blank value as
227/// unset, so an empty override never shadows the derived host.
228fn host_override_from(raw: Option<String>) -> Option<String> {
229    raw.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
230}
231
232/// Parses the heartbeat-interval setting: seconds, with `0` meaning disabled.
233/// Unset or unparseable values fall back to the default. (The `secs` helper in
234/// [`SnowflakeEngineConfig::from_env_and_settings`] rejects `0`, which here is
235/// a meaningful value.)
236fn heartbeat_interval_from(raw: Option<String>) -> Duration {
237    raw.and_then(|s| s.trim().parse::<u64>().ok())
238        .map_or(DEFAULT_HEARTBEAT_INTERVAL, Duration::from_secs)
239}
240
241/// Resolves the [`AuthMethod`] from the `SNOWFLAKE_AUTHENTICATOR` selector and
242/// the method-specific credential vars (`private_key_pem` is the already-read
243/// key material, from a file or inline). An unset or blank selector keeps
244/// external-browser SSO, preserving the pre-#1108 default. The PAT secret is
245/// trimmed (dropping a stray trailing newline from `$(cat …)`-style values).
246///
247/// `browser_command` (the raw `SNOWFLAKE_BROWSER_COMMAND` value) configures how
248/// external-browser SSO opens the sign-in URL: a non-blank value becomes a
249/// [`BrowserLaunch::Command`] (parsed by [`split_browser_command`]); blank/unset
250/// keeps [`BrowserLaunch::Auto`]. It is ignored by the non-interactive methods,
251/// which open no browser.
252///
253/// # Errors
254///
255/// If the selector is unknown, a non-interactive method is selected without its
256/// credential, an encrypted key passphrase is set (unsupported), or (for
257/// external-browser) `browser_command` is present but cannot be tokenized.
258fn resolve_auth_method(
259    authenticator: Option<&str>,
260    browser_command: Option<String>,
261    token: Option<String>,
262    private_key_pem: Option<String>,
263    passphrase: Option<String>,
264) -> Result<AuthMethod> {
265    let selector = authenticator.unwrap_or("").trim().to_ascii_lowercase();
266    match selector.as_str() {
267        "" | "externalbrowser" | "external_browser" => {
268            let launch = match browser_command
269                .map(|c| c.trim().to_string())
270                .filter(|c| !c.is_empty())
271            {
272                Some(cmd) => BrowserLaunch::Command(split_browser_command(&cmd)?),
273                None => BrowserLaunch::Auto,
274            };
275            Ok(AuthMethod::ExternalBrowser(BrowserConfig {
276                launch,
277                ..BrowserConfig::default()
278            }))
279        }
280        "programmatic_access_token" | "pat" => {
281            let token = token
282                .map(|t| t.trim().to_string())
283                .filter(|t| !t.is_empty())
284                .ok_or_else(|| {
285                    anyhow!("{ENV_AUTHENTICATOR}={selector} requires {ENV_TOKEN} to be set")
286                })?;
287            Ok(AuthMethod::ProgrammaticAccessToken {
288                token: Secret::from(token),
289            })
290        }
291        "snowflake_jwt" | "keypair" | "key_pair" | "jwt" => {
292            if passphrase.is_some_and(|p| !p.trim().is_empty()) {
293                bail!(
294                    "{ENV_PRIVATE_KEY_PASSPHRASE} is set, but encrypted private keys are not yet \
295                     supported; decrypt the key with `openssl pkcs8 -in key.p8 -out \
296                     key_unencrypted.p8` and unset {ENV_PRIVATE_KEY_PASSPHRASE}"
297                );
298            }
299            let private_key_pem = private_key_pem
300                .filter(|k| !k.trim().is_empty())
301                .ok_or_else(|| {
302                    anyhow!(
303                        "{ENV_AUTHENTICATOR}={selector} requires {ENV_PRIVATE_KEY_PATH} or \
304                         {ENV_PRIVATE_KEY}"
305                    )
306                })?;
307            Ok(AuthMethod::KeyPairJwt(KeyPairConfig {
308                private_key_pem: Secret::from(private_key_pem),
309            }))
310        }
311        other => Err(anyhow!(
312            "unknown {ENV_AUTHENTICATOR} '{other}' \
313             (expected externalbrowser, programmatic_access_token, or snowflake_jwt)"
314        )),
315    }
316}
317
318/// Tokenizes a `SNOWFLAKE_BROWSER_COMMAND` value into program + args with
319/// POSIX-style quoting so a program path or an argument value may contain
320/// spaces (`Google Chrome.app`, `--profile-directory="Profile 1"`):
321///
322/// - unquoted whitespace separates tokens;
323/// - single quotes take their contents literally;
324/// - double quotes group while a backslash escapes only `"` or `\` (so
325///   Windows-style paths keep their other backslashes);
326/// - an unquoted backslash escapes the next character.
327///
328/// The `{url}` placeholder is left intact here; the client's `open_browser`
329/// substitutes it (or appends the URL) at launch time.
330///
331/// # Errors
332///
333/// If a quote is left unterminated, or the value tokenizes to zero words.
334fn split_browser_command(raw: &str) -> Result<Vec<String>> {
335    let mut words: Vec<String> = Vec::new();
336    let mut current = String::new();
337    // Distinguishes an empty quoted arg (`""`) from "no arg accumulated yet".
338    let mut has_word = false;
339    let mut chars = raw.chars();
340
341    while let Some(c) = chars.next() {
342        match c {
343            c if c.is_whitespace() => {
344                if has_word {
345                    words.push(std::mem::take(&mut current));
346                    has_word = false;
347                }
348            }
349            '\'' => {
350                has_word = true;
351                loop {
352                    match chars.next() {
353                        Some('\'') => break,
354                        Some(ch) => current.push(ch),
355                        None => {
356                            bail!("{ENV_BROWSER_COMMAND} has an unterminated single quote: {raw}")
357                        }
358                    }
359                }
360            }
361            '"' => {
362                has_word = true;
363                loop {
364                    match chars.next() {
365                        Some('"') => break,
366                        Some('\\') => match chars.next() {
367                            Some(ch @ ('"' | '\\')) => current.push(ch),
368                            Some(ch) => {
369                                current.push('\\');
370                                current.push(ch);
371                            }
372                            None => bail!(
373                                "{ENV_BROWSER_COMMAND} has an unterminated double quote: {raw}"
374                            ),
375                        },
376                        Some(ch) => current.push(ch),
377                        None => {
378                            bail!("{ENV_BROWSER_COMMAND} has an unterminated double quote: {raw}")
379                        }
380                    }
381                }
382            }
383            '\\' => {
384                has_word = true;
385                match chars.next() {
386                    Some(ch) => current.push(ch),
387                    None => current.push('\\'),
388                }
389            }
390            ch => {
391                has_word = true;
392                current.push(ch);
393            }
394        }
395    }
396    if has_word {
397        words.push(current);
398    }
399    if words.is_empty() {
400        bail!("{ENV_BROWSER_COMMAND} is set but contains no command");
401    }
402    Ok(words)
403}
404
405/// A single arbitrary-SQL query request routed to the engine.
406///
407/// `account`/`user` and the per-query context default to the engine config when
408/// omitted. Serialized by the CLI client (after [`fill_defaults_from`]
409/// resolution) and deserialized from the daemon `query` op payload.
410///
411/// [`fill_defaults_from`]: QueryRequest::fill_defaults_from
412#[derive(Clone, Debug, Default, Deserialize, Serialize)]
413#[serde(default)]
414pub struct QueryRequest {
415    /// Target account; falls back to `SNOWFLAKE_ACCOUNT`.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub account: Option<String>,
418    /// Authenticating user; falls back to `SNOWFLAKE_USER`.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub user: Option<String>,
421    /// Per-query `USE WAREHOUSE` override.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub warehouse: Option<String>,
424    /// Per-query `USE ROLE` override.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub role: Option<String>,
427    /// Per-query `USE DATABASE` override.
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub database: Option<String>,
430    /// Per-query `USE SCHEMA` override.
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub schema: Option<String>,
433    /// The SQL to execute.
434    pub sql: String,
435}
436
437impl QueryRequest {
438    /// Fills each unset identity/context field from `env`.
439    ///
440    /// Called by the CLI client with a profile-aware source
441    /// ([`SettingsEnv`](crate::utils::settings::SettingsEnv)) so that
442    /// `--profile` / `OMNI_DEV_PROFILE` resolve in the invoking process —
443    /// the daemon's startup defaults then only back-fill requests that still
444    /// omit a field (e.g. bare socket clients). Explicit values are never
445    /// overwritten.
446    pub fn fill_defaults_from(&mut self, env: &impl EnvSource) {
447        let fill = |slot: &mut Option<String>, key: &str| {
448            if slot.is_none() {
449                *slot = env.var(key);
450            }
451        };
452        fill(&mut self.account, ENV_ACCOUNT);
453        fill(&mut self.user, ENV_USER);
454        fill(&mut self.warehouse, ENV_WAREHOUSE);
455        fill(&mut self.role, ENV_ROLE);
456        fill(&mut self.database, ENV_DATABASE);
457        fill(&mut self.schema, ENV_SCHEMA);
458    }
459    /// The per-query context overrides (the `Some` fields override the session
460    /// base context).
461    fn overrides(&self) -> QueryContext {
462        QueryContext {
463            warehouse: self.warehouse.clone(),
464            role: self.role.clone(),
465            database: self.database.clone(),
466            schema: self.schema.clone(),
467        }
468    }
469}
470
471/// The running keep-alive heartbeat loop: cancelled and awaited on shutdown.
472struct HeartbeatTask {
473    token: CancellationToken,
474    handle: JoinHandle<()>,
475}
476
477/// The account-agnostic Snowflake query engine: lazy multiplexed auth, bounded
478/// per-identity session pools, and concurrent arbitrary-SQL execution.
479///
480/// A background keep-alive heartbeat for idle sessions is started by
481/// [`start_heartbeat`](Self::start_heartbeat) and stopped by
482/// [`shutdown`](Self::shutdown).
483pub struct SnowflakeEngine {
484    config: SnowflakeEngineConfig,
485    registry: PoolRegistry,
486    heartbeat: StdMutex<Option<HeartbeatTask>>,
487}
488
489impl SnowflakeEngine {
490    /// Builds an engine. Cheap — no eager auth or I/O.
491    #[must_use]
492    pub fn new(config: SnowflakeEngineConfig) -> Self {
493        Self {
494            config,
495            registry: PoolRegistry::new(),
496            heartbeat: StdMutex::new(None),
497        }
498    }
499
500    /// Starts the background keep-alive heartbeat loop (idempotent).
501    ///
502    /// Every `heartbeat_interval` the loop heartbeats each pool's idle sessions
503    /// — renewing a session token about to lapse — so the server-side
504    /// `CLIENT_SESSION_KEEP_ALIVE` actually extends the master token and an
505    /// idle pool survives past the token TTL without a new browser SSO (#1107).
506    /// Busy sessions are skipped; the query path keeps them alive inline.
507    ///
508    /// No-op when the interval is zero (disabled) or when called outside a
509    /// tokio runtime. Stopped by [`shutdown`](Self::shutdown).
510    pub fn start_heartbeat(&self) {
511        let interval = self.config.heartbeat_interval;
512        if interval.is_zero() {
513            return;
514        }
515        if tokio::runtime::Handle::try_current().is_err() {
516            tracing::debug!("no tokio runtime; Snowflake keep-alive heartbeat not started");
517            return;
518        }
519        let mut guard = self.lock_heartbeat();
520        if guard.is_some() {
521            return;
522        }
523        let token = CancellationToken::new();
524        let loop_token = token.clone();
525        let registry = self.registry.clone();
526        let handle = tokio::spawn(async move {
527            loop {
528                tokio::select! {
529                    () = loop_token.cancelled() => break,
530                    () = tokio::time::sleep(interval) => {
531                        heartbeat_all_pools(&registry, interval).await;
532                    }
533                }
534            }
535        });
536        *guard = Some(HeartbeatTask { token, handle });
537    }
538
539    fn lock_heartbeat(&self) -> MutexGuard<'_, Option<HeartbeatTask>> {
540        self.heartbeat
541            .lock()
542            .unwrap_or_else(std::sync::PoisonError::into_inner)
543    }
544
545    /// A snapshot of every active pool.
546    #[must_use]
547    pub fn sessions(&self) -> Vec<SessionInfo> {
548        self.registry.snapshot()
549    }
550
551    /// The number of active pools (`(account, user)` identities).
552    #[must_use]
553    pub fn pool_count(&self) -> usize {
554        self.registry.len()
555    }
556
557    /// Evicts the pool for `(account, user)`. Returns whether one existed.
558    pub fn disconnect(&self, account: &str, user: &str) -> bool {
559        let key = SessionKey::new(normalize_account(account), user.trim());
560        self.registry.remove(&key).is_some()
561    }
562
563    /// Evicts the pool with the given id. Returns whether one existed.
564    pub fn disconnect_by_id(&self, id: u64) -> bool {
565        self.registry.remove_by_id(id).is_some()
566    }
567
568    /// Evicts every pool. Returns how many were removed.
569    pub fn disconnect_all(&self) -> usize {
570        self.registry.take_all().len()
571    }
572
573    /// Stops the keep-alive heartbeat, then drops every pool (and its sessions).
574    pub async fn shutdown(&self) {
575        let task = self.lock_heartbeat().take();
576        if let Some(task) = task {
577            task.token.cancel();
578            let _ = task.handle.await;
579        }
580        let pools = self.registry.take_all();
581        drop(pools);
582    }
583
584    /// Runs arbitrary SQL against the `(account, user)` pool, authenticating a
585    /// session on first use, and returns a self-describing `{ columns, rows }`
586    /// payload. Concurrent calls run on separate pooled sessions (up to the pool
587    /// size).
588    ///
589    /// # Errors
590    ///
591    /// Returns an error if no account/user can be resolved, a context flag is not
592    /// a valid identifier, authentication fails, or the query fails. On a
593    /// session-expiry error that session is discarded and the next query
594    /// re-authenticates.
595    pub async fn query(&self, req: QueryRequest) -> Result<Value> {
596        let account = normalize_account(
597            req.account
598                .as_deref()
599                .or(self.config.default_account.as_deref())
600                .ok_or_else(|| {
601                    anyhow!("no Snowflake account: pass --account or set SNOWFLAKE_ACCOUNT")
602                })?,
603        );
604        let user = req
605            .user
606            .as_deref()
607            .or(self.config.default_user.as_deref())
608            .ok_or_else(|| anyhow!("no Snowflake user: pass --user or set SNOWFLAKE_USER"))?
609            .trim()
610            .to_string();
611        validate_context(&req)?;
612
613        let key = SessionKey::new(account, user);
614        let overrides = req.overrides();
615        let pool = self.registry.get_or_create(&key, self.config.pool_size);
616
617        // Check out a session. The pool reuses an idle one when available
618        // (re-checking after the auth gate so a session freed mid-auth is reused),
619        // and only authenticates a new one — serialized to one browser at a time
620        // by the pool's shared auth gate — when none is idle and it is under
621        // capacity. The permit inside the checkout caps concurrency at pool_size.
622        let cfg = self.config.clone();
623        let create_key = key.clone();
624        let auth_timeout = self.config.auth_timeout;
625        let checkout = pool
626            .checkout(move || async move {
627                // Overall sign-in deadline so a hung auth releases the gate.
628                match tokio::time::timeout(
629                    auth_timeout,
630                    create_session_with_base(&create_key, &cfg),
631                )
632                .await
633                {
634                    Ok(result) => result,
635                    Err(_) => Err(ClientError::Auth(format!(
636                        "Snowflake sign-in timed out after {auth_timeout:?}"
637                    ))),
638                }
639            })
640            .await
641            .map_err(|e| {
642                anyhow::Error::new(e).context(format!(
643                    "failed to authenticate Snowflake session for {} / {}",
644                    key.account, key.user
645                ))
646            })?;
647
648        // Proactively renew a session whose token is about to expire, before use.
649        if checkout
650            .session()
651            .session_expiring_within(TimeDelta::seconds(120))
652            && checkout.session().renew().await.is_err()
653        {
654            pool.discard(checkout);
655            return Err(anyhow!(
656                "Snowflake session expired and was discarded — re-run the query to re-authenticate"
657            ));
658        }
659
660        // Record what this member is now running, so menus/status show it.
661        pool.start_query(checkout.id(), sql_preview(&req.sql, SQL_PREVIEW_MAX));
662
663        // Apply the requested context and run the query, transparently renewing
664        // the token and retrying once if it expires mid-flight.
665        let target = checkout.base().overlay(&overrides);
666        match run_with_renew(checkout.session(), checkout.current(), &target, &req.sql).await {
667            Ok(rows) => {
668                pool.touch();
669                pool.checkin(checkout, target);
670                Ok(client::rows_to_payload(&rows))
671            }
672            Err(e) if e.is_session_expired() => {
673                pool.discard(checkout);
674                Err(anyhow!(
675                    "Snowflake session expired and was discarded — re-run the query to re-authenticate"
676                ))
677            }
678            Err(e) => {
679                // Log the underlying cause server-side and surface it to the
680                // client (the daemon reply uses the full anyhow chain).
681                tracing::warn!("Snowflake query failed: {e}");
682                // The session's context is uncertain after a failure; check in
683                // with an empty context so the next reuse re-applies every dimension.
684                pool.checkin(checkout, QueryContext::default());
685                Err(anyhow::Error::new(e).context("Snowflake query failed"))
686            }
687        }
688    }
689}
690
691impl Drop for SnowflakeEngine {
692    fn drop(&mut self) {
693        // Best-effort: an engine dropped without `shutdown()` must not leave the
694        // heartbeat loop running forever. Cancellation is sync; the task itself
695        // is detached and exits on its next select.
696        let task = self.lock_heartbeat().take();
697        if let Some(task) = task {
698            task.token.cancel();
699        }
700    }
701}
702
703/// Sends one keep-alive round to every pool's currently-idle sessions,
704/// discarding any session that is dead beyond renewal.
705async fn heartbeat_all_pools(registry: &PoolRegistry, interval: Duration) {
706    for pool in registry.pools() {
707        let checkouts = pool.checkout_all_idle();
708        if checkouts.is_empty() {
709            continue;
710        }
711        let total = checkouts.len();
712        let mut kept = 0usize;
713        for checkout in checkouts {
714            if keep_session_alive(checkout.session(), interval).await {
715                pool.restore(checkout);
716                kept += 1;
717            } else {
718                pool.discard(checkout);
719            }
720        }
721        tracing::debug!(
722            pool = pool.id(),
723            kept,
724            total,
725            "Snowflake keep-alive heartbeat round"
726        );
727    }
728}
729
730/// Keeps one idle session alive: proactively renews a session token that would
731/// lapse before the next tick (the heartbeat itself is authorized by the
732/// session token), then heartbeats so the server extends the master token.
733///
734/// Returns whether the session is still usable: `false` only when the master
735/// token has expired (a full re-auth is unavoidable), so the caller discards
736/// it. Transient errors keep the session for the next tick.
737async fn keep_session_alive(session: &SnowflakeSession, interval: Duration) -> bool {
738    let margin_secs = i64::try_from(interval.as_secs())
739        .unwrap_or(i64::MAX)
740        .saturating_add(HEARTBEAT_RENEW_MARGIN_SECS);
741    let margin = TimeDelta::try_seconds(margin_secs).unwrap_or(TimeDelta::MAX);
742    if session.session_expiring_within(margin) {
743        match session.renew().await {
744            Ok(()) => {}
745            Err(e) if e.is_session_expired() => {
746                tracing::warn!("Snowflake keep-alive: master token expired; discarding session");
747                return false;
748            }
749            Err(e) => {
750                // Transient: keep the session and try again next tick.
751                tracing::warn!("Snowflake keep-alive renew failed: {e}");
752                return true;
753            }
754        }
755    }
756    match session.heartbeat().await {
757        Ok(()) => true,
758        Err(e) if e.is_session_expired() => match session.renew().await {
759            Ok(()) => true,
760            Err(renew_err) if renew_err.is_session_expired() => {
761                tracing::warn!("Snowflake keep-alive: master token expired; discarding session");
762                false
763            }
764            Err(renew_err) => {
765                tracing::warn!("Snowflake keep-alive renew failed: {renew_err}");
766                true
767            }
768        },
769        Err(e) => {
770            tracing::warn!("Snowflake keep-alive heartbeat failed: {e}");
771            true
772        }
773    }
774}
775
776/// Authenticates a session (via the engine's configured auth method), enables
777/// keep-alive, and captures its base (account/user default) context.
778async fn create_session_with_base(
779    key: &SessionKey,
780    config: &SnowflakeEngineConfig,
781) -> client::Result<(SnowflakeSession, QueryContext)> {
782    let mut cfg = SnowflakeClientConfig::external_browser(&key.account, &key.user);
783    cfg.auth = config.auth.clone();
784    cfg.host = config.default_host.clone();
785    cfg.warehouse = config.default_warehouse.clone();
786    cfg.role = config.default_role.clone();
787    cfg.database = config.default_database.clone();
788    cfg.schema = config.default_schema.clone();
789    cfg.http_timeout = config.http_timeout;
790    cfg.query_timeout = config.query_timeout;
791
792    let client = SnowflakeClient::new(cfg)?;
793    let session = client.create_session().await?;
794    session
795        .query("ALTER SESSION SET CLIENT_SESSION_KEEP_ALIVE = true")
796        .await?;
797    let base = capture_base_context(&session).await?;
798    Ok((session, base))
799}
800
801/// Reads the session's effective default context so per-query overrides can
802/// later be reset back to it.
803async fn capture_base_context(session: &SnowflakeSession) -> client::Result<QueryContext> {
804    let rows = session
805        .query("SELECT CURRENT_WAREHOUSE(), CURRENT_ROLE(), CURRENT_DATABASE(), CURRENT_SCHEMA()")
806        .await?;
807    let Some(row) = rows.first() else {
808        return Ok(QueryContext::default());
809    };
810    Ok(QueryContext {
811        warehouse: row.raw_at(0).map(str::to_string),
812        role: row.raw_at(1).map(str::to_string),
813        database: row.raw_at(2).map(str::to_string),
814        schema: row.raw_at(3).map(str::to_string),
815    })
816}
817
818/// Applies the context and runs the SQL, transparently renewing the session
819/// token (via the master token) and retrying once if it expired mid-flight.
820async fn run_with_renew(
821    session: &SnowflakeSession,
822    current: &QueryContext,
823    target: &QueryContext,
824    sql: &str,
825) -> client::Result<Vec<Row>> {
826    match apply_and_query(session, current, target, sql).await {
827        Err(e) if e.is_session_expired() => {
828            session.renew().await?;
829            // Re-apply the full context on the renewed session, then retry.
830            apply_and_query(session, &QueryContext::default(), target, sql).await
831        }
832        other => other,
833    }
834}
835
836/// Issues any needed `USE` statements, then runs the SQL.
837async fn apply_and_query(
838    session: &SnowflakeSession,
839    current: &QueryContext,
840    target: &QueryContext,
841    sql: &str,
842) -> client::Result<Vec<Row>> {
843    apply_context(session, current, target).await?;
844    session.query(sql).await
845}
846
847/// Issues `USE` for each context dimension whose target differs from the
848/// session's current value. Target names are either validated user overrides or
849/// Snowflake-reported base names.
850async fn apply_context(
851    session: &SnowflakeSession,
852    current: &QueryContext,
853    target: &QueryContext,
854) -> client::Result<()> {
855    for (keyword, cur, tgt) in [
856        (
857            "WAREHOUSE",
858            current.warehouse.as_deref(),
859            target.warehouse.as_deref(),
860        ),
861        ("ROLE", current.role.as_deref(), target.role.as_deref()),
862        (
863            "DATABASE",
864            current.database.as_deref(),
865            target.database.as_deref(),
866        ),
867        (
868            "SCHEMA",
869            current.schema.as_deref(),
870            target.schema.as_deref(),
871        ),
872    ] {
873        if let Some(name) = tgt {
874            if cur != Some(name) {
875                session
876                    .query(format!("USE {keyword} {name}").as_str())
877                    .await?;
878            }
879        }
880    }
881    Ok(())
882}
883
884/// Normalizes an account identifier for keying (Snowflake is case-insensitive).
885fn normalize_account(account: &str) -> String {
886    account.trim().to_ascii_uppercase()
887}
888
889/// A single-line, length-bounded preview of SQL for the "running" display
890/// (collapses whitespace/newlines; appends `…` when truncated).
891fn sql_preview(sql: &str, max: usize) -> String {
892    let collapsed = sql.split_whitespace().collect::<Vec<_>>().join(" ");
893    if collapsed.chars().count() > max {
894        let head: String = collapsed.chars().take(max).collect();
895        format!("{}…", head.trim_end())
896    } else {
897        collapsed
898    }
899}
900
901/// Validates every present context flag as a safe Snowflake identifier before it
902/// is interpolated into a `USE …` statement.
903fn validate_context(req: &QueryRequest) -> Result<()> {
904    for (name, value) in [
905        ("warehouse", req.warehouse.as_deref()),
906        ("role", req.role.as_deref()),
907        ("database", req.database.as_deref()),
908        ("schema", req.schema.as_deref()),
909    ] {
910        if let Some(value) = value {
911            validate_identifier(name, value)?;
912        }
913    }
914    Ok(())
915}
916
917/// Rejects context values that are not bare Snowflake identifiers (letters,
918/// digits, `_`, `$`, `.`), so a `--warehouse` flag cannot smuggle extra SQL into
919/// the `USE …` statement.
920fn validate_identifier(field: &str, value: &str) -> Result<()> {
921    if value.is_empty() {
922        bail!("--{field} must not be empty");
923    }
924    if !value
925        .chars()
926        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '.'))
927    {
928        bail!(
929            "--{field} '{value}' is not a valid Snowflake identifier \
930             (allowed: letters, digits, '_', '$', '.')"
931        );
932    }
933    Ok(())
934}
935
936#[cfg(test)]
937#[allow(clippy::unwrap_used, clippy::expect_used)]
938mod tests {
939    use super::*;
940    use crate::test_support::env::MapEnv;
941
942    #[test]
943    fn default_config_has_a_nonzero_pool_size() {
944        assert!(SnowflakeEngineConfig::default().pool_size >= 1);
945    }
946
947    #[test]
948    fn heartbeat_interval_from_parses_seconds_zero_and_garbage() {
949        assert_eq!(heartbeat_interval_from(None), DEFAULT_HEARTBEAT_INTERVAL);
950        assert_eq!(
951            heartbeat_interval_from(Some("300".to_string())),
952            Duration::from_secs(300)
953        );
954        // `0` is meaningful: it disables the heartbeat.
955        assert_eq!(
956            heartbeat_interval_from(Some(" 0 ".to_string())),
957            Duration::ZERO
958        );
959        assert_eq!(
960            heartbeat_interval_from(Some("garbage".to_string())),
961            DEFAULT_HEARTBEAT_INTERVAL
962        );
963    }
964
965    #[test]
966    fn host_override_from_trims_and_treats_blank_as_unset() {
967        assert_eq!(host_override_from(None), None);
968        assert_eq!(host_override_from(Some("   ".to_string())), None);
969        assert_eq!(
970            host_override_from(Some(
971                "  acct.privatelink.snowflakecomputing.com\n".to_string()
972            )),
973            Some("acct.privatelink.snowflakecomputing.com".to_string())
974        );
975    }
976
977    #[test]
978    fn split_browser_command_splits_on_unquoted_whitespace() {
979        assert_eq!(
980            split_browser_command("chrome --new-window {url}").unwrap(),
981            vec!["chrome", "--new-window", "{url}"]
982        );
983    }
984
985    #[test]
986    fn split_browser_command_keeps_quoted_spaces_together() {
987        // The motivating case: a program path and an argument value both with
988        // spaces, plus the `{url}` placeholder left intact for later substitution.
989        assert_eq!(
990            split_browser_command(
991                "'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' \
992                 --profile-directory=\"Profile 1\" --new-window {url}"
993            )
994            .unwrap(),
995            vec![
996                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
997                "--profile-directory=Profile 1",
998                "--new-window",
999                "{url}",
1000            ]
1001        );
1002    }
1003
1004    #[test]
1005    fn split_browser_command_handles_backslash_escapes() {
1006        // An unquoted backslash escapes the next char; inside double quotes only
1007        // `\"` and `\\` are escapes, so other backslashes (Windows paths) survive.
1008        assert_eq!(
1009            split_browser_command(r#"chrome a\ b "c\"d" "e\\f" "g\h""#).unwrap(),
1010            vec!["chrome", "a b", "c\"d", "e\\f", "g\\h"]
1011        );
1012    }
1013
1014    #[test]
1015    fn split_browser_command_rejects_unterminated_quotes() {
1016        assert!(split_browser_command("chrome \"--flag").is_err());
1017        assert!(split_browser_command("chrome '--flag").is_err());
1018    }
1019
1020    #[test]
1021    fn split_browser_command_rejects_an_empty_command() {
1022        assert!(split_browser_command("   ").is_err());
1023        assert!(split_browser_command("").is_err());
1024    }
1025
1026    #[test]
1027    fn resolve_auth_method_defaults_to_external_browser() {
1028        // Unset, blank, and the explicit name all keep interactive SSO.
1029        for selector in [
1030            None,
1031            Some(""),
1032            Some("  "),
1033            Some("externalbrowser"),
1034            Some("EXTERNALBROWSER"),
1035        ] {
1036            assert!(matches!(
1037                resolve_auth_method(selector, None, None, None, None).unwrap(),
1038                AuthMethod::ExternalBrowser(BrowserConfig {
1039                    launch: BrowserLaunch::Auto,
1040                    ..
1041                })
1042            ));
1043        }
1044    }
1045
1046    #[test]
1047    fn resolve_auth_method_threads_browser_command() {
1048        let auth = resolve_auth_method(
1049            Some("externalbrowser"),
1050            Some(
1051                "\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\" \
1052                 --profile-directory=\"Profile 1\" --new-window {url}"
1053                    .to_string(),
1054            ),
1055            None,
1056            None,
1057            None,
1058        )
1059        .unwrap();
1060        let AuthMethod::ExternalBrowser(BrowserConfig {
1061            launch: BrowserLaunch::Command(args),
1062            ..
1063        }) = auth
1064        else {
1065            panic!("expected an external-browser Command launch");
1066        };
1067        assert_eq!(
1068            args,
1069            vec![
1070                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".to_string(),
1071                "--profile-directory=Profile 1".to_string(),
1072                "--new-window".to_string(),
1073                "{url}".to_string(),
1074            ]
1075        );
1076    }
1077
1078    #[test]
1079    fn resolve_auth_method_blank_browser_command_is_auto() {
1080        // A blank/whitespace command is treated as unset, not a parse error.
1081        assert!(matches!(
1082            resolve_auth_method(None, Some("   ".to_string()), None, None, None).unwrap(),
1083            AuthMethod::ExternalBrowser(BrowserConfig {
1084                launch: BrowserLaunch::Auto,
1085                ..
1086            })
1087        ));
1088    }
1089
1090    #[test]
1091    fn resolve_auth_method_rejects_a_malformed_browser_command() {
1092        let err = resolve_auth_method(None, Some("chrome \"--flag".to_string()), None, None, None)
1093            .unwrap_err();
1094        assert!(err.to_string().contains(ENV_BROWSER_COMMAND));
1095    }
1096
1097    #[test]
1098    fn resolve_auth_method_ignores_browser_command_for_non_interactive_methods() {
1099        // PAT and JWT open no browser, so a set command is harmlessly ignored.
1100        assert!(matches!(
1101            resolve_auth_method(
1102                Some("pat"),
1103                Some("chrome {url}".to_string()),
1104                Some("tok".to_string()),
1105                None,
1106                None,
1107            )
1108            .unwrap(),
1109            AuthMethod::ProgrammaticAccessToken { .. }
1110        ));
1111        assert!(matches!(
1112            resolve_auth_method(
1113                Some("snowflake_jwt"),
1114                Some("chrome {url}".to_string()),
1115                None,
1116                Some("pem".to_string()),
1117                None,
1118            )
1119            .unwrap(),
1120            AuthMethod::KeyPairJwt(_)
1121        ));
1122    }
1123
1124    #[test]
1125    fn resolve_auth_method_reads_pat_and_trims_it() {
1126        let auth = resolve_auth_method(
1127            Some("programmatic_access_token"),
1128            None,
1129            Some("  tok-123\n".to_string()),
1130            None,
1131            None,
1132        )
1133        .unwrap();
1134        let AuthMethod::ProgrammaticAccessToken { token } = auth else {
1135            panic!("expected a PAT auth method");
1136        };
1137        assert_eq!(token.expose_secret(), "tok-123");
1138    }
1139
1140    #[test]
1141    fn resolve_auth_method_accepts_the_pat_alias() {
1142        assert!(matches!(
1143            resolve_auth_method(Some("pat"), None, Some("t".to_string()), None, None).unwrap(),
1144            AuthMethod::ProgrammaticAccessToken { .. }
1145        ));
1146    }
1147
1148    #[test]
1149    fn resolve_auth_method_errors_when_pat_is_missing_or_blank() {
1150        assert!(
1151            resolve_auth_method(Some("programmatic_access_token"), None, None, None, None).is_err()
1152        );
1153        assert!(
1154            resolve_auth_method(Some("pat"), None, Some("   ".to_string()), None, None).is_err()
1155        );
1156    }
1157
1158    #[test]
1159    fn resolve_auth_method_reads_key_pair_pem() {
1160        let auth = resolve_auth_method(
1161            Some("snowflake_jwt"),
1162            None,
1163            None,
1164            Some("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----".to_string()),
1165            None,
1166        )
1167        .unwrap();
1168        let AuthMethod::KeyPairJwt(cfg) = auth else {
1169            panic!("expected a key-pair auth method");
1170        };
1171        assert!(cfg
1172            .private_key_pem
1173            .expose_secret()
1174            .contains("BEGIN PRIVATE KEY"));
1175    }
1176
1177    #[test]
1178    fn resolve_auth_method_accepts_key_pair_aliases() {
1179        for selector in ["snowflake_jwt", "keypair", "key_pair", "jwt"] {
1180            assert!(matches!(
1181                resolve_auth_method(Some(selector), None, None, Some("pem".to_string()), None)
1182                    .unwrap(),
1183                AuthMethod::KeyPairJwt(_)
1184            ));
1185        }
1186    }
1187
1188    #[test]
1189    fn resolve_auth_method_errors_when_key_is_missing() {
1190        assert!(resolve_auth_method(Some("snowflake_jwt"), None, None, None, None).is_err());
1191        assert!(resolve_auth_method(
1192            Some("snowflake_jwt"),
1193            None,
1194            None,
1195            Some("  ".to_string()),
1196            None
1197        )
1198        .is_err());
1199    }
1200
1201    #[test]
1202    fn resolve_auth_method_rejects_an_encrypted_key_passphrase() {
1203        let err = resolve_auth_method(
1204            Some("snowflake_jwt"),
1205            None,
1206            None,
1207            Some("pem".to_string()),
1208            Some("hunter2".to_string()),
1209        )
1210        .unwrap_err();
1211        assert!(err.to_string().contains(ENV_PRIVATE_KEY_PASSPHRASE));
1212    }
1213
1214    #[test]
1215    fn resolve_auth_method_rejects_an_unknown_selector() {
1216        let err = resolve_auth_method(Some("carrier-pigeon"), None, None, None, None).unwrap_err();
1217        assert!(err.to_string().contains("carrier-pigeon"));
1218    }
1219
1220    #[tokio::test]
1221    async fn start_heartbeat_is_a_noop_when_disabled() {
1222        let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1223            heartbeat_interval: Duration::ZERO,
1224            ..SnowflakeEngineConfig::default()
1225        });
1226        engine.start_heartbeat();
1227        assert!(engine.lock_heartbeat().is_none());
1228    }
1229
1230    #[test]
1231    fn start_heartbeat_is_a_noop_outside_a_runtime() {
1232        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1233        engine.start_heartbeat();
1234        assert!(engine.lock_heartbeat().is_none());
1235    }
1236
1237    #[tokio::test]
1238    async fn start_heartbeat_is_idempotent_and_shutdown_stops_it() {
1239        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1240        engine.start_heartbeat();
1241        // Cancelling the running task's token lets a replacement be detected: a
1242        // second start must keep this task, not spawn (and orphan) a fresh one.
1243        engine.lock_heartbeat().as_ref().unwrap().token.cancel();
1244        engine.start_heartbeat();
1245        assert!(
1246            engine
1247                .lock_heartbeat()
1248                .as_ref()
1249                .unwrap()
1250                .token
1251                .is_cancelled(),
1252            "second start must not replace the running task"
1253        );
1254        engine.shutdown().await;
1255        assert!(engine.lock_heartbeat().is_none());
1256    }
1257
1258    #[test]
1259    fn normalize_account_uppercases_and_trims() {
1260        assert_eq!(normalize_account("  my-org.acct  "), "MY-ORG.ACCT");
1261    }
1262
1263    #[test]
1264    fn validate_identifier_accepts_bare_identifiers() {
1265        for value in ["WH", "my_wh", "DB.SCHEMA", "wh$1"] {
1266            assert!(validate_identifier("warehouse", value).is_ok(), "{value}");
1267        }
1268    }
1269
1270    #[test]
1271    fn validate_identifier_rejects_injection_and_empty() {
1272        for value in ["", "wh; DROP TABLE t", "wh OR 1=1", "wh'", "a b"] {
1273            assert!(validate_identifier("warehouse", value).is_err(), "{value}");
1274        }
1275    }
1276
1277    #[test]
1278    fn fill_defaults_from_fills_unset_fields() {
1279        let env = MapEnv::new()
1280            .with(ENV_ACCOUNT, "ACCT")
1281            .with(ENV_USER, "me")
1282            .with(ENV_WAREHOUSE, "WH")
1283            .with(ENV_ROLE, "R")
1284            .with(ENV_DATABASE, "DB")
1285            .with(ENV_SCHEMA, "S");
1286        let mut req = QueryRequest {
1287            sql: "SELECT 1".to_string(),
1288            ..QueryRequest::default()
1289        };
1290        req.fill_defaults_from(&env);
1291        assert_eq!(req.account.as_deref(), Some("ACCT"));
1292        assert_eq!(req.user.as_deref(), Some("me"));
1293        assert_eq!(req.warehouse.as_deref(), Some("WH"));
1294        assert_eq!(req.role.as_deref(), Some("R"));
1295        assert_eq!(req.database.as_deref(), Some("DB"));
1296        assert_eq!(req.schema.as_deref(), Some("S"));
1297    }
1298
1299    #[test]
1300    fn fill_defaults_from_keeps_explicit_values() {
1301        let env = MapEnv::new()
1302            .with(ENV_ACCOUNT, "ENV_ACCT")
1303            .with(ENV_USER, "env_user");
1304        let mut req = QueryRequest {
1305            account: Some("FLAG_ACCT".to_string()),
1306            sql: "SELECT 1".to_string(),
1307            ..QueryRequest::default()
1308        };
1309        req.fill_defaults_from(&env);
1310        assert_eq!(req.account.as_deref(), Some("FLAG_ACCT"));
1311        assert_eq!(req.user.as_deref(), Some("env_user"));
1312    }
1313
1314    #[test]
1315    fn fill_defaults_from_leaves_unresolved_fields_none() {
1316        let mut req = QueryRequest {
1317            sql: "SELECT 1".to_string(),
1318            ..QueryRequest::default()
1319        };
1320        req.fill_defaults_from(&MapEnv::new());
1321        assert!(req.account.is_none());
1322        assert!(req.user.is_none());
1323        assert!(req.warehouse.is_none());
1324    }
1325
1326    #[test]
1327    fn query_request_serializes_without_none_fields() {
1328        let req = QueryRequest {
1329            account: Some("ACCT".to_string()),
1330            sql: "SELECT 1".to_string(),
1331            ..QueryRequest::default()
1332        };
1333        let value = serde_json::to_value(&req).unwrap();
1334        assert_eq!(
1335            value,
1336            serde_json::json!({ "account": "ACCT", "sql": "SELECT 1" })
1337        );
1338    }
1339
1340    #[test]
1341    fn validate_context_checks_each_present_flag() {
1342        let mut req = QueryRequest {
1343            sql: "SELECT 1".to_string(),
1344            ..QueryRequest::default()
1345        };
1346        assert!(validate_context(&req).is_ok());
1347        req.role = Some("good_role".to_string());
1348        assert!(validate_context(&req).is_ok());
1349        req.database = Some("bad; drop".to_string());
1350        assert!(validate_context(&req).is_err());
1351    }
1352
1353    #[tokio::test]
1354    async fn query_without_account_errors_without_network() {
1355        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1356        let err = engine
1357            .query(QueryRequest {
1358                sql: "SELECT 1".to_string(),
1359                ..QueryRequest::default()
1360            })
1361            .await
1362            .unwrap_err();
1363        assert!(err.to_string().contains("account"));
1364    }
1365
1366    #[tokio::test]
1367    async fn query_without_user_errors_without_network() {
1368        let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1369            default_account: Some("ACCT".to_string()),
1370            ..SnowflakeEngineConfig::default()
1371        });
1372        let err = engine
1373            .query(QueryRequest {
1374                sql: "SELECT 1".to_string(),
1375                ..QueryRequest::default()
1376            })
1377            .await
1378            .unwrap_err();
1379        assert!(err.to_string().contains("user"));
1380    }
1381
1382    #[test]
1383    fn disconnect_and_sessions_on_empty_engine() {
1384        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1385        assert_eq!(engine.pool_count(), 0);
1386        assert!(engine.sessions().is_empty());
1387        assert!(!engine.disconnect("ACCT", "user"));
1388        assert!(!engine.disconnect_by_id(1));
1389        assert_eq!(engine.disconnect_all(), 0);
1390    }
1391
1392    #[test]
1393    fn sql_preview_collapses_whitespace_and_truncates() {
1394        // Whitespace/newlines collapse to single spaces; short SQL is unchanged.
1395        assert_eq!(sql_preview("SELECT   1\n  FROM t", 60), "SELECT 1 FROM t");
1396        // Over-length SQL is truncated with an ellipsis.
1397        let long = format!("SELECT {}", "a".repeat(100));
1398        let preview = sql_preview(&long, 20);
1399        assert!(preview.ends_with('…'));
1400        assert!(preview.chars().count() <= 21, "{preview}");
1401    }
1402
1403    #[test]
1404    fn overrides_extracts_only_the_set_dimensions() {
1405        let req = QueryRequest {
1406            warehouse: Some("WH".to_string()),
1407            schema: Some("S".to_string()),
1408            sql: "SELECT 1".to_string(),
1409            ..QueryRequest::default()
1410        };
1411        let overrides = req.overrides();
1412        assert_eq!(overrides.warehouse.as_deref(), Some("WH"));
1413        assert_eq!(overrides.schema.as_deref(), Some("S"));
1414        assert!(overrides.role.is_none());
1415        assert!(overrides.database.is_none());
1416    }
1417
1418    mod orchestration {
1419        use super::*;
1420        use serde_json::json;
1421        use wiremock::matchers::{method, path};
1422        use wiremock::{Mock, MockServer, ResponseTemplate};
1423
1424        /// Mounts a `query-request` handler that returns `data` for every POST.
1425        async fn mount_query(server: &MockServer, data: serde_json::Value) {
1426            Mock::given(method("POST"))
1427                .and(path("/queries/v1/query-request"))
1428                .respond_with(
1429                    ResponseTemplate::new(200)
1430                        .set_body_json(json!({ "success": true, "data": data })),
1431                )
1432                .mount(server)
1433                .await;
1434        }
1435
1436        #[tokio::test]
1437        async fn capture_base_context_reads_current_context() {
1438            let server = MockServer::start().await;
1439            mount_query(
1440                &server,
1441                json!({
1442                    "rowtype": [
1443                        { "name": "CURRENT_WAREHOUSE()", "type": "text" },
1444                        { "name": "CURRENT_ROLE()", "type": "text" },
1445                        { "name": "CURRENT_DATABASE()", "type": "text" },
1446                        { "name": "CURRENT_SCHEMA()", "type": "text" },
1447                    ],
1448                    "rowset": [["WH", "R", "DB", "S"]],
1449                }),
1450            )
1451            .await;
1452            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1453            let base = capture_base_context(&session).await.unwrap();
1454            assert_eq!(base.warehouse.as_deref(), Some("WH"));
1455            assert_eq!(base.role.as_deref(), Some("R"));
1456            assert_eq!(base.database.as_deref(), Some("DB"));
1457            assert_eq!(base.schema.as_deref(), Some("S"));
1458        }
1459
1460        #[tokio::test]
1461        async fn capture_base_context_defaults_when_no_rows() {
1462            let server = MockServer::start().await;
1463            mount_query(
1464                &server,
1465                json!({ "rowtype": [{ "name": "X", "type": "text" }], "rowset": [] }),
1466            )
1467            .await;
1468            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1469            assert_eq!(
1470                capture_base_context(&session).await.unwrap(),
1471                QueryContext::default()
1472            );
1473        }
1474
1475        #[tokio::test]
1476        async fn apply_context_issues_use_only_for_differing_dimensions() {
1477            let server = MockServer::start().await;
1478            mount_query(&server, json!({ "rowtype": [], "rowset": [] })).await;
1479            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1480
1481            let current = QueryContext {
1482                warehouse: Some("WH".to_string()),
1483                ..QueryContext::default()
1484            };
1485            let target = QueryContext {
1486                warehouse: Some("WH".to_string()), // same → no USE
1487                role: Some("R2".to_string()),      // differs → one USE
1488                ..QueryContext::default()
1489            };
1490            apply_context(&session, &current, &target).await.unwrap();
1491
1492            let reqs = server.received_requests().await.unwrap();
1493            assert_eq!(reqs.len(), 1, "only the differing dimension issues a USE");
1494            assert!(String::from_utf8_lossy(&reqs[0].body).contains("USE ROLE R2"));
1495        }
1496
1497        #[tokio::test]
1498        async fn run_with_renew_runs_without_renew_when_not_expired() {
1499            let server = MockServer::start().await;
1500            mount_query(
1501                &server,
1502                json!({ "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }),
1503            )
1504            .await;
1505            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1506            let ctx = QueryContext::default();
1507            let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
1508                .await
1509                .unwrap();
1510            assert_eq!(rows.len(), 1);
1511        }
1512
1513        #[tokio::test]
1514        async fn run_with_renew_renews_and_retries_once_on_expiry() {
1515            let server = MockServer::start().await;
1516            // First query attempt: session expired (then this rule is exhausted).
1517            Mock::given(method("POST"))
1518                .and(path("/queries/v1/query-request"))
1519                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1520                    "success": false, "code": "390112", "message": "expired", "data": {}
1521                })))
1522                .up_to_n_times(1)
1523                .with_priority(1)
1524                .mount(&server)
1525                .await;
1526            // Renew succeeds.
1527            Mock::given(method("POST"))
1528                .and(path("/session/token-request"))
1529                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1530                    "success": true,
1531                    "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
1532                })))
1533                .mount(&server)
1534                .await;
1535            // The retried query succeeds.
1536            Mock::given(method("POST"))
1537                .and(path("/queries/v1/query-request"))
1538                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1539                    "success": true,
1540                    "data": { "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }
1541                })))
1542                .with_priority(2)
1543                .mount(&server)
1544                .await;
1545
1546            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1547            let ctx = QueryContext::default();
1548            let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
1549                .await
1550                .unwrap();
1551            assert_eq!(rows.len(), 1, "renewed and retried transparently");
1552        }
1553    }
1554
1555    mod keep_alive {
1556        use super::*;
1557        use serde_json::json;
1558        use wiremock::matchers::{method, path};
1559        use wiremock::{Mock, MockServer, ResponseTemplate};
1560
1561        /// A short interval so `session_expiring_within(interval + margin)` is
1562        /// false for the test session's fresh 3600s token.
1563        const INTERVAL: Duration = Duration::from_secs(60);
1564
1565        /// Mounts a `session/heartbeat` handler answering with `body`.
1566        async fn mount_heartbeat(server: &MockServer, body: serde_json::Value) {
1567            Mock::given(method("POST"))
1568                .and(path("/session/heartbeat"))
1569                .respond_with(ResponseTemplate::new(200).set_body_json(body))
1570                .mount(server)
1571                .await;
1572        }
1573
1574        /// Mounts a `session/token-request` (renew) handler answering with `body`.
1575        async fn mount_renew(server: &MockServer, body: serde_json::Value) {
1576            Mock::given(method("POST"))
1577                .and(path("/session/token-request"))
1578                .respond_with(ResponseTemplate::new(200).set_body_json(body))
1579                .mount(server)
1580                .await;
1581        }
1582
1583        fn ok_body() -> serde_json::Value {
1584            json!({ "success": true, "data": {} })
1585        }
1586
1587        fn renew_ok_body() -> serde_json::Value {
1588            json!({
1589                "success": true,
1590                "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
1591            })
1592        }
1593
1594        fn expired_body() -> serde_json::Value {
1595            json!({ "success": false, "code": "390112", "message": "expired", "data": {} })
1596        }
1597
1598        #[tokio::test]
1599        async fn keep_session_alive_heartbeats_a_healthy_session() {
1600            let server = MockServer::start().await;
1601            mount_heartbeat(&server, ok_body()).await;
1602            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1603            assert!(keep_session_alive(&session, INTERVAL).await);
1604            let reqs = server.received_requests().await.unwrap();
1605            assert_eq!(reqs.len(), 1, "one heartbeat, no renew");
1606        }
1607
1608        #[tokio::test]
1609        async fn keep_session_alive_renews_when_the_heartbeat_reports_expiry() {
1610            let server = MockServer::start().await;
1611            mount_heartbeat(&server, expired_body()).await;
1612            mount_renew(&server, renew_ok_body()).await;
1613            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1614            assert!(keep_session_alive(&session, INTERVAL).await);
1615            let reqs = server.received_requests().await.unwrap();
1616            assert!(
1617                reqs.iter()
1618                    .any(|r| r.url.path() == "/session/token-request"),
1619                "renewed after the expired heartbeat"
1620            );
1621        }
1622
1623        #[tokio::test]
1624        async fn keep_session_alive_discards_when_the_master_token_is_dead() {
1625            let server = MockServer::start().await;
1626            mount_heartbeat(&server, expired_body()).await;
1627            mount_renew(&server, expired_body()).await;
1628            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1629            assert!(!keep_session_alive(&session, INTERVAL).await);
1630        }
1631
1632        #[tokio::test]
1633        async fn keep_session_alive_keeps_the_session_on_transient_errors() {
1634            let server = MockServer::start().await;
1635            mount_heartbeat(
1636                &server,
1637                json!({ "success": false, "code": "390001", "message": "hiccup", "data": {} }),
1638            )
1639            .await;
1640            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1641            assert!(keep_session_alive(&session, INTERVAL).await);
1642        }
1643
1644        #[tokio::test]
1645        async fn keep_session_alive_proactively_renews_a_token_expiring_before_the_next_tick() {
1646            let server = MockServer::start().await;
1647            mount_heartbeat(&server, ok_body()).await;
1648            mount_renew(&server, renew_ok_body()).await;
1649            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1650            // interval + margin exceeds the fresh 3600s validity → renew first.
1651            assert!(keep_session_alive(&session, Duration::from_secs(7200)).await);
1652            let reqs = server.received_requests().await.unwrap();
1653            assert_eq!(
1654                reqs[0].url.path(),
1655                "/session/token-request",
1656                "renew ran first"
1657            );
1658            assert_eq!(reqs[1].url.path(), "/session/heartbeat");
1659        }
1660
1661        #[tokio::test]
1662        async fn engine_heartbeat_loop_beats_idle_sessions_and_stops_on_shutdown() {
1663            let server = MockServer::start().await;
1664            mount_heartbeat(&server, ok_body()).await;
1665
1666            let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1667                heartbeat_interval: Duration::from_millis(50),
1668                ..SnowflakeEngineConfig::default()
1669            });
1670            // Park one idle session in a pool, bypassing the (live-only) SSO.
1671            let pool = engine
1672                .registry
1673                .get_or_create(&SessionKey::new("ACCT", "user"), 2);
1674            let uri = server.uri();
1675            let checkout = pool
1676                .checkout(|| async {
1677                    Ok::<_, std::convert::Infallible>((
1678                        client::test_session(&uri, Duration::from_secs(5)),
1679                        QueryContext::default(),
1680                    ))
1681                })
1682                .await
1683                .unwrap();
1684            pool.checkin(checkout, QueryContext::default());
1685
1686            engine.start_heartbeat();
1687            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1688            while server.received_requests().await.unwrap().is_empty() {
1689                assert!(
1690                    tokio::time::Instant::now() < deadline,
1691                    "no heartbeat within the deadline"
1692                );
1693                tokio::time::sleep(Duration::from_millis(10)).await;
1694            }
1695            // The borrowed session was restored, not consumed.
1696            assert_eq!(pool.live(), 1);
1697
1698            engine.shutdown().await;
1699            let after = server.received_requests().await.unwrap().len();
1700            tokio::time::sleep(Duration::from_millis(150)).await;
1701            assert_eq!(
1702                server.received_requests().await.unwrap().len(),
1703                after,
1704                "no heartbeats after shutdown"
1705            );
1706            assert_eq!(engine.pool_count(), 0, "pools drained on shutdown");
1707        }
1708    }
1709}