1#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
54
55use std::path::{Path, PathBuf};
56use std::time::{SystemTime, UNIX_EPOCH};
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum CredentialState {
61 Fresh,
63 RefreshWindow,
66 Grace,
69 Expired,
71}
72
73pub trait LifecyclePolicy: Send + Sync {
83 fn max_age_secs(&self, risk_level: u8) -> u64;
85
86 fn refresh_window_secs(&self, risk_level: u8) -> u64;
89
90 fn grace_period_secs(&self, risk_level: u8) -> u64;
93
94 fn session_timeout_secs(&self, _risk_level: u8) -> Option<u64> {
98 None
99 }
100}
101
102pub fn classify_credential(
116 issued_at: u64,
117 session_start: u64,
118 now: u64,
119 policy: &dyn LifecyclePolicy,
120 risk_level: u8,
121) -> CredentialState {
122 if let Some(session_max) = policy.session_timeout_secs(risk_level) {
124 if now.saturating_sub(session_start) >= session_max {
125 return CredentialState::Expired;
126 }
127 }
128
129 let age = now.saturating_sub(issued_at);
130 let max_age = policy.max_age_secs(risk_level);
131 let refresh = policy.refresh_window_secs(risk_level);
132 let grace = policy.grace_period_secs(risk_level);
133
134 if age < max_age {
135 CredentialState::Fresh
136 } else if age < max_age + refresh {
137 CredentialState::RefreshWindow
138 } else if age < max_age + refresh + grace {
139 CredentialState::Grace
140 } else {
141 CredentialState::Expired
142 }
143}
144
145pub fn now_secs() -> u64 {
147 system_time_secs(SystemTime::now())
148}
149
150pub fn system_time_secs(time: SystemTime) -> u64 {
152 time.duration_since(UNIX_EPOCH)
153 .map(|d| d.as_secs())
154 .unwrap_or(0)
155}
156
157pub fn encode_cache_component(input: &str) -> String {
162 let mut output = String::with_capacity(input.len());
163 for c in input.chars() {
164 match c {
165 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => output.push(c),
166 _ => {
167 for byte in c.to_string().as_bytes() {
168 output.push('~');
169 output.push_str(&format!("{byte:02X}"));
170 }
171 }
172 }
173 }
174 output
175}
176
177pub fn cache_file_path(cache_dir: &Path, components: &[&str], extension: &str) -> PathBuf {
182 let encoded: Vec<String> = components
183 .iter()
184 .map(|c| encode_cache_component(c))
185 .collect();
186 let filename = format!("{}.{}", encoded.join("-"), extension);
187 cache_dir.join(filename)
188}
189
190#[allow(unused_qualifications)]
195pub fn validate_https_url(url: &str, field_name: &str) -> std::result::Result<(), String> {
196 if url.starts_with("https://") {
197 Ok(())
198 } else if url.starts_with("http://") {
199 Err(format!(
200 "{field_name} must use HTTPS (got {url}); cleartext HTTP is not allowed for credential endpoints"
201 ))
202 } else {
203 Err(format!("{field_name} must be an HTTPS URL (got {url})"))
204 }
205}
206
207pub fn clear_cache_files(paths: &[PathBuf]) {
212 for path in paths {
213 if path.exists() {
214 if let Err(e) = std::fs::remove_file(path) {
215 tracing::warn!("failed to remove cache file {}: {e}", path.display());
216 }
217 }
218 }
219}
220
221pub fn exec_with_credential(
227 env_var: &str,
228 credential: &str,
229 command: &[String],
230) -> std::io::Result<std::process::ExitStatus> {
231 if command.is_empty() {
232 return Err(std::io::Error::new(
233 std::io::ErrorKind::InvalidInput,
234 "no command specified",
235 ));
236 }
237
238 let mut cmd = std::process::Command::new(&command[0]);
239 if command.len() > 1 {
240 cmd.args(&command[1..]);
241 }
242 cmd.env(env_var, credential);
243 cmd.status()
244}
245
246pub fn exec_with_credential_owned(
252 env_var: &str,
253 mut credential: String,
254 command: &[String],
255) -> std::io::Result<std::process::ExitStatus> {
256 let status = exec_with_credential(env_var, &credential, command)?;
257 zeroize::Zeroize::zeroize(&mut credential);
258 Ok(status)
259}
260
261#[cfg(test)]
262#[allow(clippy::unwrap_used, clippy::panic)]
263mod tests {
264 use super::*;
265
266 struct TestPolicy {
268 max_age: u64,
269 refresh: u64,
270 grace: u64,
271 session_timeout: Option<u64>,
272 }
273
274 impl TestPolicy {
275 fn new(max_age: u64, refresh: u64, grace: u64) -> Self {
276 Self {
277 max_age,
278 refresh,
279 grace,
280 session_timeout: None,
281 }
282 }
283
284 fn with_session_timeout(mut self, timeout: u64) -> Self {
285 self.session_timeout = Some(timeout);
286 self
287 }
288 }
289
290 impl LifecyclePolicy for TestPolicy {
291 fn max_age_secs(&self, _risk_level: u8) -> u64 {
292 self.max_age
293 }
294 fn refresh_window_secs(&self, _risk_level: u8) -> u64 {
295 self.refresh
296 }
297 fn grace_period_secs(&self, _risk_level: u8) -> u64 {
298 self.grace
299 }
300 fn session_timeout_secs(&self, _risk_level: u8) -> Option<u64> {
301 self.session_timeout
302 }
303 }
304
305 #[test]
306 fn fresh_within_max_age() {
307 let policy = TestPolicy::new(3600, 600, 300);
308 let now = 10_000;
309 let issued = now - 1800; assert_eq!(
311 classify_credential(issued, issued, now, &policy, 1),
312 CredentialState::Fresh
313 );
314 }
315
316 #[test]
317 fn refresh_window_after_max_age() {
318 let policy = TestPolicy::new(3600, 600, 300);
319 let now = 10_000;
320 let issued = now - 3900; assert_eq!(
322 classify_credential(issued, issued, now, &policy, 1),
323 CredentialState::RefreshWindow
324 );
325 }
326
327 #[test]
328 fn grace_after_refresh_window() {
329 let policy = TestPolicy::new(3600, 600, 300);
330 let now = 10_000;
331 let issued = now - 4300; assert_eq!(
333 classify_credential(issued, issued, now, &policy, 1),
334 CredentialState::Grace
335 );
336 }
337
338 #[test]
339 fn expired_after_grace() {
340 let policy = TestPolicy::new(3600, 600, 300);
341 let now = 10_000;
342 let issued = now - 5000; assert_eq!(
344 classify_credential(issued, issued, now, &policy, 1),
345 CredentialState::Expired
346 );
347 }
348
349 #[test]
350 fn session_timeout_overrides_fresh() {
351 let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(7200);
352 let now = 10_000;
353 let session_start = now - 8000; let issued = now - 100; assert_eq!(
356 classify_credential(issued, session_start, now, &policy, 1),
357 CredentialState::Expired
358 );
359 }
360
361 #[test]
362 fn no_session_timeout_by_default() {
363 let policy = TestPolicy::new(3600, 600, 300);
364 let now = 10_000;
365 let session_start = 0; let issued = now - 100; assert_eq!(
368 classify_credential(issued, session_start, now, &policy, 1),
369 CredentialState::Fresh
370 );
371 }
372
373 #[test]
374 fn boundary_exactly_at_max_age() {
375 let policy = TestPolicy::new(3600, 600, 300);
376 let now = 10_000;
377 let issued = now - 3600; assert_eq!(
379 classify_credential(issued, issued, now, &policy, 1),
380 CredentialState::RefreshWindow
381 );
382 }
383
384 #[test]
385 fn zero_age_is_fresh() {
386 let policy = TestPolicy::new(3600, 600, 300);
387 let now = 10_000;
388 assert_eq!(
389 classify_credential(now, now, now, &policy, 1),
390 CredentialState::Fresh
391 );
392 }
393
394 #[test]
395 fn encode_cache_component_simple() {
396 assert_eq!(encode_cache_component("my-server"), "my-server");
397 assert_eq!(
398 encode_cache_component("prod.example.com"),
399 "prod.example.com"
400 );
401 }
402
403 #[test]
404 fn encode_cache_component_special_chars() {
405 assert_eq!(encode_cache_component("foo/bar"), "foo~2Fbar");
406 assert_eq!(encode_cache_component("a:b"), "a~3Ab");
407 assert_eq!(encode_cache_component("hello world"), "hello~20world");
408 }
409
410 #[test]
411 fn encode_cache_component_empty() {
412 assert_eq!(encode_cache_component(""), "");
413 }
414
415 #[test]
416 fn cache_file_path_single_component() {
417 let dir = Path::new("/tmp/cache");
418 let path = cache_file_path(dir, &["myserver"], "bin");
419 assert_eq!(path, PathBuf::from("/tmp/cache/myserver.bin"));
420 }
421
422 #[test]
423 fn cache_file_path_multiple_components() {
424 let dir = Path::new("/tmp/cache");
425 let path = cache_file_path(dir, &["server", "prod", "default"], "bin");
426 assert_eq!(path, PathBuf::from("/tmp/cache/server-prod-default.bin"));
427 }
428
429 #[test]
430 fn cache_file_path_encodes_special_chars() {
431 let dir = Path::new("/tmp/cache");
432 let path = cache_file_path(dir, &["my/server", "env:prod"], "cache");
433 assert_eq!(
434 path,
435 PathBuf::from("/tmp/cache/my~2Fserver-env~3Aprod.cache")
436 );
437 }
438
439 #[test]
440 fn validate_https_url_accepts_https() {
441 assert!(validate_https_url("https://example.com/auth", "oauth_url").is_ok());
442 }
443
444 #[test]
445 fn validate_https_url_rejects_http() {
446 let err = validate_https_url("http://example.com/auth", "oauth_url").unwrap_err();
447 assert!(err.contains("HTTPS"));
448 assert!(err.contains("oauth_url"));
449 }
450
451 #[test]
452 fn validate_https_url_rejects_other() {
453 let err = validate_https_url("ftp://example.com", "token_url").unwrap_err();
454 assert!(err.contains("HTTPS"));
455 }
456
457 #[test]
458 fn exec_with_credential_rejects_empty_command() {
459 let result = exec_with_credential("TOKEN", "secret", &[]);
460 assert!(result.is_err());
461 }
462
463 #[test]
464 fn now_secs_returns_nonzero() {
465 assert!(now_secs() > 1_000_000_000); }
467
468 #[test]
469 fn classify_issued_in_future_is_fresh() {
470 let policy = TestPolicy::new(3600, 600, 300);
472 let now = 10_000;
473 let issued = now + 100; assert_eq!(
475 classify_credential(issued, issued, now, &policy, 1),
476 CredentialState::Fresh
477 );
478 }
479
480 #[test]
481 fn classify_exactly_one_before_refresh_window() {
482 let policy = TestPolicy::new(3600, 600, 300);
483 let now = 10_000;
484 let issued = now - 3599; assert_eq!(
486 classify_credential(issued, issued, now, &policy, 1),
487 CredentialState::Fresh
488 );
489 }
490
491 #[test]
492 fn classify_exactly_at_refresh_window_end() {
493 let policy = TestPolicy::new(3600, 600, 300);
494 let now = 10_000;
495 let issued = now - (3600 + 600);
497 assert_eq!(
498 classify_credential(issued, issued, now, &policy, 1),
499 CredentialState::Grace
500 );
501 }
502
503 #[test]
504 fn classify_one_before_refresh_window_end() {
505 let policy = TestPolicy::new(3600, 600, 300);
506 let now = 10_000;
507 let issued = now - (3600 + 600 - 1);
509 assert_eq!(
510 classify_credential(issued, issued, now, &policy, 1),
511 CredentialState::RefreshWindow
512 );
513 }
514
515 #[test]
516 fn classify_exactly_at_grace_end() {
517 let policy = TestPolicy::new(3600, 600, 300);
518 let now = 10_000;
519 let issued = now - (3600 + 600 + 300);
521 assert_eq!(
522 classify_credential(issued, issued, now, &policy, 1),
523 CredentialState::Expired
524 );
525 }
526
527 #[test]
528 fn classify_one_before_grace_end() {
529 let policy = TestPolicy::new(3600, 600, 300);
530 let now = 10_000;
531 let issued = now - (3600 + 600 + 300 - 1);
533 assert_eq!(
534 classify_credential(issued, issued, now, &policy, 1),
535 CredentialState::Grace
536 );
537 }
538
539 #[test]
540 fn session_timeout_at_exact_boundary_is_expired() {
541 let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(1000);
543 let now = 10_000;
544 let session_start = now - 1000;
545 let issued = now - 10; assert_eq!(
547 classify_credential(issued, session_start, now, &policy, 1),
548 CredentialState::Expired
549 );
550 }
551
552 #[test]
553 fn session_timeout_one_before_boundary_is_not_expired() {
554 let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(1000);
555 let now = 10_000;
556 let session_start = now - 999; let issued = now - 10;
558 assert_eq!(
559 classify_credential(issued, session_start, now, &policy, 1),
560 CredentialState::Fresh
561 );
562 }
563
564 #[test]
565 fn encode_cache_component_tilde_is_encoded() {
566 let encoded = encode_cache_component("a~b");
568 assert!(!encoded.contains('~') || encoded.contains("~7E") || encoded.starts_with("a~7E"));
569 assert!(encoded.contains("7E") || !encoded.contains('~'));
571 }
572
573 #[test]
574 fn encode_cache_component_unicode_multi_byte() {
575 let encoded = encode_cache_component("café");
577 assert!(encoded.starts_with("caf"));
579 assert!(!encoded.contains('é'));
580 assert!(encoded.contains('~')); }
582
583 #[test]
584 fn validate_https_url_empty_string_is_error() {
585 let err = validate_https_url("", "endpoint").unwrap_err();
586 assert!(err.contains("HTTPS") || err.contains("endpoint"));
587 }
588
589 #[test]
590 fn validate_https_url_no_scheme() {
591 let err = validate_https_url("example.com/api", "url").unwrap_err();
592 assert!(err.contains("HTTPS"));
593 }
594
595 #[test]
596 fn validate_https_url_ftp_scheme_is_error() {
597 let err = validate_https_url("ftp://example.com/auth", "ftp_url").unwrap_err();
598 assert!(err.contains("HTTPS"));
599 assert!(err.contains("ftp_url"));
600 }
601
602 #[test]
603 fn cache_file_path_empty_components_list() {
604 let dir = Path::new("/tmp/cache");
605 let path = cache_file_path(dir, &[], "bin");
606 assert_eq!(path, PathBuf::from("/tmp/cache/.bin"));
608 }
609
610 #[test]
611 fn system_time_secs_before_epoch_returns_zero() {
612 let before_epoch = SystemTime::UNIX_EPOCH
614 .checked_sub(std::time::Duration::from_secs(1))
615 .unwrap();
616 assert_eq!(system_time_secs(before_epoch), 0);
617 }
618}