sherpack_engine/
secrets.rs1use minijinja::{Environment, Error, ErrorKind};
49use sherpack_core::{SecretCharset, SecretGenerator, SecretState};
50use std::sync::Arc;
51
52#[derive(Debug, Clone)]
60pub struct SecretFunctionState {
61 generator: Arc<std::sync::Mutex<SecretGenerator>>,
62}
63
64impl Default for SecretFunctionState {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl SecretFunctionState {
71 pub fn new() -> Self {
73 Self {
74 generator: Arc::new(std::sync::Mutex::new(SecretGenerator::new())),
75 }
76 }
77
78 pub fn with_state(state: SecretState) -> Self {
80 Self {
81 generator: Arc::new(std::sync::Mutex::new(SecretGenerator::with_state(state))),
82 }
83 }
84
85 pub fn is_dirty(&self) -> bool {
87 self.generator.lock().unwrap().is_dirty()
88 }
89
90 pub fn take_state(&self) -> SecretState {
95 let mut generator = self.generator.lock().unwrap();
96 let state = std::mem::take(&mut *generator);
97 state.into_state()
98 }
99
100 pub fn register(&self, env: &mut Environment<'static>) {
115 let generator = Arc::clone(&self.generator);
116
117 env.add_function(
118 "generate_secret",
119 move |name: String, length: i64, charset: Option<String>| -> Result<String, Error> {
120 if name.is_empty() {
122 return Err(Error::new(
123 ErrorKind::InvalidOperation,
124 "generate_secret: name cannot be empty",
125 ));
126 }
127
128 if length < 1 {
130 return Err(Error::new(
131 ErrorKind::InvalidOperation,
132 format!("generate_secret: length must be positive, got {}", length),
133 ));
134 }
135
136 if length > 4096 {
137 return Err(Error::new(
138 ErrorKind::InvalidOperation,
139 format!("generate_secret: length {} exceeds maximum of 4096", length),
140 ));
141 }
142
143 let charset = match charset {
145 Some(ref charset_str) => {
146 SecretCharset::parse(charset_str).ok_or_else(|| {
147 Error::new(
148 ErrorKind::InvalidOperation,
149 format!(
150 "generate_secret: unknown charset '{}'. Valid options: \
151 alphanumeric, alpha, numeric, hex, base64, urlsafe",
152 charset_str
153 ),
154 )
155 })?
156 }
157 None => SecretCharset::default(),
158 };
159
160 let mut secret_gen = generator.lock().unwrap();
162 let secret =
163 secret_gen.get_or_generate_with_charset(&name, length as usize, charset);
164
165 Ok(secret)
166 },
167 );
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn test_generate_secret_basic() {
177 let mut env = Environment::new();
178 let state = SecretFunctionState::new();
179 state.register(&mut env);
180
181 let template = r#"{{ generate_secret("test-password", 16) }}"#;
182 let result = env.render_str(template, ()).unwrap();
183
184 assert_eq!(result.len(), 16);
185 assert!(result.chars().all(|c| c.is_ascii_alphanumeric()));
186 }
187
188 #[test]
189 fn test_generate_secret_idempotent() {
190 let mut env = Environment::new();
191 let state = SecretFunctionState::new();
192 state.register(&mut env);
193
194 let template1 = r#"{{ generate_secret("db-password", 20) }}"#;
196 let result1 = env.render_str(template1, ()).unwrap();
197 let result2 = env.render_str(template1, ()).unwrap();
198
199 assert_eq!(result1, result2);
201 }
202
203 #[test]
204 fn test_generate_secret_different_names() {
205 let mut env = Environment::new();
206 let state = SecretFunctionState::new();
207 state.register(&mut env);
208
209 let template =
210 r#"{{ generate_secret("password1", 16) }}-{{ generate_secret("password2", 16) }}"#;
211 let result = env.render_str(template, ()).unwrap();
212
213 let parts: Vec<&str> = result.split('-').collect();
214 assert_eq!(parts.len(), 2);
215 assert_ne!(parts[0], parts[1]);
216 }
217
218 #[test]
219 fn test_generate_secret_hex_charset() {
220 let mut env = Environment::new();
221 let state = SecretFunctionState::new();
222 state.register(&mut env);
223
224 let template = r#"{{ generate_secret("hex-token", 32, "hex") }}"#;
225 let result = env.render_str(template, ()).unwrap();
226
227 assert_eq!(result.len(), 32);
228 assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
229 }
230
231 #[test]
232 fn test_generate_secret_numeric_charset() {
233 let mut env = Environment::new();
234 let state = SecretFunctionState::new();
235 state.register(&mut env);
236
237 let template = r#"{{ generate_secret("pin", 6, "numeric") }}"#;
238 let result = env.render_str(template, ()).unwrap();
239
240 assert_eq!(result.len(), 6);
241 assert!(result.chars().all(|c| c.is_ascii_digit()));
242 }
243
244 #[test]
245 fn test_generate_secret_invalid_charset() {
246 let mut env = Environment::new();
247 let state = SecretFunctionState::new();
248 state.register(&mut env);
249
250 let template = r#"{{ generate_secret("test", 16, "invalid") }}"#;
251 let result = env.render_str(template, ());
252
253 assert!(result.is_err());
254 let err = result.unwrap_err();
255 assert!(err.to_string().contains("unknown charset"));
256 }
257
258 #[test]
259 fn test_generate_secret_missing_args() {
260 let mut env = Environment::new();
261 let state = SecretFunctionState::new();
262 state.register(&mut env);
263
264 let template = r#"{{ generate_secret("only-name") }}"#;
266 let result = env.render_str(template, ());
267
268 assert!(result.is_err());
269 }
270
271 #[test]
272 fn test_generate_secret_invalid_length() {
273 let mut env = Environment::new();
274 let state = SecretFunctionState::new();
275 state.register(&mut env);
276
277 let result = env.render_str(r#"{{ generate_secret("test", 0) }}"#, ());
279 assert!(result.is_err());
280 assert!(result.unwrap_err().to_string().contains("must be positive"));
281
282 let mut env2 = Environment::new();
284 let state2 = SecretFunctionState::new();
285 state2.register(&mut env2);
286 let result = env2.render_str(r#"{{ generate_secret("test", -5) }}"#, ());
287 assert!(result.is_err());
288
289 let mut env3 = Environment::new();
291 let state3 = SecretFunctionState::new();
292 state3.register(&mut env3);
293 let result = env3.render_str(r#"{{ generate_secret("test", 10000) }}"#, ());
294 assert!(result.is_err());
295 assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
296 }
297
298 #[test]
299 fn test_state_is_dirty() {
300 let state = SecretFunctionState::new();
301 assert!(!state.is_dirty());
302
303 let mut env = Environment::new();
304 state.register(&mut env);
305
306 env.render_str(r#"{{ generate_secret("new-secret", 16) }}"#, ())
307 .unwrap();
308
309 assert!(state.is_dirty());
310 }
311
312 #[test]
313 fn test_state_persistence() {
314 let state1 = SecretFunctionState::new();
316 let mut env1 = Environment::new();
317 state1.register(&mut env1);
318
319 let secret = env1
320 .render_str(r#"{{ generate_secret("db-password", 24) }}"#, ())
321 .unwrap();
322
323 let persisted = state1.take_state();
325 let json = serde_json::to_string(&persisted).unwrap();
326
327 let loaded: SecretState = serde_json::from_str(&json).unwrap();
329 let state2 = SecretFunctionState::with_state(loaded);
330 let mut env2 = Environment::new();
331 state2.register(&mut env2);
332
333 let secret2 = env2
334 .render_str(r#"{{ generate_secret("db-password", 24) }}"#, ())
335 .unwrap();
336
337 assert_eq!(secret, secret2);
339 assert!(!state2.is_dirty());
341 }
342
343 #[test]
344 fn test_multiple_secrets_in_template() {
345 let state = SecretFunctionState::new();
346 let mut env = Environment::new();
347 state.register(&mut env);
348
349 let template = r#"
350postgres-password: {{ generate_secret("postgres-password", 24) }}
351replication-password: {{ generate_secret("replication-password", 24) }}
352api-key: {{ generate_secret("api-key", 32, "hex") }}
353"#;
354
355 let result = env.render_str(template, ()).unwrap();
356
357 let lines: Vec<&str> = result.lines().filter(|l| !l.is_empty()).collect();
359 assert_eq!(lines.len(), 3);
360
361 let postgres_pw = lines[0].split(": ").nth(1).unwrap();
362 let repl_pw = lines[1].split(": ").nth(1).unwrap();
363 let api_key = lines[2].split(": ").nth(1).unwrap();
364
365 assert_ne!(postgres_pw, repl_pw);
366 assert_ne!(postgres_pw, api_key);
367 assert_eq!(postgres_pw.len(), 24);
368 assert_eq!(repl_pw.len(), 24);
369 assert_eq!(api_key.len(), 32);
370 }
371}