1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3use faucet_core::FaucetError;
7use google_cloud_storage::client::{Storage, StorageControl};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
18#[serde(tag = "type", content = "config", rename_all = "snake_case")]
19pub enum GcsCredentials {
20 ServiceAccountJsonFile { path: String },
22 ServiceAccountJsonInline { json: String },
26 #[default]
30 ApplicationDefault,
31 Anonymous,
36}
37
38pub 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
78pub 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
96pub 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 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
210 #[tokio::test]
211 async fn build_credentials_reads_file_and_gets_past_parse() {
212 let path = std::env::temp_dir().join(format!("faucet_gcs_sa_{}.json", std::process::id()));
219 std::fs::write(
220 &path,
221 br#"{"type":"service_account","client_email":"x@y.iam"}"#,
222 )
223 .expect("write temp sa file");
224 let creds = GcsCredentials::ServiceAccountJsonFile {
225 path: path.to_string_lossy().into_owned(),
226 };
227 let result = build_credentials(&creds).await;
228 let _ = std::fs::remove_file(&path);
229 match result {
230 Ok(_) => {} Err(FaucetError::Auth(msg)) => assert!(
232 !msg.contains("could not read") && !msg.contains("not valid JSON"),
233 "read + parse should have succeeded, got: {msg}"
234 ),
235 Err(other) => panic!("unexpected error variant: {other:?}"),
236 }
237 }
238
239 #[tokio::test]
240 async fn build_credentials_inline_valid_json_gets_past_parse() {
241 let creds = GcsCredentials::ServiceAccountJsonInline {
244 json: r#"{"type":"service_account","client_email":"x@y.iam"}"#.into(),
245 };
246 match build_credentials(&creds).await {
247 Ok(_) => {}
248 Err(FaucetError::Auth(msg)) => assert!(
249 !msg.contains("not valid JSON"),
250 "inline JSON should have parsed, got: {msg}"
251 ),
252 Err(other) => panic!("unexpected error variant: {other:?}"),
253 }
254 }
255
256 #[tokio::test]
257 async fn build_credentials_application_default_does_not_panic() {
258 match build_credentials(&GcsCredentials::ApplicationDefault).await {
263 Ok(_) | Err(FaucetError::Auth(_)) => {}
264 Err(other) => panic!("unexpected error variant: {other:?}"),
265 }
266 }
267
268 #[tokio::test]
269 async fn build_storage_constructs_client_with_endpoint_override() {
270 match build_storage(&GcsCredentials::Anonymous, Some("http://localhost:4443")).await {
275 Ok(_) | Err(FaucetError::Auth(_)) => {}
276 Err(other) => panic!("unexpected error variant: {other:?}"),
277 }
278 }
279
280 #[tokio::test]
281 async fn build_storage_control_constructs_client_with_endpoint_override() {
282 match build_storage_control(&GcsCredentials::Anonymous, Some("http://localhost:4443")).await
284 {
285 Ok(_) | Err(FaucetError::Auth(_)) => {}
286 Err(other) => panic!("unexpected error variant: {other:?}"),
287 }
288 }
289}