use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SecretValue(serde_json::Value);
impl SecretValue {
pub fn new(v: impl Into<serde_json::Value>) -> Self {
Self(v.into())
}
pub fn expose(&self) -> &serde_json::Value {
&self.0
}
pub fn expose_str(&self) -> Option<&str> {
self.0.as_str()
}
}
impl fmt::Debug for SecretValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretValue(<redacted>)")
}
}
impl fmt::Display for SecretValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SecretRecord {
pub kind: String,
pub fields: BTreeMap<String, SecretValue>,
#[serde(default)]
pub host_only: BTreeMap<String, SecretValue>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub expires_at: Option<i64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Secret {
pub kind: String,
pub fields: BTreeMap<String, SecretValue>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SecretInfo {
pub key: String,
pub kind: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub expires_at: Option<i64>,
}
impl SecretRecord {
pub fn project(&self) -> Secret {
Secret {
kind: self.kind.clone(),
fields: self.fields.clone(),
}
}
pub fn info(&self, key: &str) -> SecretInfo {
SecretInfo {
key: key.to_string(),
kind: self.kind.clone(),
description: self.description.clone(),
expires_at: self.expires_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record() -> SecretRecord {
let mut fields = BTreeMap::new();
fields.insert("std:access-token".to_string(), SecretValue::new("at-123"));
let mut host_only = BTreeMap::new();
host_only.insert("std:refresh-token".to_string(), SecretValue::new("rt-456"));
SecretRecord {
kind: "std:oauth2".into(),
fields,
host_only,
description: Some("Notion".into()),
expires_at: Some(1_800_000_000),
}
}
#[test]
fn projection_drops_the_host_only_compartment() {
let projected = record().project();
assert!(projected.fields.contains_key("std:access-token"));
assert!(
!projected.fields.contains_key("std:refresh-token"),
"a refresh token must never cross the sandbox boundary"
);
}
#[test]
fn debug_never_prints_a_value() {
let rendered = format!("{:?}", record());
assert!(!rendered.contains("at-123"));
assert!(!rendered.contains("rt-456"));
assert!(
rendered.contains("std:oauth2"),
"non-secret fields stay legible"
);
}
#[test]
fn the_stored_json_is_the_value_itself() {
for (value, expected) in [
(SecretValue::new("v"), r#""v""#),
(
SecretValue::new(serde_json::json!({"std:access-token": "at"})),
r#"{"std:access-token":"at"}"#,
),
] {
let json = serde_json::to_string(&value).expect("serialise");
assert_eq!(json, expected, "a stored value must be its own JSON");
let back: SecretValue = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back, value, "and must read back unchanged");
}
}
#[test]
fn an_object_value_round_trips() {
let v = SecretValue::new(serde_json::json!({
"std:access-token": "at",
"std:expires-at": 1_760_000_000u64,
}));
assert_eq!(v.expose()["std:access-token"], "at");
assert_eq!(v.expose_str(), None, "an object is not a string");
}
#[test]
fn a_string_value_still_reads_as_a_string() {
let v = SecretValue::new("sekrit");
assert_eq!(v.expose_str(), Some("sekrit"));
}
#[test]
fn debug_and_display_redact_an_object_including_its_members() {
let v = SecretValue::new(serde_json::json!({
"std:access-token": "ghp-sentinel-token",
"std:scopes": ["repo"],
}));
for rendered in [format!("{v:?}"), format!("{v}")] {
assert!(
!rendered.contains("ghp-sentinel-token") && !rendered.contains("repo"),
"redaction leaked: {rendered}"
);
assert!(rendered.contains("redacted"), "must say so: {rendered}");
}
}
}