Skip to main content

skipper_core/
credentials.rs

1use std::path::PathBuf;
2
3use base64::Engine;
4use serde::{Deserialize, Serialize};
5
6use crate::error::Result;
7
8/// Google service-account credentials, in one of three formats.
9#[derive(Clone)]
10pub enum Credentials {
11    /// Path to the service-account JSON key file.
12    File(PathBuf),
13    /// Base64-encoded service-account JSON (e.g. a CI secret).
14    Base64(String),
15    /// Inline service-account fields.
16    ServiceAccount(Box<ServiceAccountCredentials>),
17}
18
19impl std::fmt::Debug for Credentials {
20    // Never print key material.
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        match self {
23            Credentials::File(p) => f.debug_tuple("File").field(p).finish(),
24            Credentials::Base64(_) => f.write_str("Base64(<redacted>)"),
25            Credentials::ServiceAccount(sa) => f
26                .debug_struct("ServiceAccount")
27                .field("client_email", &sa.client_email)
28                .finish_non_exhaustive(),
29        }
30    }
31}
32
33/// The fields of a Google service-account JSON key.
34#[derive(Clone, Default, Serialize, Deserialize)]
35pub struct ServiceAccountCredentials {
36    #[serde(rename = "type", default)]
37    pub kind: String,
38    #[serde(default)]
39    pub project_id: String,
40    #[serde(default)]
41    pub private_key_id: String,
42    pub private_key: String,
43    pub client_email: String,
44    #[serde(default)]
45    pub client_id: String,
46    #[serde(default)]
47    pub auth_uri: String,
48    #[serde(default)]
49    pub token_uri: String,
50    #[serde(default)]
51    pub auth_provider_x509_cert_url: String,
52    #[serde(default)]
53    pub client_x509_cert_url: String,
54}
55
56impl Credentials {
57    /// Return the raw service-account JSON bytes.
58    pub fn resolve(&self) -> Result<Vec<u8>> {
59        Ok(match self {
60            Credentials::File(path) => std::fs::read(path).map_err(|e| {
61                crate::Error(format!(
62                    "cannot read credentials file {}: {e}",
63                    path.display()
64                ))
65            })?,
66            Credentials::Base64(enc) => {
67                base64::engine::general_purpose::STANDARD.decode(enc.trim())?
68            }
69            Credentials::ServiceAccount(sa) => serde_json::to_vec(sa)?,
70        })
71    }
72
73    /// Parse the resolved JSON into service-account fields.
74    pub fn service_account(&self) -> Result<ServiceAccountCredentials> {
75        Ok(serde_json::from_slice(&self.resolve()?)?)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    const JSON: &str = r#"{"type":"service_account","private_key":"k","client_email":"a@b.c","token_uri":"https://t"}"#;
84
85    #[test]
86    fn base64_and_file_and_inline_resolve_to_same_account() {
87        let b64 = base64::engine::general_purpose::STANDARD.encode(JSON);
88        let dir = tempfile::tempdir().unwrap();
89        let path = dir.path().join("sa.json");
90        std::fs::write(&path, JSON).unwrap();
91
92        for c in [Credentials::Base64(b64), Credentials::File(path)] {
93            let sa = c.service_account().unwrap();
94            assert_eq!(sa.client_email, "a@b.c");
95            assert_eq!(sa.token_uri, "https://t");
96        }
97        let inline = Credentials::ServiceAccount(Box::new(ServiceAccountCredentials {
98            client_email: "x@y.z".into(),
99            private_key: "k".into(),
100            ..Default::default()
101        }));
102        assert_eq!(inline.service_account().unwrap().client_email, "x@y.z");
103    }
104
105    #[test]
106    fn debug_redacts_secrets() {
107        let s = format!("{:?}", Credentials::Base64("c2VjcmV0".into()));
108        assert!(!s.contains("c2VjcmV0"));
109    }
110}