use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExternalSecretPayload {
pub context: String,
pub account: String,
pub secret: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_key_id: Option<String>,
}
impl Drop for ExternalSecretPayload {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.secret);
}
}
impl std::fmt::Debug for ExternalSecretPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExternalSecretPayload")
.field("context", &self.context)
.field("account", &self.account)
.field("secret", &"<redacted>")
.field("access_key_id", &self.access_key_id)
.finish()
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
#[non_exhaustive]
pub enum ExternalCredentialPayload {
AwsSession {
access_key_id: String,
secret_access_key: String,
session_token: String,
expiration: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_request_id: Option<String>,
},
BearerToken {
token: String,
token_type: String,
expires_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_request_id: Option<String>,
},
PresignedRequest {
method: String,
url: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
headers: BTreeMap<String, String>,
expires_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_request_id: Option<String>,
},
PresignedRequests {
requests: Vec<PresignedEntry>,
expires_at: String,
},
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PresignedEntry {
pub index: u32,
pub operation: String,
pub method: String,
pub url: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub expires_at: String,
}
impl Drop for PresignedEntry {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.url);
}
}
impl std::fmt::Debug for PresignedEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PresignedEntry")
.field("index", &self.index)
.field("operation", &self.operation)
.field("method", &self.method)
.field("url", &"<redacted>")
.field("expires_at", &self.expires_at)
.finish()
}
}
impl ExternalCredentialPayload {
pub fn expires_at(&self) -> &str {
match self {
Self::AwsSession { expiration, .. } => expiration,
Self::BearerToken { expires_at, .. }
| Self::PresignedRequest { expires_at, .. }
| Self::PresignedRequests { expires_at, .. } => expires_at,
}
}
}
impl Drop for ExternalCredentialPayload {
fn drop(&mut self) {
use zeroize::Zeroize;
match self {
Self::AwsSession {
secret_access_key,
session_token,
..
} => {
secret_access_key.zeroize();
session_token.zeroize();
}
Self::BearerToken { token, .. } => token.zeroize(),
Self::PresignedRequest { url, .. } => url.zeroize(),
Self::PresignedRequests { .. } => {}
}
}
}
impl std::fmt::Debug for ExternalCredentialPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let kind = match self {
Self::AwsSession { .. } => "awsSession",
Self::BearerToken { .. } => "bearerToken",
Self::PresignedRequest { .. } => "presignedRequest",
Self::PresignedRequests { .. } => "presignedRequests",
};
f.debug_struct("ExternalCredentialPayload")
.field("kind", &kind)
.field("expires_at", &self.expires_at())
.field("credential", &"<redacted>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_presigned_request_set_is_the_specifications() {
let raw = json!({
"kind": "presignedRequests",
"requests": [{
"index": 0, "operation": "upload-part", "method": "PUT",
"url": "https://b.example/k?partNumber=1&X-Amz-Signature=x",
"headers": { "Content-Length": "5", "x-amz-checksum-sha256": "c" },
"expiresAt": "2026-10-10T09:00:00Z",
}],
"expiresAt": "2026-10-10T09:00:00Z",
});
let set: ExternalCredentialPayload = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(serde_json::to_value(&set).unwrap(), raw);
assert!(!format!("{set:?}").contains("Signature"));
let ExternalCredentialPayload::PresignedRequests { requests, .. } = &set else {
panic!()
};
assert!(!format!("{:?}", requests[0]).contains("Signature"));
}
#[test]
fn the_shapes_are_the_specifications() {
let secret = ExternalSecretPayload {
context: "community".into(),
account: "r2".into(),
secret: "s".into(),
access_key_id: Some("AKID".into()),
};
assert_eq!(
serde_json::to_value(&secret).unwrap(),
json!({ "context": "community", "account": "r2", "secret": "s", "accessKeyId": "AKID" })
);
let cred = ExternalCredentialPayload::PresignedRequest {
method: "GET".into(),
url: "https://b.example/k?X-Amz-Signature=x".into(),
headers: BTreeMap::new(),
expires_at: "2026-10-10T09:00:00Z".into(),
provider_request_id: None,
};
assert_eq!(
serde_json::to_value(&cred).unwrap(),
json!({ "kind": "presignedRequest", "method": "GET",
"url": "https://b.example/k?X-Amz-Signature=x", "expiresAt": "2026-10-10T09:00:00Z" })
);
let aws: ExternalCredentialPayload = serde_json::from_value(json!({
"kind": "awsSession", "accessKeyId": "ASIA", "secretAccessKey": "k",
"sessionToken": "t", "expiration": "2026-10-10T09:00:00Z", "region": "eu-west-1",
"providerRequestId": "req-1",
}))
.unwrap();
assert_eq!(aws.expires_at(), "2026-10-10T09:00:00Z");
assert!(format!("{aws:?}").contains("<redacted>"));
assert!(
serde_json::from_value::<ExternalCredentialPayload>(json!({
"kind": "bearerToken", "token": "t", "tokenType": "Bearer",
"expiresAt": "2026-10-10T09:00:00Z", "unexpected": 1,
}))
.is_err(),
"unknown members are refused"
);
}
}