use std::collections::HashMap;
use std::path::{Path, PathBuf};
use async_tempfile::TempFile;
use figment::providers::{Format, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
use time::{OffsetDateTime, PrimitiveDateTime};
use tokio::io::AsyncWriteExt;
use toml_edit::{DocumentMut, Item};
use crate::configuration::LoadError;
use super::error::{IoErrorWithPath, IoOperation, WriteError};
use super::{expand_path_from_env_or_default, DEFAULT_PROFILE_NAME};
pub use super::secret_string::{SecretAccessToken, SecretRefreshToken};
pub const SECRETS_PATH_VAR: &str = "QCS_SECRETS_FILE_PATH";
pub const SECRETS_READ_ONLY_VAR: &str = "QCS_SECRETS_READ_ONLY";
pub const DEFAULT_SECRETS_PATH: &str = "~/.qcs/secrets.toml";
#[derive(Deserialize, Debug, PartialEq, Eq, Serialize)]
pub struct Secrets {
#[serde(default = "default_credentials")]
pub credentials: HashMap<String, Credential>,
#[serde(skip)]
pub file_path: Option<PathBuf>,
}
fn default_credentials() -> HashMap<String, Credential> {
HashMap::from([(DEFAULT_PROFILE_NAME.to_string(), Credential::default())])
}
impl Default for Secrets {
fn default() -> Self {
Self {
credentials: default_credentials(),
file_path: None,
}
}
}
impl Secrets {
pub fn load() -> Result<Self, LoadError> {
let path = expand_path_from_env_or_default(SECRETS_PATH_VAR, DEFAULT_SECRETS_PATH)?;
#[cfg(feature = "tracing")]
tracing::debug!("loading QCS secrets from {path:?}");
Self::load_from_path(&path)
}
pub fn load_from_path(path: &PathBuf) -> Result<Self, LoadError> {
let mut secrets: Self = Figment::from(Toml::file(path)).extract()?;
secrets.file_path = Some(path.into());
Ok(secrets)
}
pub async fn is_read_only(
secrets_path: impl AsRef<Path> + Send + Sync,
) -> Result<bool, WriteError> {
let ro_env = std::env::var(SECRETS_READ_ONLY_VAR);
let ro_env_lowercase = ro_env.as_deref().map(str::to_lowercase);
if let Ok("true" | "yes" | "1") = ro_env_lowercase.as_deref() {
return Ok(true);
}
for (i, ancestor) in secrets_path.as_ref().ancestors().enumerate() {
match tokio::fs::metadata(ancestor).await {
Ok(metadata) => return Ok(metadata.permissions().readonly()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) if i == 0 => {
return Err(IoErrorWithPath {
error,
path: secrets_path.as_ref().to_path_buf(),
operation: IoOperation::GetMetadata,
}
.into());
}
Err(_) => return Ok(true), }
}
Ok(true) }
pub(crate) async fn write_tokens(
secrets_path: impl AsRef<Path> + Send + Sync + std::fmt::Debug,
profile_name: &str,
refresh_token: Option<&SecretRefreshToken>,
access_token: &SecretAccessToken,
updated_at: OffsetDateTime,
) -> Result<(), WriteError> {
let secrets_string = tokio::fs::read_to_string(&secrets_path)
.await
.map_err(|error| IoErrorWithPath {
error,
path: secrets_path.as_ref().to_path_buf(),
operation: IoOperation::Read,
})?;
let mut secrets_toml = secrets_string.parse::<DocumentMut>()?;
let token_payload = Self::get_token_payload_table(&mut secrets_toml, profile_name)?;
let current_updated_at = token_payload
.get("updated_at")
.and_then(|v| v.as_str())
.and_then(|s| PrimitiveDateTime::parse(s, &Rfc3339).ok())
.map(PrimitiveDateTime::assume_utc);
let did_update_access_token = if current_updated_at.is_none_or(|dt| dt < updated_at) {
token_payload["access_token"] = access_token.secret().into();
token_payload["updated_at"] = updated_at.format(&Rfc3339)?.into();
true
} else {
false
};
let did_update_refresh_token = refresh_token.is_some_and(|new_refresh_token| {
let current_refresh_token = token_payload.get("refresh_token").and_then(|v| v.as_str());
let new_refresh_token = new_refresh_token.secret();
let is_changed = current_refresh_token != Some(new_refresh_token);
if is_changed {
token_payload["refresh_token"] = new_refresh_token.into();
}
is_changed
});
if did_update_access_token || did_update_refresh_token {
let mut temp_file = TempFile::new().await?;
#[cfg(feature = "tracing")]
tracing::debug!(
"Created temporary QCS secrets file at {:?}",
temp_file.file_path()
);
let secrets_file_permissions = tokio::fs::metadata(&secrets_path)
.await
.map_err(|error| IoErrorWithPath {
error,
path: secrets_path.as_ref().to_path_buf(),
operation: IoOperation::GetMetadata,
})?
.permissions();
temp_file
.set_permissions(secrets_file_permissions)
.await
.map_err(|error| IoErrorWithPath {
error,
path: temp_file.file_path().clone(),
operation: IoOperation::SetPermissions,
})?;
temp_file
.write_all(secrets_toml.to_string().as_bytes())
.await
.map_err(|error| IoErrorWithPath {
error,
path: temp_file.file_path().clone(),
operation: IoOperation::Write,
})?;
temp_file.flush().await.map_err(|error| IoErrorWithPath {
error,
path: temp_file.file_path().clone(),
operation: IoOperation::Flush,
})?;
#[cfg(feature = "tracing")]
tracing::debug!(
"Overwriting QCS secrets file at {secrets_path:?} with temporary file at {:?}",
temp_file.file_path()
);
tokio::fs::rename(temp_file.file_path(), &secrets_path)
.await
.map_err(|error| IoErrorWithPath {
error,
path: temp_file.file_path().clone(),
operation: IoOperation::Rename {
dest: secrets_path.as_ref().to_path_buf(),
},
})?;
}
Ok(())
}
fn get_token_payload_table<'a>(
secrets_toml: &'a mut DocumentMut,
profile_name: &str,
) -> Result<&'a mut Item, WriteError> {
secrets_toml
.get_mut("credentials")
.and_then(|credentials| credentials.get_mut(profile_name)?.get_mut("token_payload"))
.ok_or_else(|| {
WriteError::MissingTable(format!("credentials.{profile_name}.token_payload",))
})
}
}
#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Credential {
pub token_payload: Option<TokenPayload>,
}
#[derive(Deserialize, Debug, Default, PartialEq, Eq, Serialize)]
pub struct TokenPayload {
pub refresh_token: Option<SecretRefreshToken>,
pub access_token: Option<SecretAccessToken>,
#[serde(
default,
deserialize_with = "time::serde::rfc3339::option::deserialize",
serialize_with = "time::serde::rfc3339::option::serialize"
)]
pub updated_at: Option<OffsetDateTime>,
scope: Option<String>,
expires_in: Option<u32>,
id_token: Option<String>,
token_type: Option<String>,
}
#[cfg(test)]
mod describe_load {
#![allow(clippy::result_large_err, reason = "happens in figment tests")]
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use time::{macros::datetime, OffsetDateTime};
use crate::configuration::secrets::{SecretAccessToken, SECRETS_READ_ONLY_VAR};
use super::{Credential, Secrets, SECRETS_PATH_VAR};
#[test]
fn returns_err_if_invalid_path_env() {
figment::Jail::expect_with(|jail| {
jail.set_env(SECRETS_PATH_VAR, "/blah/doesnt_exist.toml");
Secrets::load().expect_err("Should return error when a file cannot be found.");
Ok(())
});
}
#[test]
fn loads_from_env_var_path() {
figment::Jail::expect_with(|jail| {
let mut secrets = Secrets {
file_path: Some(PathBuf::from("env_secrets.toml")),
..Secrets::default()
};
secrets
.credentials
.insert("test".to_string(), Credential::default());
let secrets_string =
toml::to_string(&secrets).expect("Should be able to serialize secrets");
_ = jail.create_file("env_secrets.toml", &secrets_string)?;
jail.set_env(SECRETS_PATH_VAR, "env_secrets.toml");
assert_eq!(secrets, Secrets::load().unwrap());
Ok(())
});
}
const fn max_rfc3339() -> OffsetDateTime {
datetime!(9999-12-31 23:59:59.999_999_999).assume_utc()
}
#[test]
fn test_write_access_token() {
figment::Jail::expect_with(|jail| {
let secrets_file_contents = r#"
[credentials]
[credentials.test]
[credentials.test.token_payload]
access_token = "old_access_token"
expires_in = 3600
id_token = "id_token"
refresh_token = "refresh_token"
scope = "offline_access openid profile email"
token_type = "Bearer"
"#;
jail.create_file("secrets.toml", secrets_file_contents)
.expect("should create test secrets.toml");
let mut original_permissions = std::fs::metadata("secrets.toml")
.expect("Should be able to get file metadata")
.permissions();
#[cfg(unix)]
{
assert_ne!(
0o666,
original_permissions.mode(),
"Initial file mode should not be 666"
);
original_permissions.set_mode(0o100_666);
std::fs::set_permissions("secrets.toml", original_permissions.clone())
.expect("Should be able to set file permissions");
}
jail.set_env("QCS_SECRETS_FILE_PATH", "secrets.toml");
jail.set_env("QCS_PROFILE_NAME", "test");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let token_updates = [
("new_access_token", max_rfc3339()),
("stale_access_token", OffsetDateTime::now_utc()),
];
for (access_token, updated_at) in token_updates {
Secrets::write_tokens(
"secrets.toml",
"test",
None,
&SecretAccessToken::from(access_token),
updated_at,
)
.await
.expect("Should be able to write access token");
}
let mut secrets = Secrets::load_from_path(&"secrets.toml".into()).unwrap();
let payload = secrets
.credentials
.remove("test")
.unwrap()
.token_payload
.unwrap();
assert_eq!(
payload.access_token.unwrap(),
SecretAccessToken::from("new_access_token")
);
assert_eq!(payload.updated_at.unwrap(), max_rfc3339());
let new_permissions = std::fs::metadata("secrets.toml")
.expect("Should be able to get file metadata")
.permissions();
assert_eq!(
original_permissions, new_permissions,
"Final file permissions should not be changed"
);
});
Ok(())
});
}
fn set_mode(path: &PathBuf, mode: u32) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(mode);
std::fs::set_permissions(path, perms).expect("Should be able to set permissions");
}
}
#[test]
fn test_is_read_only_missing_file_checks_parent_dir() {
figment::Jail::expect_with(|jail| {
jail.set_env(SECRETS_READ_ONLY_VAR, "false");
let writable_dir = jail.create_dir("writable_dir")?;
let readonly_dir = jail.create_dir("readonly_dir")?;
set_mode(&writable_dir, 0o777);
set_mode(&readonly_dir, 0o555);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let writable_path = writable_dir.join("missing_secrets.toml");
let is_ro = Secrets::is_read_only(&writable_path)
.await
.expect("Should not error");
assert!(
!is_ro,
"Missing file in writable directory should not be read-only: {}",
writable_path.display()
);
let readonly_path = readonly_dir.join("missing_secrets.toml");
let is_ro = Secrets::is_read_only(&readonly_path)
.await
.expect("Should not error");
assert!(
is_ro,
"Missing file in read-only directory should be read-only: {}",
readonly_path.display()
);
});
Ok(())
});
}
#[test]
fn test_is_read_only_existing_file() {
figment::Jail::expect_with(|jail| {
jail.set_env(SECRETS_READ_ONLY_VAR, "false");
jail.create_file("writable_secrets.toml", "")?;
jail.create_file("readonly_secrets.toml", "")?;
let writable_path = jail.directory().join("writable_secrets.toml");
let readonly_path = jail.directory().join("readonly_secrets.toml");
set_mode(&writable_path, 0o666);
set_mode(&readonly_path, 0o444);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let is_ro = Secrets::is_read_only(&writable_path)
.await
.expect("Should not error");
assert!(
!is_ro,
"Writable file should not be read-only: {}",
writable_path.display()
);
let is_ro = Secrets::is_read_only(&readonly_path)
.await
.expect("Should not error");
assert!(
is_ro,
"Read-only file should be read-only: {}",
readonly_path.display()
);
});
Ok(())
});
}
}