1#![cfg_attr(docsrs, feature(doc_cfg))]
2pub mod decode;
21pub mod encode;
22pub mod types;
23
24use faucet_core::FaucetError;
25use gcloud_gax::conn::Environment;
26use gcloud_spanner::admin::AdminClientConfig;
27use gcloud_spanner::admin::client::Client as AdminClient;
28use gcloud_spanner::client::google_cloud_auth::credentials::CredentialsFile;
29use gcloud_spanner::client::{Client, ClientConfig};
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use std::time::Duration;
33
34const SESSIONS_PER_CHANNEL: usize = 100;
37
38const SESSION_GET_TIMEOUT: Duration = Duration::from_secs(30);
42
43#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
54#[serde(tag = "type", content = "config", rename_all = "snake_case")]
55pub enum SpannerCredentials {
56 ServiceAccountKeyPath {
58 #[schemars(extend("x-faucet-resource" = "path"))]
60 path: String,
61 },
62 ServiceAccountKey {
65 json: String,
67 },
68 #[default]
72 ApplicationDefault,
73}
74
75impl std::fmt::Debug for SpannerCredentials {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::ServiceAccountKeyPath { path } => f
79 .debug_struct("ServiceAccountKeyPath")
80 .field("path", path)
81 .finish(),
82 Self::ServiceAccountKey { .. } => f
84 .debug_struct("ServiceAccountKey")
85 .field("json", &"***")
86 .finish(),
87 Self::ApplicationDefault => f.debug_struct("ApplicationDefault").finish(),
88 }
89 }
90}
91
92fn default_max_sessions() -> usize {
93 100
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99pub struct SpannerConnection {
100 pub project_id: String,
102 pub instance: String,
104 pub database: String,
106 #[serde(default)]
108 pub auth: SpannerCredentials,
109 #[serde(default = "default_max_sessions")]
112 pub max_sessions: usize,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
119 #[schemars(extend("x-faucet-resource" = "url"))]
120 pub emulator_host: Option<String>,
121}
122
123pub fn host_identity(config: &faucet_core::Value) -> Vec<String> {
128 use faucet_core::capabilities::config::{non_empty_str_at, tag_at};
129 let adc = tag_at(config, "/auth").unwrap_or("application_default") == "application_default";
130 if adc && !non_empty_str_at(config, "/emulator_host") {
131 vec!["auth".to_string()]
132 } else {
133 Vec::new()
134 }
135}
136
137impl SpannerConnection {
138 fn check_host_identity(&self) -> Result<(), FaucetError> {
141 let emulated = self.emulator_host.is_some()
142 || std::env::var_os("SPANNER_EMULATOR_HOST").is_some_and(|v| !v.is_empty());
143 if matches!(self.auth, SpannerCredentials::ApplicationDefault) && !emulated {
144 faucet_core::resolve::require_host_credentials("spanner", "auth")?;
145 }
146 Ok(())
147 }
148
149 pub fn database_path(&self) -> String {
152 format!(
153 "projects/{}/instances/{}/databases/{}",
154 self.project_id, self.instance, self.database
155 )
156 }
157
158 pub fn validate(&self) -> Result<(), FaucetError> {
160 for (field, value) in [
161 ("project_id", &self.project_id),
162 ("instance", &self.instance),
163 ("database", &self.database),
164 ] {
165 if value.trim().is_empty() {
166 return Err(FaucetError::Config(format!(
167 "spanner: `{field}` must not be empty"
168 )));
169 }
170 }
171 if self.max_sessions == 0 {
172 return Err(FaucetError::Config(
173 "spanner: `max_sessions` must be at least 1".into(),
174 ));
175 }
176 Ok(())
177 }
178
179 pub async fn connect(&self) -> Result<Client, FaucetError> {
182 self.validate()?;
183 self.check_host_identity()?;
184 let mut config = ClientConfig::default();
185 config.channel_config.num_channels = self.max_sessions.div_ceil(SESSIONS_PER_CHANNEL);
186 config.session_config.min_opened = 1;
187 config.session_config.max_idle = self.max_sessions;
188 config.session_config.max_opened = self.max_sessions;
189 config.session_config.session_get_timeout = SESSION_GET_TIMEOUT;
190 if let Some(host) = &self.emulator_host {
191 config.environment = Environment::Emulator(host.clone());
192 }
193 let config = self.apply_data_credentials(config).await?;
196 Client::new(self.database_path(), config)
197 .await
198 .map_err(|e| FaucetError::Custom(Box::new(e)))
199 }
200
201 pub async fn connect_admin(&self) -> Result<AdminClient, FaucetError> {
204 self.validate()?;
205 self.check_host_identity()?;
206 let mut config = AdminClientConfig::default();
207 if let Some(host) = &self.emulator_host {
208 config.environment = Environment::Emulator(host.clone());
209 }
210 let config = match &self.auth {
211 SpannerCredentials::ApplicationDefault => config
212 .with_auth()
213 .await
214 .map_err(|e| FaucetError::Auth(format!("spanner ADC: {e}")))?,
215 SpannerCredentials::ServiceAccountKeyPath { path } => config
216 .with_credentials(credentials_from_file(path).await?)
217 .await
218 .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}")))?,
219 SpannerCredentials::ServiceAccountKey { json } => config
220 .with_credentials(credentials_from_str(json).await?)
221 .await
222 .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}")))?,
223 };
224 AdminClient::new(config)
225 .await
226 .map_err(|e| FaucetError::Custom(Box::new(e)))
227 }
228
229 async fn apply_data_credentials(
230 &self,
231 config: ClientConfig,
232 ) -> Result<ClientConfig, FaucetError> {
233 match &self.auth {
234 SpannerCredentials::ApplicationDefault => config
235 .with_auth()
236 .await
237 .map_err(|e| FaucetError::Auth(format!("spanner ADC: {e}"))),
238 SpannerCredentials::ServiceAccountKeyPath { path } => config
239 .with_credentials(credentials_from_file(path).await?)
240 .await
241 .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}"))),
242 SpannerCredentials::ServiceAccountKey { json } => config
243 .with_credentials(credentials_from_str(json).await?)
244 .await
245 .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}"))),
246 }
247 }
248}
249
250async fn credentials_from_file(path: &str) -> Result<CredentialsFile, FaucetError> {
251 faucet_core::resolve::check_google_credentials_file(
252 "spanner",
253 "auth",
254 std::path::Path::new(path),
255 )?;
256 CredentialsFile::new_from_file(path.to_string())
257 .await
258 .map_err(|e| FaucetError::Auth(format!("spanner key file {path}: {e}")))
259}
260
261async fn credentials_from_str(json: &str) -> Result<CredentialsFile, FaucetError> {
262 faucet_core::resolve::check_google_credentials("spanner", "auth", json)?;
263 CredentialsFile::new_from_str(json)
264 .await
265 .map_err(|e| FaucetError::Auth(format!("spanner inline key: {e}")))
266}
267
268pub fn quote_ident_spanner(ident: &str) -> String {
272 format!("`{}`", ident.replace('\\', "\\\\").replace('`', "\\`"))
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use serde_json::json;
279
280 #[test]
281 fn host_identity_follows_the_selected_credentials() {
282 assert_eq!(host_identity(&json!({})), vec!["auth"]);
283 assert!(host_identity(&json!({"emulator_host": "localhost:9010"})).is_empty());
284 for t in ["service_account_key_path", "service_account_key"] {
285 assert!(
286 host_identity(&json!({"auth": {"type": t}})).is_empty(),
287 "{t}"
288 );
289 }
290 }
291
292 #[tokio::test]
293 async fn caller_runs_take_only_service_account_keys() {
294 if std::env::var_os("SPANNER_EMULATOR_HOST").is_some() {
295 return;
296 }
297 let conn: SpannerConnection = serde_json::from_value(json!({
298 "project_id": "p", "instance": "i", "database": "d",
299 "auth": {"type": "service_account_key", "config": {
300 "json": "{\"type\": \"external_account\", \"token_url\": \"http://127.0.0.1:1/t\"}"
301 }}
302 }))
303 .unwrap();
304 let caller = std::sync::Arc::new(
305 faucet_core::resolve::ReferencePolicy::deny_all()
306 .with_path_roots(["/srv/data"])
307 .any_url_scheme()
308 .any_url_host(),
309 );
310 faucet_core::http::with_reference_policy(caller, async {
311 for err in [
312 conn.connect().await.err().expect("refused").to_string(),
313 conn.connect_admin()
314 .await
315 .err()
316 .expect("refused")
317 .to_string(),
318 ] {
319 assert!(err.contains("spanner: `auth`"), "{err}");
320 assert!(err.contains("external_account"), "{err}");
321 }
322 })
323 .await;
324 }
325
326 #[tokio::test]
327 async fn ambient_clients_are_refused_inside_a_caller_run() {
328 if std::env::var_os("SPANNER_EMULATOR_HOST").is_some() {
329 return;
330 }
331 let conn: SpannerConnection = serde_json::from_value(json!({
332 "project_id": "p", "instance": "i", "database": "d"
333 }))
334 .unwrap();
335 faucet_core::resolve::with_host_credentials(false, async {
336 let err = conn.connect().await.err().expect("refused").to_string();
337 assert!(err.contains("spanner: `auth`"), "{err}");
338 let err = conn
339 .connect_admin()
340 .await
341 .err()
342 .expect("refused")
343 .to_string();
344 assert!(err.contains("spanner: `auth`"), "{err}");
345 let emulated = SpannerConnection {
346 emulator_host: Some("127.0.0.1:1".into()),
347 ..conn.clone()
348 };
349 assert!(emulated.check_host_identity().is_ok());
350 })
351 .await;
352 }
353
354 #[test]
355 fn credentials_serde_shape_is_adjacently_tagged() {
356 let c: SpannerCredentials = serde_json::from_value(json!({
357 "type": "service_account_key_path",
358 "config": {"path": "/tmp/sa.json"}
359 }))
360 .unwrap();
361 assert!(matches!(
362 c,
363 SpannerCredentials::ServiceAccountKeyPath { ref path } if path == "/tmp/sa.json"
364 ));
365 let adc: SpannerCredentials =
366 serde_json::from_value(json!({"type": "application_default"})).unwrap();
367 assert!(matches!(adc, SpannerCredentials::ApplicationDefault));
368 assert!(matches!(
369 SpannerCredentials::default(),
370 SpannerCredentials::ApplicationDefault
371 ));
372 }
373
374 #[test]
375 fn debug_masks_inline_key_material() {
376 let c = SpannerCredentials::ServiceAccountKey {
377 json: "{\"private_key\": \"SECRET\"}".into(),
378 };
379 let rendered = format!("{c:?}");
380 assert!(!rendered.contains("SECRET"));
381 assert!(rendered.contains("***"));
382 }
383
384 fn conn() -> SpannerConnection {
385 SpannerConnection {
386 project_id: "p".into(),
387 instance: "i".into(),
388 database: "d".into(),
389 auth: SpannerCredentials::default(),
390 max_sessions: default_max_sessions(),
391 emulator_host: None,
392 }
393 }
394
395 #[test]
396 fn database_path_shape() {
397 assert_eq!(conn().database_path(), "projects/p/instances/i/databases/d");
398 }
399
400 #[test]
401 fn validate_rejects_empty_fields_and_zero_sessions() {
402 let mut c = conn();
403 c.project_id = " ".into();
404 assert!(matches!(c.validate(), Err(FaucetError::Config(_))));
405 let mut c = conn();
406 c.instance = String::new();
407 assert!(c.validate().is_err());
408 let mut c = conn();
409 c.database = String::new();
410 assert!(c.validate().is_err());
411 let mut c = conn();
412 c.max_sessions = 0;
413 assert!(c.validate().is_err());
414 assert!(conn().validate().is_ok());
415 }
416
417 #[test]
418 fn connection_defaults_from_minimal_config() {
419 let c: SpannerConnection = serde_json::from_value(json!({
420 "project_id": "p", "instance": "i", "database": "d"
421 }))
422 .unwrap();
423 assert_eq!(c.max_sessions, 100);
424 assert!(c.emulator_host.is_none());
425 assert!(matches!(c.auth, SpannerCredentials::ApplicationDefault));
426 }
427
428 #[test]
429 fn quote_ident_backslash_escapes() {
430 assert_eq!(quote_ident_spanner("plain"), "`plain`");
431 assert_eq!(quote_ident_spanner("we`ird"), "`we\\`ird`");
432 assert_eq!(quote_ident_spanner("a\\b"), "`a\\\\b`");
433 }
434
435 #[tokio::test]
436 async fn credentials_from_missing_file_is_a_typed_auth_error() {
437 let Err(err) = credentials_from_file("/definitely/not/here.json").await else {
438 panic!("missing key file unexpectedly loaded");
439 };
440 assert!(matches!(err, FaucetError::Auth(_)));
441 assert!(err.to_string().contains("/definitely/not/here.json"));
442 }
443
444 #[tokio::test]
445 async fn credentials_from_invalid_json_is_a_typed_auth_error() {
446 let Err(err) = credentials_from_str("{not json").await else {
447 panic!("invalid key json unexpectedly parsed");
448 };
449 assert!(matches!(err, FaucetError::Auth(_)));
450 assert!(err.to_string().contains("inline key"));
451 }
452
453 const FAKE_SA: &str = r#"{"type": "service_account", "project_id": "p"}"#;
456
457 #[tokio::test]
462 async fn connect_exercises_every_credential_variant_against_dead_emulator() {
463 let key_path = std::env::temp_dir().join("faucet-spanner-test-sa.json");
464 std::fs::write(&key_path, FAKE_SA).expect("write fake key");
465 let variants = [
466 SpannerCredentials::ApplicationDefault,
467 SpannerCredentials::ServiceAccountKey {
468 json: FAKE_SA.to_string(),
469 },
470 SpannerCredentials::ServiceAccountKeyPath {
471 path: key_path.to_string_lossy().into_owned(),
472 },
473 ];
474 for auth in variants {
475 let mut c = conn();
476 c.emulator_host = Some("127.0.0.1:1".into());
479 c.auth = auth.clone();
480 let Err(err) = c.connect().await else {
481 panic!("data-plane connect unexpectedly succeeded for {auth:?}");
482 };
483 assert!(
484 matches!(err, FaucetError::Custom(_)),
485 "data-plane connect: unexpected error {err:?} for {auth:?}"
486 );
487 let Err(err) = c.connect_admin().await else {
488 panic!("admin connect unexpectedly succeeded for {auth:?}");
489 };
490 assert!(
491 matches!(err, FaucetError::Custom(_)),
492 "admin connect: unexpected error {err:?} for {auth:?}"
493 );
494 }
495 }
496}