Skip to content

Commit c9d1090

Browse files
committed
bump
1 parent be77f11 commit c9d1090

1 file changed

Lines changed: 174 additions & 35 deletions

File tree

src/auth.rs

Lines changed: 174 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::BTreeMap;
1+
use std::collections::{BTreeMap, BTreeSet};
22
use std::error::Error as StdError;
33
use std::fs;
44
use std::io::{IsTerminal, Write};
@@ -551,6 +551,8 @@ pub async fn login(base: &BaseArgs) -> Result<LoginContext> {
551551

552552
#[derive(Debug, Deserialize)]
553553
struct AiProviderSecret {
554+
#[serde(default)]
555+
id: Option<String>,
554556
name: String,
555557
#[serde(default)]
556558
r#type: Option<String>,
@@ -564,6 +566,18 @@ struct AiProviderSecret {
564566
created: Option<String>,
565567
}
566568

569+
#[derive(Debug, Default, Deserialize, Serialize)]
570+
struct AiProviderKeyStalenessWarningState {
571+
#[serde(default)]
572+
warned: BTreeSet<String>,
573+
}
574+
575+
#[derive(Debug, Clone, PartialEq, Eq)]
576+
struct StaleAiProviderSecret {
577+
name: String,
578+
warning_key: String,
579+
}
580+
567581
fn parse_ai_provider_secret_timestamp(value: Option<&str>) -> Option<DateTime<Utc>> {
568582
let value = value?.trim();
569583
if value.is_empty() {
@@ -574,12 +588,44 @@ fn parse_ai_provider_secret_timestamp(value: Option<&str>) -> Option<DateTime<Ut
574588
.map(|parsed| parsed.with_timezone(&Utc))
575589
}
576590

577-
fn stale_ai_provider_secret_names(secrets: &[AiProviderSecret], now: DateTime<Utc>) -> Vec<String> {
591+
fn ai_provider_warning_state_path() -> Result<PathBuf> {
592+
Ok(crate::config::global_config_dir()?.join("ai_provider_key_warnings.json"))
593+
}
594+
595+
fn load_ai_provider_warning_state() -> AiProviderKeyStalenessWarningState {
596+
let Ok(path) = ai_provider_warning_state_path() else {
597+
return AiProviderKeyStalenessWarningState::default();
598+
};
599+
let Ok(contents) = fs::read_to_string(path) else {
600+
return AiProviderKeyStalenessWarningState::default();
601+
};
602+
serde_json::from_str(&contents).unwrap_or_default()
603+
}
604+
605+
fn save_ai_provider_warning_state(state: &AiProviderKeyStalenessWarningState) -> Result<()> {
606+
let path = ai_provider_warning_state_path()?;
607+
let parent = path.parent().unwrap_or_else(|| Path::new("."));
608+
fs::create_dir_all(parent)?;
609+
610+
let json = serde_json::to_string_pretty(state)?;
611+
let mut file = tempfile::NamedTempFile::new_in(parent)?;
612+
file.write_all(json.as_bytes())?;
613+
file.write_all(b"\n")?;
614+
file.as_file().sync_all()?;
615+
file.persist(path)?;
616+
Ok(())
617+
}
618+
619+
fn stale_ai_provider_secrets(
620+
org_id: &str,
621+
secrets: &[AiProviderSecret],
622+
now: DateTime<Utc>,
623+
) -> Vec<StaleAiProviderSecret> {
578624
let Some(cutoff) = now.checked_sub_months(Months::new(6)) else {
579625
return Vec::new();
580626
};
581627

582-
let mut stale_names = secrets
628+
let mut stale = secrets
583629
.iter()
584630
.filter(|secret| {
585631
secret
@@ -588,29 +634,47 @@ fn stale_ai_provider_secret_names(secrets: &[AiProviderSecret], now: DateTime<Ut
588634
.is_some_and(|preview| !preview.trim().is_empty())
589635
})
590636
.filter_map(|secret| {
591-
let updated_at = parse_ai_provider_secret_timestamp(
592-
secret
593-
.secret_updated_at
594-
.as_deref()
595-
.or(secret.updated_at.as_deref())
596-
.or(secret.created.as_deref()),
597-
)?;
598-
(updated_at < cutoff).then(|| {
599-
secret
600-
.r#type
601-
.as_deref()
602-
.filter(|value| !value.trim().is_empty())
603-
.unwrap_or(&secret.name)
604-
.to_string()
605-
})
637+
let updated_at_raw = secret
638+
.secret_updated_at
639+
.as_deref()
640+
.or(secret.updated_at.as_deref())
641+
.or(secret.created.as_deref())?;
642+
let updated_at = parse_ai_provider_secret_timestamp(Some(updated_at_raw))?;
643+
if updated_at >= cutoff {
644+
return None;
645+
}
646+
let identity = secret
647+
.id
648+
.as_deref()
649+
.or(secret.r#type.as_deref())
650+
.unwrap_or(&secret.name);
651+
let name = secret.name.clone();
652+
let warning_key = format!("{org_id}:{identity}:{updated_at_raw}");
653+
Some(StaleAiProviderSecret { name, warning_key })
606654
})
607655
.collect::<Vec<_>>();
608-
stale_names.sort();
609-
stale_names
656+
stale.sort_by(|a, b| a.name.cmp(&b.name));
657+
stale
658+
}
659+
660+
fn unwarned_stale_ai_provider_secrets(
661+
stale: Vec<StaleAiProviderSecret>,
662+
state: &AiProviderKeyStalenessWarningState,
663+
) -> Vec<StaleAiProviderSecret> {
664+
stale
665+
.into_iter()
666+
.filter(|secret| !state.warned.contains(&secret.warning_key))
667+
.collect()
610668
}
611669

612670
async fn maybe_warn_ai_provider_key_staleness(base: &BaseArgs, ctx: &LoginContext) {
613-
if base.json || ui::is_quiet() || ctx.login.org_id().is_none_or(|org_id| org_id.trim().is_empty()) {
671+
if base.json
672+
|| ui::is_quiet()
673+
|| ctx
674+
.login
675+
.org_id()
676+
.is_none_or(|org_id| org_id.trim().is_empty())
677+
{
614678
return;
615679
}
616680
if AI_PROVIDER_KEY_STALENESS_WARNED.swap(true, Ordering::Relaxed) {
@@ -645,23 +709,29 @@ async fn warn_ai_provider_key_staleness(ctx: &LoginContext) -> Result<()> {
645709
.json::<Vec<AiProviderSecret>>()
646710
.await
647711
.context("failed to parse AI provider secrets response")?;
648-
let stale_names = stale_ai_provider_secret_names(&secrets, Utc::now());
649-
if stale_names.is_empty() {
712+
let stale = stale_ai_provider_secrets(&org_id, &secrets, Utc::now());
713+
if stale.is_empty() {
714+
return Ok(());
715+
}
716+
let mut state = load_ai_provider_warning_state();
717+
let to_warn = unwarned_stale_ai_provider_secrets(stale, &state);
718+
if to_warn.is_empty() {
650719
return Ok(());
651720
}
652721

653-
let label = if stale_names.len() == 1 {
654-
"key has"
655-
} else {
656-
"keys have"
657-
};
658-
ui::print_command_status(
659-
ui::CommandStatus::Warning,
660-
&format!(
661-
"The following AI provider {label} not been rotated in over 6 months: {}",
662-
stale_names.join(", ")
663-
),
664-
);
722+
for secret in &to_warn {
723+
ui::print_command_status(
724+
ui::CommandStatus::Warning,
725+
&format!(
726+
"We recommend disabling and rotating AI provider secrets periodically. This {} has not been rotated in over 6 months.",
727+
secret.name
728+
),
729+
);
730+
}
731+
state
732+
.warned
733+
.extend(to_warn.into_iter().map(|secret| secret.warning_key));
734+
let _ = save_ai_provider_warning_state(&state);
665735
Ok(())
666736
}
667737

@@ -3157,6 +3227,75 @@ mod tests {
31573227
}
31583228
}
31593229

3230+
fn dt(value: &str) -> DateTime<Utc> {
3231+
DateTime::parse_from_rfc3339(value)
3232+
.expect("timestamp")
3233+
.with_timezone(&Utc)
3234+
}
3235+
3236+
#[test]
3237+
fn stale_ai_provider_secrets_returns_configured_keys_older_than_six_months() {
3238+
let secrets = vec![
3239+
AiProviderSecret {
3240+
id: Some("openai-secret".to_string()),
3241+
name: "OPENAI_API_KEY".to_string(),
3242+
r#type: Some("openai".to_string()),
3243+
preview_secret: Some("********".to_string()),
3244+
secret_updated_at: Some("2025-11-12T00:00:00Z".to_string()),
3245+
updated_at: None,
3246+
created: None,
3247+
},
3248+
AiProviderSecret {
3249+
id: Some("anthropic-secret".to_string()),
3250+
name: "ANTHROPIC_API_KEY".to_string(),
3251+
r#type: Some("anthropic".to_string()),
3252+
preview_secret: Some("********".to_string()),
3253+
secret_updated_at: Some("2025-11-14T00:00:00Z".to_string()),
3254+
updated_at: None,
3255+
created: None,
3256+
},
3257+
AiProviderSecret {
3258+
id: Some("gemini-secret".to_string()),
3259+
name: "GEMINI_API_KEY".to_string(),
3260+
r#type: Some("google".to_string()),
3261+
preview_secret: None,
3262+
secret_updated_at: Some("2025-01-01T00:00:00Z".to_string()),
3263+
updated_at: None,
3264+
created: None,
3265+
},
3266+
];
3267+
3268+
let stale = stale_ai_provider_secrets("org-id", &secrets, dt("2026-05-13T00:00:00Z"));
3269+
3270+
assert_eq!(
3271+
stale,
3272+
vec![StaleAiProviderSecret {
3273+
name: "OPENAI_API_KEY".to_string(),
3274+
warning_key: "org-id:openai-secret:2025-11-12T00:00:00Z".to_string(),
3275+
}]
3276+
);
3277+
}
3278+
3279+
#[test]
3280+
fn unwarned_stale_ai_provider_secrets_skips_previously_warned_key_versions() {
3281+
let already_warned = StaleAiProviderSecret {
3282+
name: "openai".to_string(),
3283+
warning_key: "org-id:secret-id:2025-11-12T00:00:00Z".to_string(),
3284+
};
3285+
let newly_stale = StaleAiProviderSecret {
3286+
name: "openai".to_string(),
3287+
warning_key: "org-id:secret-id:2026-05-13T00:00:00Z".to_string(),
3288+
};
3289+
let state = AiProviderKeyStalenessWarningState {
3290+
warned: BTreeSet::from([already_warned.warning_key.clone()]),
3291+
};
3292+
3293+
let unwarned =
3294+
unwarned_stale_ai_provider_secrets(vec![already_warned, newly_stale.clone()], &state);
3295+
3296+
assert_eq!(unwarned, vec![newly_stale]);
3297+
}
3298+
31603299
fn env_test_lock() -> &'static Mutex<()> {
31613300
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
31623301
LOCK.get_or_init(|| Mutex::new(()))

0 commit comments

Comments
 (0)