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