Skip to main content

brainos_vault/
inject.rs

1//! Injection shapes and credential value wrapper.
2//!
3//! The vault never returns raw `String` values directly — callers receive an
4//! `InjectedCredential` that describes *how* the credential should be applied
5//! (env var, HTTP header, positional arg). This keeps credential handling
6//! at the site of use and avoids accidentally logging the value.
7
8use serde::{Deserialize, Serialize};
9use zeroize::Zeroize;
10
11/// How a credential should be injected into the consuming call.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "snake_case", tag = "shape")]
14pub enum InjectionShape {
15    /// Set as environment variable.
16    EnvVar { name: String },
17    /// Set as HTTP header.
18    Header { name: String },
19    /// Substitute as positional argument at index.
20    Arg { position: usize },
21}
22
23/// A credential value. Zeroized on drop. `Debug` does not print the value.
24#[derive(Clone, Zeroize)]
25#[zeroize(drop)]
26pub struct CredentialValue(String);
27
28impl CredentialValue {
29    pub fn new(value: String) -> Self {
30        Self(value)
31    }
32
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36
37    pub fn len(&self) -> usize {
38        self.0.len()
39    }
40
41    pub fn is_empty(&self) -> bool {
42        self.0.is_empty()
43    }
44}
45
46impl std::fmt::Debug for CredentialValue {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "CredentialValue(<redacted, {} bytes>)", self.0.len())
49    }
50}
51
52impl From<String> for CredentialValue {
53    fn from(value: String) -> Self {
54        Self(value)
55    }
56}
57
58/// A credential prepared for injection. Carries the value plus its shape.
59#[derive(Debug, Clone)]
60pub struct InjectedCredential {
61    pub shape: InjectionShape,
62    pub value: CredentialValue,
63}
64
65impl InjectedCredential {
66    pub fn env(name: impl Into<String>, value: impl Into<CredentialValue>) -> Self {
67        Self {
68            shape: InjectionShape::EnvVar { name: name.into() },
69            value: value.into(),
70        }
71    }
72
73    pub fn header(name: impl Into<String>, value: impl Into<CredentialValue>) -> Self {
74        Self {
75            shape: InjectionShape::Header { name: name.into() },
76            value: value.into(),
77        }
78    }
79
80    pub fn arg(position: usize, value: impl Into<CredentialValue>) -> Self {
81        Self {
82            shape: InjectionShape::Arg { position },
83            value: value.into(),
84        }
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use proptest::prelude::*;
92
93    fn injection_shape() -> impl Strategy<Value = InjectionShape> {
94        prop_oneof![
95            ".*".prop_map(|name| InjectionShape::EnvVar { name }),
96            ".*".prop_map(|name| InjectionShape::Header { name }),
97            any::<usize>().prop_map(|position| InjectionShape::Arg { position }),
98        ]
99    }
100
101    proptest! {
102        #![proptest_config(ProptestConfig { cases: 512, .. ProptestConfig::default() })]
103
104        /// Security invariant: a credential's secret value must never appear
105        /// in its Debug rendering — only a byte count. Stated as an exact
106        /// equality so it can't leak even a prefix.
107        #[test]
108        fn debug_never_prints_secret(s in ".*") {
109            let cred = CredentialValue::new(s.clone());
110            prop_assert_eq!(
111                format!("{cred:?}"),
112                format!("CredentialValue(<redacted, {} bytes>)", s.len())
113            );
114            // len()/is_empty() track the wrapped value (byte length).
115            prop_assert_eq!(cred.len(), s.len());
116            prop_assert_eq!(cred.is_empty(), s.is_empty());
117        }
118
119        /// InjectionShape is persisted as JSON (the `.meta` sidecar and the
120        /// keychain/secret-service payloads). Serializing then deserializing
121        /// must round-trip exactly, including the internal `shape` tag, for
122        /// any field contents.
123        #[test]
124        fn injection_shape_json_round_trips(shape in injection_shape()) {
125            let json = serde_json::to_string(&shape).unwrap();
126            let back: InjectionShape = serde_json::from_str(&json).unwrap();
127            prop_assert_eq!(back, shape);
128        }
129    }
130}
131
132/// Metadata returned by `list` / `get` (without the value). Safe to log.
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134pub struct CredentialMetadata {
135    pub tool: String,
136    pub key: String,
137    pub backend: String, // "keychain" | "secret-service" | "file"
138    pub created_at: String,
139    pub last_used_at: Option<String>,
140    pub shape: InjectionShape,
141}