act_credentials/
record.rs1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6#[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 pub fn expose(&self) -> &serde_json::Value {
33 &self.0
34 }
35
36 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct SecretRecord {
58 pub kind: String,
59 pub fields: BTreeMap<String, SecretValue>,
61 #[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#[derive(Debug, Clone, PartialEq)]
72pub struct Secret {
73 pub kind: String,
74 pub fields: BTreeMap<String, SecretValue>,
75}
76
77#[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 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 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}