use crate::WireError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum KeyProvider {
Age,
Pgp,
AwsKms,
GcpKms,
HuaweiKms,
AzureKeyVault,
HcVault,
}
impl KeyProvider {
#[must_use]
pub fn field(self) -> &'static str {
match self {
Self::Age => "age",
Self::Pgp => "pgp",
Self::AwsKms => "kms",
Self::GcpKms => "gcp_kms",
Self::HuaweiKms => "hckms",
Self::AzureKeyVault => "azure_kv",
Self::HcVault => "hc_vault",
}
}
#[must_use]
pub fn order_token(self) -> &'static str {
match self {
Self::Age => "age",
Self::Pgp => "pgp",
Self::AwsKms => "kms",
Self::GcpKms => "gcp_kms",
Self::HuaweiKms => "hckms",
Self::AzureKeyVault => "azure_kv",
Self::HcVault => "hc_vault",
}
}
#[must_use]
pub fn is_implemented(self) -> bool {
matches!(self, Self::Age)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WrappedKey {
provider: KeyProvider,
recipient: String,
enc: String,
created_at: Option<String>,
}
impl WrappedKey {
#[must_use]
pub fn age(recipient: impl Into<String>, enc: impl Into<String>) -> Self {
Self {
provider: KeyProvider::Age,
recipient: recipient.into(),
enc: enc.into(),
created_at: None,
}
}
#[must_use]
pub fn opaque(
provider: KeyProvider,
recipient: impl Into<String>,
enc: impl Into<String>,
created_at: Option<String>,
) -> Self {
Self {
provider,
recipient: recipient.into(),
enc: enc.into(),
created_at,
}
}
#[must_use]
pub fn provider(&self) -> KeyProvider {
self.provider
}
#[must_use]
pub fn recipient(&self) -> &str {
&self.recipient
}
#[must_use]
pub fn enc(&self) -> &str {
&self.enc
}
#[must_use]
pub fn created_at(&self) -> Option<&str> {
self.created_at.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgeKey {
pub recipient: String,
pub enc: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Metadata {
keys: Vec<WrappedKey>,
pub shamir_threshold: Option<u32>,
pub lastmodified: String,
pub mac: String,
pub unencrypted_suffix: Option<String>,
pub encrypted_suffix: Option<String>,
pub unencrypted_regex: Option<String>,
pub encrypted_regex: Option<String>,
pub unencrypted_comment_regex: Option<String>,
pub encrypted_comment_regex: Option<String>,
pub mac_only_encrypted: bool,
pub version: String,
}
impl Metadata {
#[must_use]
pub fn from_wrapped(
keys: Vec<WrappedKey>,
lastmodified: impl Into<String>,
mac: impl Into<String>,
) -> Self {
Self {
keys,
shamir_threshold: None,
lastmodified: lastmodified.into(),
mac: mac.into(),
unencrypted_suffix: None,
encrypted_suffix: None,
unencrypted_regex: None,
encrypted_regex: None,
unencrypted_comment_regex: None,
encrypted_comment_regex: None,
mac_only_encrypted: false,
version: crate::FORMAT_VERSION.to_string(),
}
}
#[must_use]
pub fn keys(&self) -> &[WrappedKey] {
&self.keys
}
#[must_use]
pub fn keys_for(&self, provider: KeyProvider) -> Vec<&WrappedKey> {
self.keys
.iter()
.filter(|k| k.provider == provider)
.collect()
}
#[must_use]
pub fn age_keys(&self) -> Vec<AgeKey> {
self.keys_for(KeyProvider::Age)
.into_iter()
.map(|k| AgeKey {
recipient: k.recipient.clone(),
enc: k.enc.clone(),
})
.collect()
}
#[must_use]
pub fn providers(&self) -> Vec<KeyProvider> {
let mut ps: Vec<_> = self.keys.iter().map(|k| k.provider).collect();
ps.sort_unstable();
ps.dedup();
ps
}
#[must_use]
pub fn unimplemented_providers(&self) -> Vec<KeyProvider> {
self.providers()
.into_iter()
.filter(|p| !p.is_implemented())
.collect()
}
pub fn rewrap(&mut self, keys: Vec<WrappedKey>) -> Result<(), WireError> {
if keys.is_empty() {
return Err(WireError::DataKeyLength(0));
}
self.keys = keys;
Ok(())
}
pub fn selector(&self) -> Result<crate::selector::EncryptionSelector, WireError> {
let s = crate::selector::EncryptionSelector::new(
self.unencrypted_suffix.as_deref(),
self.encrypted_suffix.as_deref(),
self.unencrypted_regex.as_deref(),
self.encrypted_regex.as_deref(),
self.unencrypted_comment_regex.as_deref(),
self.encrypted_comment_regex.as_deref(),
)?;
Ok(if s.is_unconfigured() {
crate::selector::EncryptionSelector::default_policy()
} else {
s
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta() -> Metadata {
Metadata::from_wrapped(
vec![
WrappedKey::age(
"age1aaa",
"-----BEGIN AGE ENCRYPTED FILE-----\nA\n-----END AGE ENCRYPTED FILE-----\n",
),
WrappedKey::age(
"age1bbb",
"-----BEGIN AGE ENCRYPTED FILE-----\nB\n-----END AGE ENCRYPTED FILE-----\n",
),
],
"2026-08-18T00:00:00Z",
"ENC[AES256_GCM,data:x,iv:y,tag:z,type:str]",
)
}
#[test]
fn recipient_lists_are_projections_of_the_wrapped_keys() {
let m = meta();
let age = m.age_keys();
assert_eq!(age.len(), 2);
assert_eq!(age[0].recipient, "age1aaa");
assert_eq!(age[1].recipient, "age1bbb");
}
#[test]
fn a_recipient_cannot_exist_without_its_wrapped_key() {
let m = meta();
for k in m.age_keys() {
assert!(
!k.enc.is_empty(),
"a projected recipient always carries its wrapped key"
);
}
let mut m2 = meta();
m2.rewrap(vec![WrappedKey::age(
"age1ccc",
"-----BEGIN AGE ENCRYPTED FILE-----\nC\n-----END AGE ENCRYPTED FILE-----\n",
)])
.expect("rewrap");
assert_eq!(m2.age_keys().len(), 1);
assert_eq!(m2.age_keys()[0].recipient, "age1ccc");
}
#[test]
fn rewrapping_to_nothing_is_refused() {
let mut m = meta();
assert!(m.rewrap(vec![]).is_err());
assert_eq!(m.age_keys().len(), 2, "the refusal left the file intact");
}
#[test]
fn an_unimplemented_provider_is_named_not_dropped() {
let mut m = meta();
m.rewrap(vec![
WrappedKey::age("age1aaa", "enc"),
WrappedKey::opaque(
KeyProvider::AwsKms,
"arn:aws:kms:us-east-2:1:key/abc",
"CiA…",
Some("2026-01-01T00:00:00Z".into()),
),
])
.expect("rewrap");
assert_eq!(m.unimplemented_providers(), vec![KeyProvider::AwsKms]);
assert_eq!(m.keys_for(KeyProvider::AwsKms).len(), 1);
assert_eq!(
m.keys_for(KeyProvider::AwsKms)[0].created_at(),
Some("2026-01-01T00:00:00Z")
);
}
#[test]
fn an_all_age_file_has_nothing_unimplemented() {
assert!(meta().unimplemented_providers().is_empty());
}
#[test]
fn provider_field_names_match_the_wire() {
assert_eq!(KeyProvider::Age.field(), "age");
assert_eq!(KeyProvider::AwsKms.field(), "kms");
assert_eq!(KeyProvider::GcpKms.field(), "gcp_kms");
assert_eq!(KeyProvider::HuaweiKms.field(), "hckms");
assert_eq!(KeyProvider::AzureKeyVault.field(), "azure_kv");
assert_eq!(KeyProvider::HcVault.field(), "hc_vault");
assert_eq!(KeyProvider::Pgp.field(), "pgp");
}
#[test]
fn lastmodified_is_kept_verbatim() {
let m =
Metadata::from_wrapped(vec![WrappedKey::age("a", "e")], "2026-08-18T00:00:00Z", "m");
assert_eq!(m.lastmodified, "2026-08-18T00:00:00Z");
}
#[test]
fn an_unconfigured_file_gets_the_default_policy() {
let s = meta().selector().expect("selector");
assert!(
!s.is_unconfigured(),
"must have fallen back to the _unencrypted default"
);
}
}