use std::time::{Duration, SystemTime};
use google_cloud_kms_v1::client::KeyManagementService;
use google_cloud_kms_v1::model::CryptoKeyVersion;
use snafu::prelude::*;
#[must_use]
pub fn version_id_from_resource_name(resource_name: &str) -> &str {
resource_name.rsplit('/').next().unwrap_or(resource_name)
}
#[derive(Debug, Snafu)]
#[non_exhaustive]
pub enum VersionResolutionError {
GetCryptoKey {
source: google_cloud_kms_v1::Error,
},
ListCryptoKeyVersions {
source: google_cloud_kms_v1::Error,
},
NoEnabledCryptoKeyVersions,
InvalidKeyVersionName,
VersionLabelNotFound {
label: String,
},
}
impl VersionResolutionError {
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::GetCryptoKey { source } | Self::ListCryptoKeyVersions { source } => {
source.is_timeout() || source.is_exhausted()
}
Self::NoEnabledCryptoKeyVersions
| Self::InvalidKeyVersionName
| Self::VersionLabelNotFound { .. } => false,
}
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub enum VersionStrategy {
Specific(String),
#[default]
Latest,
ByLabel(String),
MinAge(Duration),
}
pub async fn resolve_version(
key_name: &str,
strategy: &VersionStrategy,
kms_client: &KeyManagementService,
) -> Result<String, VersionResolutionError> {
match strategy {
VersionStrategy::Specific(version) => Ok(version.clone()),
VersionStrategy::Latest => resolve_latest_version(key_name, kms_client).await,
VersionStrategy::ByLabel(label) => {
resolve_version_by_label(key_name, kms_client, label).await
}
VersionStrategy::MinAge(min_age) => {
resolve_min_age_version(key_name, kms_client, min_age).await
}
}
}
async fn resolve_latest_version(
key_name: &str,
kms_client: &KeyManagementService,
) -> Result<String, VersionResolutionError> {
kms_client
.list_crypto_key_versions()
.set_parent(key_name)
.set_page_size(1)
.set_filter("state=ENABLED")
.set_order_by("name desc")
.send()
.await
.context(ListCryptoKeyVersionsSnafu)?
.crypto_key_versions
.into_iter()
.next()
.ok_or(NoEnabledCryptoKeyVersionsSnafu.build())?
.name
.rsplit('/')
.next()
.ok_or(InvalidKeyVersionNameSnafu.build())
.map(String::from)
}
async fn resolve_min_age_version(
key_name: &str,
kms_client: &KeyManagementService,
min_age: &Duration,
) -> Result<String, VersionResolutionError> {
let versions = list_enabled_kms_versions(kms_client, key_name, None, Some("name desc"))
.await
.context(ListCryptoKeyVersionsSnafu)?;
ensure!(!versions.is_empty(), NoEnabledCryptoKeyVersionsSnafu);
Ok(select_min_age_id(&versions, min_age).to_string())
}
pub(crate) async fn list_enabled_kms_versions(
kms_client: &KeyManagementService,
key_name: &str,
max_versions: Option<usize>,
order_by: Option<&str>,
) -> Result<Vec<CryptoKeyVersion>, google_cloud_kms_v1::Error> {
let mut all = Vec::new();
let mut page_token = String::new();
loop {
let remaining = max_versions.map(|m| m.saturating_sub(all.len()));
if remaining == Some(0) {
break;
}
let mut request = kms_client
.list_crypto_key_versions()
.set_parent(key_name)
.set_filter("state=ENABLED");
if let Some(order) = order_by {
request = request.set_order_by(order);
}
if let Some(n) = remaining {
request = request.set_page_size(i32::try_from(n).unwrap_or(i32::MAX));
}
if !page_token.is_empty() {
request = request.set_page_token(&page_token);
}
let response = request.send().await?;
all.extend(response.crypto_key_versions);
if response.next_page_token.is_empty() || max_versions.is_some_and(|m| all.len() >= m) {
break;
}
page_token = response.next_page_token;
}
Ok(all)
}
pub(crate) fn select_min_age_id<'a>(
versions: &'a [CryptoKeyVersion],
min_age: &Duration,
) -> &'a str {
let cutoff = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default() .as_secs()
.cast_signed()
- min_age.as_secs().cast_signed();
let chosen = versions
.iter()
.find(|v| {
v.create_time
.as_ref()
.is_some_and(|ct| ct.seconds() <= cutoff)
})
.unwrap_or(&versions[0]);
version_id_from_resource_name(&chosen.name)
}
async fn resolve_version_by_label(
key_name: &str,
kms_client: &KeyManagementService,
label: &str,
) -> Result<String, VersionResolutionError> {
let crypto_key = kms_client
.get_crypto_key()
.set_name(key_name)
.send()
.await
.context(GetCryptoKeySnafu)?;
crypto_key
.labels
.get(label)
.cloned()
.context(VersionLabelNotFoundSnafu { label })
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::time::UNIX_EPOCH;
use google_cloud_wkt::Timestamp;
use rstest::rstest;
use super::*;
#[rstest]
#[case("projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/42", "42")]
#[case("projects/p/.../cryptoKeyVersions/primary", "primary")]
#[case("1", "1")]
#[case("", "")]
fn version_id_from_resource_name_extracts_trailing_segment(
#[case] resource_name: &str,
#[case] expected: &str,
) {
assert_eq!(version_id_from_resource_name(resource_name), expected);
}
fn now_secs() -> i64 {
i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
)
.unwrap()
}
fn versions(ages_secs: &[(&str, i64)]) -> Vec<CryptoKeyVersion> {
let now = now_secs();
ages_secs
.iter()
.map(|(name, age)| {
CryptoKeyVersion::default()
.set_name(*name)
.set_create_time(Timestamp::clamp(now - age, 0))
})
.collect()
}
#[test]
fn select_min_age_picks_newest_version_past_the_threshold() {
let vs = versions(&[
(".../cryptoKeyVersions/3", 10),
(".../cryptoKeyVersions/2", 100),
(".../cryptoKeyVersions/1", 1000),
]);
assert_eq!(select_min_age_id(&vs, &Duration::from_secs(60)), "2");
}
#[test]
fn select_min_age_falls_back_to_newest_when_none_old_enough() {
let vs = versions(&[
(".../cryptoKeyVersions/3", 5),
(".../cryptoKeyVersions/2", 10),
]);
assert_eq!(select_min_age_id(&vs, &Duration::from_secs(3600)), "3");
}
#[test]
fn select_min_age_ignores_versions_without_create_time() {
let now = now_secs();
let vs = vec![
CryptoKeyVersion::default().set_name(".../cryptoKeyVersions/3"),
CryptoKeyVersion::default()
.set_name(".../cryptoKeyVersions/2")
.set_create_time(Timestamp::clamp(now - 1000, 0)),
];
assert_eq!(select_min_age_id(&vs, &Duration::from_secs(60)), "2");
}
}