Skip to main content

faucet_common_gcs/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! Shared GCS credential and client construction for faucet source and
4//! sink connectors.
5
6use faucet_core::FaucetError;
7use google_cloud_storage::client::{Storage, StorageControl};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Credential source for a GCS client.
12///
13/// Serializes as `{ type: <method>, config: { … } }` (adjacent tagging,
14/// snake_case discriminators) — the consistent auth wire shape shared by
15/// every faucet connector:
16/// `{ type: service_account_json_file, config: { path: "/run/secrets/sa.json" } }`.
17#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
18#[serde(tag = "type", content = "config", rename_all = "snake_case")]
19pub enum GcsCredentials {
20    /// Path to a service-account JSON key file on disk.
21    ServiceAccountJsonFile { path: String },
22    /// Service-account JSON key as an inline string. Useful for
23    /// environment-variable injection via `${env:GCP_SA_JSON}` in CLI
24    /// configs.
25    ServiceAccountJsonInline { json: String },
26    /// Application Default Credentials — honours
27    /// `GOOGLE_APPLICATION_CREDENTIALS`, gcloud user creds, and the
28    /// GCE/GKE metadata server, in that order.
29    #[default]
30    ApplicationDefault,
31    /// Anonymous credentials. Use this with emulators (e.g.
32    /// `fake-gcs-server`) that do not validate bearer tokens — the SDK
33    /// otherwise tries to fetch ADC tokens at request time and fails in
34    /// environments without GCP credentials.
35    Anonymous,
36}
37
38/// Build a `google_cloud_auth::credentials::Credentials` from a faucet
39/// credential spec. All failures map to `FaucetError::Auth`.
40pub async fn build_credentials(
41    creds: &GcsCredentials,
42) -> Result<google_cloud_auth::credentials::Credentials, FaucetError> {
43    match creds {
44        GcsCredentials::ApplicationDefault => google_cloud_auth::credentials::Builder::default()
45            .build()
46            .map_err(|e| FaucetError::Auth(format!("GCS auth (ADC): {e}"))),
47        GcsCredentials::Anonymous => {
48            Ok(google_cloud_auth::credentials::anonymous::Builder::new().build())
49        }
50        GcsCredentials::ServiceAccountJsonFile { path } => {
51            let bytes = tokio::fs::read(path).await.map_err(|e| {
52                FaucetError::Auth(format!(
53                    "GCS auth: could not read service-account key from '{path}': {e}"
54                ))
55            })?;
56            let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
57                FaucetError::Auth(format!(
58                    "GCS auth: service-account key at '{path}' is not valid JSON: {e}"
59                ))
60            })?;
61            google_cloud_auth::credentials::service_account::Builder::new(value)
62                .build()
63                .map_err(|e| FaucetError::Auth(format!("GCS auth (service account): {e}")))
64        }
65        GcsCredentials::ServiceAccountJsonInline { json } => {
66            let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
67                FaucetError::Auth(format!(
68                    "GCS auth: inline service-account key is not valid JSON: {e}"
69                ))
70            })?;
71            google_cloud_auth::credentials::service_account::Builder::new(value)
72                .build()
73                .map_err(|e| FaucetError::Auth(format!("GCS auth (service account): {e}")))
74        }
75    }
76}
77
78/// Build a data-plane [`Storage`] client. Accepts an optional storage-host
79/// override for integration tests (e.g. fake-gcs-server at
80/// `http://localhost:4443`).
81pub async fn build_storage(
82    creds: &GcsCredentials,
83    storage_host: Option<&str>,
84) -> Result<Storage, FaucetError> {
85    let credentials = build_credentials(creds).await?;
86    let mut builder = Storage::builder().with_credentials(credentials);
87    if let Some(host) = storage_host {
88        builder = builder.with_endpoint(host.to_string());
89    }
90    builder
91        .build()
92        .await
93        .map_err(|e| FaucetError::Auth(format!("GCS client build failed: {e}")))
94}
95
96/// Build a control-plane [`StorageControl`] client.
97pub async fn build_storage_control(
98    creds: &GcsCredentials,
99    storage_host: Option<&str>,
100) -> Result<StorageControl, FaucetError> {
101    let credentials = build_credentials(creds).await?;
102    let mut builder = StorageControl::builder().with_credentials(credentials);
103    if let Some(host) = storage_host {
104        builder = builder.with_endpoint(host.to_string());
105    }
106    builder
107        .build()
108        .await
109        .map_err(|e| FaucetError::Auth(format!("GCS control client build failed: {e}")))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde_json::json;
116
117    #[test]
118    fn credentials_serde_application_default() {
119        let creds = GcsCredentials::ApplicationDefault;
120        let v = serde_json::to_value(&creds).unwrap();
121        assert_eq!(v, json!({"type": "application_default"}));
122        let back: GcsCredentials = serde_json::from_value(v).unwrap();
123        assert!(matches!(back, GcsCredentials::ApplicationDefault));
124    }
125
126    #[test]
127    fn credentials_serde_service_account_json_file() {
128        let creds = GcsCredentials::ServiceAccountJsonFile {
129            path: "/run/secrets/sa.json".into(),
130        };
131        let v = serde_json::to_value(&creds).unwrap();
132        assert_eq!(
133            v,
134            json!({"type": "service_account_json_file", "config": {"path": "/run/secrets/sa.json"}})
135        );
136        let back: GcsCredentials = serde_json::from_value(v).unwrap();
137        assert!(
138            matches!(back, GcsCredentials::ServiceAccountJsonFile { path } if path == "/run/secrets/sa.json")
139        );
140    }
141
142    #[test]
143    fn credentials_serde_service_account_json_inline() {
144        let creds = GcsCredentials::ServiceAccountJsonInline {
145            json: "{\"client_email\":\"x@y\"}".into(),
146        };
147        let v = serde_json::to_value(&creds).unwrap();
148        assert_eq!(v["type"], "service_account_json_inline");
149        assert!(
150            v["config"]["json"]
151                .as_str()
152                .unwrap()
153                .contains("client_email")
154        );
155        let back: GcsCredentials = serde_json::from_value(v).unwrap();
156        assert!(matches!(
157            back,
158            GcsCredentials::ServiceAccountJsonInline { .. }
159        ));
160    }
161
162    #[test]
163    fn credentials_default_is_application_default() {
164        let creds = GcsCredentials::default();
165        assert!(matches!(creds, GcsCredentials::ApplicationDefault));
166    }
167
168    #[test]
169    fn credentials_serde_anonymous() {
170        let creds = GcsCredentials::Anonymous;
171        let v = serde_json::to_value(&creds).unwrap();
172        assert_eq!(v, json!({"type": "anonymous"}));
173        let back: GcsCredentials = serde_json::from_value(v).unwrap();
174        assert!(matches!(back, GcsCredentials::Anonymous));
175    }
176
177    #[tokio::test]
178    async fn build_credentials_anonymous_succeeds() {
179        let creds = build_credentials(&GcsCredentials::Anonymous).await.unwrap();
180        // Smoke check: the call returns a real Credentials handle. We
181        // can't easily assert on its internal type, but the fact that
182        // it returned `Ok` (vs. needing GCP creds in the environment)
183        // is the point of the variant.
184        let _ = creds;
185    }
186
187    #[tokio::test]
188    async fn build_credentials_rejects_missing_file() {
189        let creds = GcsCredentials::ServiceAccountJsonFile {
190            path: "/definitely/does/not/exist/sa.json".into(),
191        };
192        let err = build_credentials(&creds).await.unwrap_err();
193        assert!(matches!(err, FaucetError::Auth(_)));
194        let msg = err.to_string();
195        assert!(
196            msg.contains("could not read") || msg.contains("No such file"),
197            "unexpected error: {msg}"
198        );
199    }
200
201    #[tokio::test]
202    async fn build_credentials_rejects_invalid_inline_json() {
203        let creds = GcsCredentials::ServiceAccountJsonInline {
204            json: "not-json".into(),
205        };
206        let err = build_credentials(&creds).await.unwrap_err();
207        assert!(matches!(err, FaucetError::Auth(_)));
208    }
209}