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 profile_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, profile_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 profile_name: &str,
188 ) -> Result<&'a mut Item, WriteError> {
189 secrets_toml
190 .get_mut("credentials")
191 .and_then(|credentials| credentials.get_mut(profile_name)?.get_mut("token_payload"))
192 .ok_or_else(|| {
193 WriteError::MissingTable(format!("credentials.{profile_name}.token_payload",))
194 })
195 }
196}
197
198#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
200pub struct Credential {
201 pub token_payload: Option<TokenPayload>,
203}
204
205#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
207pub struct TokenPayload {
208 pub refresh_token: Option<SecretRefreshToken>,
210 pub access_token: Option<SecretAccessToken>,
212 #[serde(
214 default,
215 deserialize_with = "time::serde::rfc3339::option::deserialize",
216 serialize_with = "time::serde::rfc3339::option::serialize"
217 )]
218 pub updated_at: Option<OffsetDateTime>,
219
220 scope: Option<String>,
223 expires_in: Option<u32>,
224 id_token: Option<String>,
225 token_type: Option<String>,
226}
227
228#[cfg(test)]
229mod describe_load {
230 #![allow(clippy::result_large_err, reason = "happens in figment tests")]
231
232 #[cfg(unix)]
233 use std::os::unix::fs::PermissionsExt;
234 use std::path::PathBuf;
235
236 use time::{OffsetDateTime, macros::datetime};
237
238 use crate::configuration::secrets::{SECRETS_READ_ONLY_VAR, SecretAccessToken};
239
240 use super::{Credential, SECRETS_PATH_VAR, Secrets};
241
242 #[test]
243 fn returns_err_if_invalid_path_env() {
244 figment::Jail::expect_with(|jail| {
245 jail.set_env(SECRETS_PATH_VAR, "/blah/doesnt_exist.toml");
246 Secrets::load().expect_err("Should return error when a file cannot be found.");
247 Ok(())
248 });
249 }
250
251 #[test]
252 fn loads_from_env_var_path() {
253 figment::Jail::expect_with(|jail| {
254 let mut secrets = Secrets {
255 file_path: Some(PathBuf::from("env_secrets.toml")),
256 ..Secrets::default()
257 };
258 secrets
259 .credentials
260 .insert("test".to_string(), Credential::default());
261 let secrets_string =
262 toml::to_string(&secrets).expect("Should be able to serialize secrets");
263
264 _ = jail.create_file("env_secrets.toml", &secrets_string)?;
265 jail.set_env(SECRETS_PATH_VAR, "env_secrets.toml");
266
267 assert_eq!(secrets, Secrets::load().unwrap());
268
269 Ok(())
270 });
271 }
272
273 const fn max_rfc3339() -> OffsetDateTime {
274 datetime!(9999-12-31 23:59:59.999_999_999).assume_utc()
277 }
278
279 #[test]
280 fn test_write_access_token() {
281 figment::Jail::expect_with(|jail| {
282 let secrets_file_contents = r#"
283[credentials]
284[credentials.test]
285[credentials.test.token_payload]
286access_token = "old_access_token"
287expires_in = 3600
288id_token = "id_token"
289refresh_token = "refresh_token"
290scope = "offline_access openid profile email"
291token_type = "Bearer"
292"#;
293
294 jail.create_file("secrets.toml", secrets_file_contents)
295 .expect("should create test secrets.toml");
296 let mut original_permissions = std::fs::metadata("secrets.toml")
297 .expect("Should be able to get file metadata")
298 .permissions();
299 #[cfg(unix)]
300 {
301 assert_ne!(
302 0o666,
303 original_permissions.mode(),
304 "Initial file mode should not be 666"
305 );
306 original_permissions.set_mode(0o100_666);
307 std::fs::set_permissions("secrets.toml", original_permissions.clone())
308 .expect("Should be able to set file permissions");
309 }
310 jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
311 jail.set_env("QCS_PROFILE_NAME", "test");
312
313 let rt = tokio::runtime::Runtime::new().unwrap();
314 rt.block_on(async {
315 let token_updates = [
317 ("new_access_token", max_rfc3339()),
318 ("stale_access_token", OffsetDateTime::now_utc()),
319 ];
320
321 for (access_token, updated_at) in token_updates {
322 Secrets::write_tokens(
323 "secrets.toml",
324 "test",
325 None,
326 &SecretAccessToken::from(access_token),
327 updated_at,
328 )
329 .await
330 .expect("Should be able to write access token");
331 }
332
333 let mut secrets = Secrets::load_from_path(&"secrets.toml".into()).unwrap();
335 let payload = secrets
336 .credentials
337 .remove("test")
338 .unwrap()
339 .token_payload
340 .unwrap();
341
342 assert_eq!(
343 payload.access_token.unwrap(),
344 SecretAccessToken::from("new_access_token")
345 );
346 assert_eq!(payload.updated_at.unwrap(), max_rfc3339());
347 let new_permissions = std::fs::metadata("secrets.toml")
348 .expect("Should be able to get file metadata")
349 .permissions();
350 assert_eq!(
351 original_permissions, new_permissions,
352 "Final file permissions should not be changed"
353 );
354 });
355
356 Ok(())
357 });
358 }
359
360 fn set_mode(path: &PathBuf, mode: u32) {
362 #[cfg(unix)]
363 {
364 use std::os::unix::fs::PermissionsExt;
365 let perms = std::fs::Permissions::from_mode(mode);
366 std::fs::set_permissions(path, perms).expect("Should be able to set permissions");
367 }
368 }
369
370 #[test]
371 fn test_is_read_only_missing_file_checks_parent_dir() {
372 figment::Jail::expect_with(|jail| {
373 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
374
375 let writable_dir = jail.create_dir("writable_dir")?;
376 let readonly_dir = jail.create_dir("readonly_dir")?;
377
378 set_mode(&writable_dir, 0o777);
379 set_mode(&readonly_dir, 0o555);
380
381 let rt = tokio::runtime::Runtime::new().unwrap();
382 rt.block_on(async {
383 let writable_path = writable_dir.join("missing_secrets.toml");
385 let is_ro = Secrets::is_read_only(&writable_path)
386 .await
387 .expect("Should not error");
388 assert!(
389 !is_ro,
390 "Missing file in writable directory should not be read-only: {}",
391 writable_path.display()
392 );
393
394 let readonly_path = readonly_dir.join("missing_secrets.toml");
396 let is_ro = Secrets::is_read_only(&readonly_path)
397 .await
398 .expect("Should not error");
399 assert!(
400 is_ro,
401 "Missing file in read-only directory should be read-only: {}",
402 readonly_path.display()
403 );
404 });
405
406 Ok(())
407 });
408 }
409
410 #[test]
411 fn test_is_read_only_existing_file() {
412 figment::Jail::expect_with(|jail| {
413 jail.set_env(SECRETS_READ_ONLY_VAR, "false");
414
415 jail.create_file("writable_secrets.toml", "")?;
416 jail.create_file("readonly_secrets.toml", "")?;
417
418 let writable_path = jail.directory().join("writable_secrets.toml");
419 let readonly_path = jail.directory().join("readonly_secrets.toml");
420
421 set_mode(&writable_path, 0o666);
422 set_mode(&readonly_path, 0o444);
423
424 let rt = tokio::runtime::Runtime::new().unwrap();
425 rt.block_on(async {
426 let is_ro = Secrets::is_read_only(&writable_path)
428 .await
429 .expect("Should not error");
430 assert!(
431 !is_ro,
432 "Writable file should not be read-only: {}",
433 writable_path.display()
434 );
435
436 let is_ro = Secrets::is_read_only(&readonly_path)
438 .await
439 .expect("Should not error");
440 assert!(
441 is_ro,
442 "Read-only file should be read-only: {}",
443 readonly_path.display()
444 );
445 });
446
447 Ok(())
448 });
449 }
450}