qcs_api_client_common/configuration/
secrets.rs1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use async_tempfile::TempFile;
7use figment::providers::{Format, Toml};
8use figment::Figment;
9use serde::{Deserialize, Serialize};
10use time::format_description::well_known::Rfc3339;
11use time::{OffsetDateTime, PrimitiveDateTime};
12use tokio::io::AsyncWriteExt;
13use toml_edit::{DocumentMut, Item};
14
15use crate::configuration::LoadError;
16
17use super::error::{IoErrorWithPath, IoOperation, WriteError};
18use super::{expand_path_from_env_or_default, DEFAULT_PROFILE_NAME};
19
20pub use super::secret_string::{SecretAccessToken, SecretRefreshToken};
21
22pub const SECRETS_PATH_VAR: &str = "QCS_SECRETS_FILE_PATH";
24pub const SECRETS_READ_ONLY_VAR: &str = "QCS_SECRETS_READ_ONLY";
28pub const DEFAULT_SECRETS_PATH: &str = "~/.qcs/secrets.toml";
30
31#[derive(Deserialize, Debug, PartialEq, Eq, Serialize)]
33pub struct Secrets {
34 #[serde(default = "default_credentials")]
36 pub credentials: HashMap<String, Credential>,
37 #[serde(skip)]
40 pub file_path: Option<PathBuf>,
41}
42
43fn default_credentials() -> HashMap<String, Credential> {
44 HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), Credential::default())])
45}
46
47impl Default for Secrets {
48 fn default() -> Self {
49 Self {
50 credentials: default_credentials(),
51 file_path: None,
52 }
53 }
54}
55
56impl Secrets {
57 pub fn load() -> Result<Self, LoadError> {
64 let path = expand_path_from_env_or_default(SECRETS_PATH_VAR, DEFAULT_SECRETS_PATH)?;
65 #[cfg(feature = "tracing")]
66 tracing::debug!("loading QCS secrets from {path:?}");
67 Self::load_from_path(&path)
68 }
69
70 pub fn load_from_path(path: &PathBuf) -> Result<Self, LoadError> {
76 let mut secrets: Self = Figment::from(Toml::file(path)).extract()?;
77 secrets.file_path = Some(path.into());
78 Ok(secrets)
79 }
80
81 pub async fn is_read_only(
90 secrets_path: impl AsRef<Path> + Send + Sync,
91 ) -> Result<bool, WriteError> {
92 let ro_env = std::env::var(SECRETS_READ_ONLY_VAR);
94 let ro_env_lowercase = ro_env.as_deref().map(str::to_lowercase);
95 if let Ok("true" | "yes" | "1") = ro_env_lowercase.as_deref() {
96 return Ok(true);
97 }
98
99 for (i, ancestor) in secrets_path.as_ref().ancestors().enumerate() {
102 match tokio::fs::metadata(ancestor).await {
103 Ok(metadata) => return Ok(metadata.permissions().readonly()),
104 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
105 Err(error) if i == 0 => {
106 return Err(IoErrorWithPath {
107 error,
108 path: secrets_path.as_ref().to_path_buf(),
109 operation: IoOperation::GetMetadata,
110 }
111 .into());
112 }
113 Err(_) => return Ok(true), }
115 }
116 Ok(true) }
118
119 pub(crate) async fn write_tokens(
129 secrets_path: impl AsRef<Path> + Send + Sync + std::fmt::Debug,
130 profile_name: &str,
131 refresh_token: Option<&SecretRefreshToken>,
132 access_token: &SecretAccessToken,
133 updated_at: OffsetDateTime,
134 ) -> Result<(), WriteError> {
135 let secrets_string = tokio::fs::read_to_string(&secrets_path)
137 .await
138 .map_err(|error| IoErrorWithPath {
139 error,
140 path: secrets_path.as_ref().to_path_buf(),
141 operation: IoOperation::Read,
142 })?;
143
144 let mut secrets_toml = secrets_string.parse::<DocumentMut>()?;
146
147 let token_payload = Self::get_token_payload_table(&mut secrets_toml, profile_name)?;
149
150 let current_updated_at = token_payload
151 .get("updated_at")
152 .and_then(|v| v.as_str())
153 .and_then(|s| PrimitiveDateTime::parse(s, &Rfc3339).ok())
154 .map(PrimitiveDateTime::assume_utc);
155
156 let did_update_access_token = if current_updated_at.is_none_or(|dt| dt < updated_at) {
157 token_payload["access_token"] = access_token.secret().into();
158 token_payload["updated_at"] = updated_at.format(&Rfc3339)?.into();
159 true
160 } else {
161 false
162 };
163
164 let did_update_refresh_token = refresh_token.is_some_and(|new_refresh_token| {
165 let current_refresh_token = token_payload.get("refresh_token").and_then(|v| v.as_str());
166 let new_refresh_token = new_refresh_token.secret();
167
168 let is_changed = current_refresh_token != Some(new_refresh_token);
169 if is_changed {
170 token_payload["refresh_token"] = new_refresh_token.into();
171 }
172 is_changed
173 });
174
175 if did_update_access_token || did_update_refresh_token {
176 let mut temp_file = TempFile::new().await?;
181 #[cfg(feature = "tracing")]
182 tracing::debug!(
183 "Created temporary QCS secrets file at {:?}",
184 temp_file.file_path()
185 );
186 let secrets_file_permissions = tokio::fs::metadata(&secrets_path)
188 .await
189 .map_err(|error| IoErrorWithPath {
190 error,
191 path: secrets_path.as_ref().to_path_buf(),
192 operation: IoOperation::GetMetadata,
193 })?
194 .permissions();
195 temp_file
196 .set_permissions(secrets_file_permissions)
197 .await
198 .map_err(|error| IoErrorWithPath {
199 error,
200 path: temp_file.file_path().clone(),
201 operation: IoOperation::SetPermissions,
202 })?;
203
204 temp_file
206 .write_all(secrets_toml.to_string().as_bytes())
207 .await
208 .map_err(|error| IoErrorWithPath {
209 error,
210 path: temp_file.file_path().clone(),
211 operation: IoOperation::Write,
212 })?;
213 temp_file.flush().await.map_err(|error| IoErrorWithPath {
214 error,
215 path: temp_file.file_path().clone(),
216 operation: IoOperation::Flush,
217 })?;
218
219 #[cfg(feature = "tracing")]
222 tracing::debug!(
223 "Overwriting QCS secrets file at {secrets_path:?} with temporary file at {:?}",
224 temp_file.file_path()
225 );
226 tokio::fs::rename(temp_file.file_path(), &secrets_path)
227 .await
228 .map_err(|error| IoErrorWithPath {
229 error,
230 path: temp_file.file_path().clone(),
231 operation: IoOperation::Rename {
232 dest: secrets_path.as_ref().to_path_buf(),
233 },
234 })?;
235 }
236
237 Ok(())
238 }
239
240 fn get_token_payload_table<'a>(
242 secrets_toml: &'a mut DocumentMut,
243 profile_name: &str,
244 ) -> Result<&'a mut Item, WriteError> {
245 secrets_toml
246 .get_mut("credentials")
247 .and_then(|credentials| credentials.get_mut(profile_name)?.get_mut("token_payload"))
248 .ok_or_else(|| {
249 WriteError::MissingTable(format!("credentials.{profile_name}.token_payload",))
250 })
251 }
252}
253
254#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
256pub struct Credential {
257 pub token_payload: Option<TokenPayload>,
259}
260
261#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
263pub struct TokenPayload {
264 pub refresh_token: Option<SecretRefreshToken>,
266 pub access_token: Option<SecretAccessToken>,
268 #[serde(
270 default,
271 deserialize_with = "time::serde::rfc3339::option::deserialize",
272 serialize_with = "time::serde::rfc3339::option::serialize"
273 )]
274 pub updated_at: Option<OffsetDateTime>,
275
276 scope: Option<String>,
279 expires_in: Option<u32>,
280 id_token: Option<String>,
281 token_type: Option<String>,
282}
283
284#[cfg(test)]
285mod describe_load {
286 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
287
288 #[cfg(unix)]
289 use std::os::unix::fs::PermissionsExt;
290 use std::path::PathBuf;
291
292 use time::{macros::datetime, OffsetDateTime};
293
294 use crate::configuration::secrets::{SecretAccessToken, SECRETS_READ_ONLY_VAR};
295
296 use super::{Credential, Secrets, SECRETS_PATH_VAR};
297
298 #[test]
299 fn returns_err_if_invalid_path_env() {
300 figment::Jail::expect_with(|jail| {
301 jail.set_env(SECRETS_PATH_VAR, "/blah/doesnt_exist.toml");
302 Secrets::load().expect_err("Should return error when a file cannot be found.");
303 Ok(())
304 });
305 }
306
307 #[test]
308 fn loads_from_env_var_path() {
309 figment::Jail::expect_with(|jail| {
310 let mut secrets = Secrets {
311 file_path: Some(PathBuf::from("env_secrets.toml")),
312 ..Secrets::default()
313 };
314 secrets
315 .credentials
316 .insert("test".to_string(), Credential::default());
317 let secrets_string =
318 toml::to_string(&secrets).expect("Should be able to serialize secrets");
319
320 _ = jail.create_file("env_secrets.toml", &secrets_string)?;
321 jail.set_env(SECRETS_PATH_VAR, "env_secrets.toml");
322
323 assert_eq!(secrets, Secrets::load().unwrap());
324
325 Ok(())
326 });
327 }
328
329 const fn max_rfc3339() -> OffsetDateTime {
330 datetime!(9999-12-31 23:59:59.999_999_999).assume_utc()
333 }
334
335 #[test]
336 fn test_write_access_token() {
337 figment::Jail::expect_with(|jail| {
338 let secrets_file_contents = r#"
339[credentials]
340[credentials.test]
341[credentials.test.token_payload]
342access_token = "old_access_token"
343expires_in = 3600
344id_token = "id_token"
345refresh_token = "refresh_token"
346scope = "offline_access openid profile email"
347token_type = "Bearer"
348"#;
349
350 jail.create_file("secrets.toml", secrets_file_contents)
351 .expect("should create test secrets.toml");
352 let mut original_permissions = std::fs::metadata("secrets.toml")
353 .expect("Should be able to get file metadata")
354 .permissions();
355 #[cfg(unix)]
356 {
357 assert_ne!(
358 0o666,
359 original_permissions.mode(),
360 "Initial file mode should not be 666"
361 );
362 original_permissions.set_mode(0o100_666);
363 std::fs::set_permissions("secrets.toml", original_permissions.clone())
364 .expect("Should be able to set file permissions");
365 }
366 jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
367 jail.set_env("QCS_PROFILE_NAME", "test");
368
369 let rt = tokio::runtime::Runtime::new().unwrap();
370 rt.block_on(async {
371 let token_updates = [
373 ("new_access_token", max_rfc3339()),
374 ("stale_access_token", OffsetDateTime::now_utc()),
375 ];
376
377 for (access_token, updated_at) in token_updates {
378 Secrets::write_tokens(
379 "secrets.toml",
380 "test",
381 None,
382 &SecretAccessToken::from(access_token),
383 updated_at,
384 )
385 .await
386 .expect("Should be able to write access token");
387 }
388
389 let mut secrets = Secrets::load_from_path(&"secrets.toml".into()).unwrap();
391 let payload = secrets
392 .credentials
393 .remove("test")
394 .unwrap()
395 .token_payload
396 .unwrap();
397
398 assert_eq!(
399 payload.access_token.unwrap(),
400 SecretAccessToken::from("new_access_token")
401 );
402 assert_eq!(payload.updated_at.unwrap(), max_rfc3339());
403 let new_permissions = std::fs::metadata("secrets.toml")
404 .expect("Should be able to get file metadata")
405 .permissions();
406 assert_eq!(
407 original_permissions, new_permissions,
408 "Final file permissions should not be changed"
409 );
410 });
411
412 Ok(())
413 });
414 }
415
416 fn set_mode(path: &PathBuf, mode: u32) {
418 #[cfg(unix)]
419 {
420 use std::os::unix::fs::PermissionsExt;
421 let perms = std::fs::Permissions::from_mode(mode);
422 std::fs::set_permissions(path, perms).expect("Should be able to set permissions");
423 }
424 }
425
426 #[test]
427 fn test_is_read_only_missing_file_checks_parent_dir() {
428 figment::Jail::expect_with(|jail| {
429 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
430
431 let writable_dir = jail.create_dir("writable_dir")?;
432 let readonly_dir = jail.create_dir("readonly_dir")?;
433
434 set_mode(&writable_dir, 0o777);
435 set_mode(&readonly_dir, 0o555);
436
437 let rt = tokio::runtime::Runtime::new().unwrap();
438 rt.block_on(async {
439 let writable_path = writable_dir.join("missing_secrets.toml");
441 let is_ro = Secrets::is_read_only(&writable_path)
442 .await
443 .expect("Should not error");
444 assert!(
445 !is_ro,
446 "Missing file in writable directory should not be read-only: {}",
447 writable_path.display()
448 );
449
450 let readonly_path = readonly_dir.join("missing_secrets.toml");
452 let is_ro = Secrets::is_read_only(&readonly_path)
453 .await
454 .expect("Should not error");
455 assert!(
456 is_ro,
457 "Missing file in read-only directory should be read-only: {}",
458 readonly_path.display()
459 );
460 });
461
462 Ok(())
463 });
464 }
465
466 #[test]
467 fn test_is_read_only_existing_file() {
468 figment::Jail::expect_with(|jail| {
469 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
470
471 jail.create_file("writable_secrets.toml", "")?;
472 jail.create_file("readonly_secrets.toml", "")?;
473
474 let writable_path = jail.directory().join("writable_secrets.toml");
475 let readonly_path = jail.directory().join("readonly_secrets.toml");
476
477 set_mode(&writable_path, 0o666);
478 set_mode(&readonly_path, 0o444);
479
480 let rt = tokio::runtime::Runtime::new().unwrap();
481 rt.block_on(async {
482 let is_ro = Secrets::is_read_only(&writable_path)
484 .await
485 .expect("Should not error");
486 assert!(
487 !is_ro,
488 "Writable file should not be read-only: {}",
489 writable_path.display()
490 );
491
492 let is_ro = Secrets::is_read_only(&readonly_path)
494 .await
495 .expect("Should not error");
496 assert!(
497 is_ro,
498 "Read-only file should be read-only: {}",
499 readonly_path.display()
500 );
501 });
502
503 Ok(())
504 });
505 }
506}