Skip to main content

systemprompt_models/
secrets.rs

1//! Secrets document model.
2//!
3//! [`Secrets`] is the deserialized on-disk secrets file: OAuth at-rest
4//! pepper, database URLs, and provider credentials.
5//! [`OAUTH_AT_REST_PEPPER_MIN_LENGTH`] is the enforced minimum.
6//! Validation returns [`crate::errors::SecretsError`].
7//!
8//! Secret hygiene is enforced by the type, not by convention: the hand-written
9//! [`fmt::Debug`] redacts every credential so a stray `{:?}` or `?secrets`
10//! cannot leak into logs, and the [`Drop`] impl wipes the plaintext fields from
11//! memory via `zeroize`. `Serialize` is retained deliberately — operator
12//! tooling round-trips the document back to the on-disk secrets file, the one
13//! legitimate sink.
14
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::fmt;
18use zeroize::Zeroize;
19
20use crate::errors::SecretsError;
21
22pub const OAUTH_AT_REST_PEPPER_MIN_LENGTH: usize = 32;
23
24#[derive(Clone, Serialize, Deserialize)]
25pub struct Secrets {
26    pub oauth_at_rest_pepper: String,
27
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub manifest_signing_secret_seed: Option<String>,
30
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub signing_key_pem: Option<String>,
33
34    pub database_url: String,
35
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub database_write_url: Option<String>,
38
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub external_database_url: Option<String>,
41
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub internal_database_url: Option<String>,
44
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub gemini: Option<String>,
47
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub anthropic: Option<String>,
50
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub openai: Option<String>,
53
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub github: Option<String>,
56
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub moonshot: Option<String>,
59
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub qwen: Option<String>,
62
63    #[serde(default, flatten)]
64    pub custom: HashMap<String, String>,
65}
66
67impl fmt::Debug for Secrets {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.debug_struct("Secrets")
70            .field("ai_providers", &self.has_ai_provider())
71            .field("custom_keys", &self.custom.keys().collect::<Vec<_>>())
72            .finish_non_exhaustive()
73    }
74}
75
76impl Drop for Secrets {
77    fn drop(&mut self) {
78        self.oauth_at_rest_pepper.zeroize();
79        self.manifest_signing_secret_seed.zeroize();
80        self.signing_key_pem.zeroize();
81        self.database_url.zeroize();
82        self.database_write_url.zeroize();
83        self.external_database_url.zeroize();
84        self.internal_database_url.zeroize();
85        self.gemini.zeroize();
86        self.anthropic.zeroize();
87        self.openai.zeroize();
88        self.github.zeroize();
89        self.moonshot.zeroize();
90        self.qwen.zeroize();
91        for value in self.custom.values_mut() {
92            value.zeroize();
93        }
94    }
95}
96
97impl Secrets {
98    pub fn parse(content: &str) -> Result<Self, SecretsError> {
99        let mut value: serde_json::Value =
100            serde_json::from_str(content).map_err(|source| SecretsError::Parse {
101                context: "Failed to parse secrets JSON",
102                source,
103            })?;
104        if let Some(obj) = value.as_object_mut() {
105            obj.retain(|_, v| !v.is_null());
106        }
107        let secrets: Self =
108            serde_json::from_value(value).map_err(|source| SecretsError::Parse {
109                context: "Failed to deserialize secrets after null stripping",
110                source,
111            })?;
112        secrets.validate()?;
113        Ok(secrets)
114    }
115
116    pub fn validate(&self) -> Result<(), SecretsError> {
117        if self.oauth_at_rest_pepper.len() < OAUTH_AT_REST_PEPPER_MIN_LENGTH {
118            return Err(SecretsError::Invalid(format!(
119                "oauth_at_rest_pepper must be at least {} characters (got {})",
120                OAUTH_AT_REST_PEPPER_MIN_LENGTH,
121                self.oauth_at_rest_pepper.len()
122            )));
123        }
124        Ok(())
125    }
126
127    pub fn effective_database_url(&self, external_db_access: bool) -> &str {
128        if external_db_access {
129            if let Some(url) = &self.external_database_url {
130                return url;
131            }
132        }
133        &self.database_url
134    }
135
136    pub const fn has_ai_provider(&self) -> bool {
137        self.gemini.is_some()
138            || self.anthropic.is_some()
139            || self.openai.is_some()
140            || self.moonshot.is_some()
141            || self.qwen.is_some()
142    }
143
144    pub fn get(&self, key: &str) -> Option<&String> {
145        match key {
146            "oauth_at_rest_pepper" | "OAUTH_AT_REST_PEPPER" => Some(&self.oauth_at_rest_pepper),
147            "signing_key_pem" | "SIGNING_KEY_PEM" => self.signing_key_pem.as_ref(),
148            "database_url" | "DATABASE_URL" => Some(&self.database_url),
149            "database_write_url" | "DATABASE_WRITE_URL" => self.database_write_url.as_ref(),
150            "external_database_url" | "EXTERNAL_DATABASE_URL" => {
151                self.external_database_url.as_ref()
152            },
153            "internal_database_url" | "INTERNAL_DATABASE_URL" => {
154                self.internal_database_url.as_ref()
155            },
156            "gemini" | "GEMINI_API_KEY" => self.gemini.as_ref(),
157            "anthropic" | "ANTHROPIC_API_KEY" => self.anthropic.as_ref(),
158            "openai" | "OPENAI_API_KEY" => self.openai.as_ref(),
159            "github" | "GITHUB_TOKEN" => self.github.as_ref(),
160            "moonshot" | "MOONSHOT_API_KEY" | "kimi" | "KIMI_API_KEY" => self.moonshot.as_ref(),
161            "qwen" | "QWEN_API_KEY" | "dashscope" | "DASHSCOPE_API_KEY" => self.qwen.as_ref(),
162            other => self.custom.get(other).or_else(|| {
163                let alternate = if other.chars().any(char::is_uppercase) {
164                    other.to_lowercase()
165                } else {
166                    other.to_uppercase()
167                };
168                self.custom.get(&alternate)
169            }),
170        }
171    }
172
173    pub fn log_configured_providers(&self) {
174        let configured: Vec<&str> = [
175            self.gemini.as_ref().map(|_| "gemini"),
176            self.anthropic.as_ref().map(|_| "anthropic"),
177            self.openai.as_ref().map(|_| "openai"),
178            self.github.as_ref().map(|_| "github"),
179            self.moonshot.as_ref().map(|_| "moonshot"),
180            self.qwen.as_ref().map(|_| "qwen"),
181        ]
182        .into_iter()
183        .flatten()
184        .collect();
185
186        tracing::info!(providers = ?configured, "Configured API providers");
187    }
188
189    pub fn to_subprocess_env(&self) -> Vec<(String, String)> {
190        let mut pairs: Vec<(String, String)> = Vec::new();
191
192        pairs.push((
193            "OAUTH_AT_REST_PEPPER".to_owned(),
194            self.oauth_at_rest_pepper.clone(),
195        ));
196        pairs.push(("DATABASE_URL".to_owned(), self.database_url.clone()));
197
198        let optionals: &[(&str, &Option<String>)] = &[
199            (
200                "MANIFEST_SIGNING_SECRET_SEED",
201                &self.manifest_signing_secret_seed,
202            ),
203            ("SIGNING_KEY_PEM", &self.signing_key_pem),
204            ("DATABASE_WRITE_URL", &self.database_write_url),
205            ("EXTERNAL_DATABASE_URL", &self.external_database_url),
206            ("INTERNAL_DATABASE_URL", &self.internal_database_url),
207            ("GEMINI_API_KEY", &self.gemini),
208            ("ANTHROPIC_API_KEY", &self.anthropic),
209            ("OPENAI_API_KEY", &self.openai),
210            ("GITHUB_TOKEN", &self.github),
211            ("MOONSHOT_API_KEY", &self.moonshot),
212            ("QWEN_API_KEY", &self.qwen),
213        ];
214        for (name, value) in optionals {
215            if let Some(v) = value
216                && !v.is_empty()
217            {
218                pairs.push(((*name).to_owned(), v.clone()));
219            }
220        }
221
222        if !self.custom.is_empty() {
223            let mut names: Vec<String> = Vec::with_capacity(self.custom.len());
224            for (key, value) in &self.custom {
225                let upper = key.to_uppercase();
226                names.push(upper.clone());
227                pairs.push((upper.clone(), value.clone()));
228                if upper != *key {
229                    pairs.push((key.clone(), value.clone()));
230                }
231            }
232            pairs.push(("SYSTEMPROMPT_CUSTOM_SECRETS".to_owned(), names.join(",")));
233        }
234
235        pairs
236    }
237}