Skip to main content

act_credentials/
record.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6/// A credential value. `Debug` and `Display` are redacted, so the spec's
7/// "never logged" holds by construction rather than by discipline — and it
8/// holds for an object's members too, which a derived Debug would print.
9/// The stored JSON is the value itself — a string field is a string on disk, an
10/// object field an object — which is what lets the file store's shape follow the
11/// field's type with no envelope.
12///
13/// `#[serde(transparent)]` states that intent, but it is **not** what enforces
14/// it: serde already serialises a single-field tuple struct as its inner value,
15/// so removing the attribute would change nothing. The guarantee is pinned by
16/// `the_stored_json_is_the_value_itself` below instead, because a property this
17/// load-bearing should be held by a test rather than by an attribute that turns
18/// out to be decorative.
19///
20/// `Eq` is deliberately absent — `serde_json::Value` holds an `f64` and is only
21/// `PartialEq`.
22#[derive(Clone, PartialEq, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct SecretValue(serde_json::Value);
25
26impl SecretValue {
27    pub fn new(v: impl Into<serde_json::Value>) -> Self {
28        Self(v.into())
29    }
30
31    /// The only way to read the value. Named so that call sites are greppable.
32    pub fn expose(&self) -> &serde_json::Value {
33        &self.0
34    }
35
36    /// The value as text, for a `std:string` field. `None` for any other JSON
37    /// type — an object is not a string with extra steps.
38    pub fn expose_str(&self) -> Option<&str> {
39        self.0.as_str()
40    }
41}
42
43impl fmt::Debug for SecretValue {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str("SecretValue(<redacted>)")
46    }
47}
48
49impl fmt::Display for SecretValue {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str("<redacted>")
52    }
53}
54
55/// What the store holds.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct SecretRecord {
58    pub kind: String,
59    /// Revealable: returned to the component.
60    pub fields: BTreeMap<String, SecretValue>,
61    /// Host-only: refresh tokens, issuer binding. Never projected.
62    #[serde(default)]
63    pub host_only: BTreeMap<String, SecretValue>,
64    #[serde(default)]
65    pub description: Option<String>,
66    #[serde(default)]
67    pub expires_at: Option<i64>,
68}
69
70/// What the component receives.
71#[derive(Debug, Clone, PartialEq)]
72pub struct Secret {
73    pub kind: String,
74    pub fields: BTreeMap<String, SecretValue>,
75}
76
77/// Non-secret metadata, safe to list and to show a user.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct SecretInfo {
80    pub key: String,
81    pub kind: String,
82    #[serde(default)]
83    pub description: Option<String>,
84    #[serde(default)]
85    pub expires_at: Option<i64>,
86}
87
88impl SecretRecord {
89    pub fn project(&self) -> Secret {
90        Secret {
91            kind: self.kind.clone(),
92            fields: self.fields.clone(),
93        }
94    }
95
96    pub fn info(&self, key: &str) -> SecretInfo {
97        SecretInfo {
98            key: key.to_string(),
99            kind: self.kind.clone(),
100            description: self.description.clone(),
101            expires_at: self.expires_at,
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn record() -> SecretRecord {
111        let mut fields = BTreeMap::new();
112        fields.insert("std:access-token".to_string(), SecretValue::new("at-123"));
113        let mut host_only = BTreeMap::new();
114        host_only.insert("std:refresh-token".to_string(), SecretValue::new("rt-456"));
115        SecretRecord {
116            kind: "std:oauth2".into(),
117            fields,
118            host_only,
119            description: Some("Notion".into()),
120            expires_at: Some(1_800_000_000),
121        }
122    }
123
124    #[test]
125    fn projection_drops_the_host_only_compartment() {
126        let projected = record().project();
127        assert!(projected.fields.contains_key("std:access-token"));
128        assert!(
129            !projected.fields.contains_key("std:refresh-token"),
130            "a refresh token must never cross the sandbox boundary"
131        );
132    }
133
134    #[test]
135    fn debug_never_prints_a_value() {
136        let rendered = format!("{:?}", record());
137        assert!(!rendered.contains("at-123"));
138        assert!(!rendered.contains("rt-456"));
139        assert!(
140            rendered.contains("std:oauth2"),
141            "non-secret fields stay legible"
142        );
143    }
144
145    #[test]
146    fn the_stored_json_is_the_value_itself() {
147        // The on-disk shape, pinned. A value must serialise as itself and not
148        // gain a wrapper, or every credential already in a store becomes
149        // unreadable — silently, because the file would simply deserialise
150        // differently rather than error.
151        for (value, expected) in [
152            (SecretValue::new("v"), r#""v""#),
153            (
154                SecretValue::new(serde_json::json!({"std:access-token": "at"})),
155                r#"{"std:access-token":"at"}"#,
156            ),
157        ] {
158            let json = serde_json::to_string(&value).expect("serialise");
159            assert_eq!(json, expected, "a stored value must be its own JSON");
160            let back: SecretValue = serde_json::from_str(&json).expect("deserialise");
161            assert_eq!(back, value, "and must read back unchanged");
162        }
163    }
164
165    #[test]
166    fn an_object_value_round_trips() {
167        let v = SecretValue::new(serde_json::json!({
168            "std:access-token": "at",
169            "std:expires-at": 1_760_000_000u64,
170        }));
171        assert_eq!(v.expose()["std:access-token"], "at");
172        assert_eq!(v.expose_str(), None, "an object is not a string");
173    }
174
175    #[test]
176    fn a_string_value_still_reads_as_a_string() {
177        let v = SecretValue::new("sekrit");
178        assert_eq!(v.expose_str(), Some("sekrit"));
179    }
180
181    #[test]
182    fn debug_and_display_redact_an_object_including_its_members() {
183        // The phase-1 guarantee, restated for the shape that did not exist
184        // then: a map's members are material too, and a derived Debug on the
185        // inner Value would print every one of them.
186        let v = SecretValue::new(serde_json::json!({
187            "std:access-token": "ghp-sentinel-token",
188            "std:scopes": ["repo"],
189        }));
190        for rendered in [format!("{v:?}"), format!("{v}")] {
191            assert!(
192                !rendered.contains("ghp-sentinel-token") && !rendered.contains("repo"),
193                "redaction leaked: {rendered}"
194            );
195            assert!(rendered.contains("redacted"), "must say so: {rendered}");
196        }
197    }
198}