vct_core/quota/
refresh.rs1use anyhow::{Context, Result};
19use reqwest::StatusCode;
20use reqwest::blocking::RequestBuilder;
21use serde_json::Value;
22use std::path::Path;
23use std::time::SystemTime;
24
25pub const REFRESH_COOLDOWN_SECS: i64 = 300;
28
29pub const EXPIRY_SKEW_SECS: i64 = 60;
31
32#[derive(Default)]
39pub struct RefreshCooldown {
40 until: i64,
41 mtime: Option<SystemTime>,
42}
43
44impl RefreshCooldown {
45 pub fn active(&self, now: i64, cur_mtime: Option<SystemTime>) -> bool {
47 now < self.until && self.mtime == cur_mtime
48 }
49
50 pub fn arm(&mut self, now: i64, mtime: Option<SystemTime>) {
52 self.until = now + REFRESH_COOLDOWN_SECS;
53 self.mtime = mtime;
54 }
55
56 pub fn clear(&mut self) {
58 self.until = 0;
59 self.mtime = None;
60 }
61}
62
63pub fn file_mtime(path: &Path) -> Option<SystemTime> {
65 std::fs::metadata(path).and_then(|m| m.modified()).ok()
66}
67
68pub fn is_expiring(expires_at_secs: Option<i64>, now_secs: i64, skew_secs: i64) -> bool {
73 expires_at_secs.is_some_and(|exp| exp - now_secs <= skew_secs)
74}
75
76pub fn send_refresh(req: RequestBuilder) -> Result<(StatusCode, String)> {
83 let resp = req.send().context("token refresh request failed")?;
84 let status = resp.status();
85 let text = resp.text().unwrap_or_default();
86 Ok((status, text))
87}
88
89pub fn update_json_file_in_place(
102 path: &Path,
103 expected_mtime: Option<SystemTime>,
104 mutate: impl FnOnce(&mut Value) -> Result<()>,
105) -> Result<bool> {
106 if file_mtime(path) != expected_mtime {
108 return Ok(false);
109 }
110 let body = std::fs::read_to_string(path)
111 .with_context(|| format!("Failed to read {}", path.display()))?;
112 let mut root: Value = serde_json::from_str(&body).context("Failed to parse credential file")?;
113 mutate(&mut root)?;
114 if file_mtime(path) != expected_mtime {
119 return Ok(false);
120 }
121 crate::utils::write_json_atomic_pretty(path, &root)?;
122 Ok(true)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn is_expiring_unknown_is_never_expiring() {
131 assert!(!is_expiring(None, 1000, 60));
132 }
133
134 #[test]
135 fn is_expiring_boundaries() {
136 assert!(!is_expiring(Some(1061), 1000, 60));
138 assert!(is_expiring(Some(1060), 1000, 60));
140 assert!(is_expiring(Some(900), 1000, 60));
142 }
143
144 #[test]
145 fn cooldown_respects_mtime_change() {
146 let mt0 = SystemTime::UNIX_EPOCH;
147 let mt1 = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(5);
148 let mut cd = RefreshCooldown::default();
149 cd.arm(1000, Some(mt0));
150 assert!(cd.active(1100, Some(mt0)));
152 assert!(!cd.active(1100, Some(mt1)));
154 assert!(!cd.active(2000, Some(mt0)));
156 }
157
158 #[test]
159 fn update_in_place_preserves_unknown_and_aborts_on_mtime_change() {
160 use std::io::Write;
161 let dir = tempfile::tempdir().unwrap();
162 let path = dir.path().join("auth.json");
163 let mut f = std::fs::File::create(&path).unwrap();
164 write!(
165 f,
166 r#"{{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{{"access_token":"old","refresh_token":"oldr"}},"extra":123}}"#
167 )
168 .unwrap();
169 drop(f);
170
171 let mtime = file_mtime(&path);
172 let wrote = update_json_file_in_place(&path, mtime, |root| {
173 let t = root
174 .get_mut("tokens")
175 .and_then(|v| v.as_object_mut())
176 .unwrap();
177 t.insert("access_token".into(), serde_json::json!("new"));
178 t.insert("refresh_token".into(), serde_json::json!("newr"));
179 Ok(())
180 })
181 .unwrap();
182 assert!(wrote);
183
184 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
185 assert_eq!(v["tokens"]["access_token"], "new");
186 assert_eq!(v["tokens"]["refresh_token"], "newr");
187 assert_eq!(v["auth_mode"], "chatgpt");
189 assert_eq!(v["extra"], 123);
190 assert!(v.get("OPENAI_API_KEY").is_some());
191
192 let wrote2 = update_json_file_in_place(&path, Some(SystemTime::UNIX_EPOCH), |root| {
194 root["tokens"]["access_token"] = serde_json::json!("should-not-write");
195 Ok(())
196 })
197 .unwrap();
198 assert!(!wrote2);
199 let v2: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
200 assert_eq!(v2["tokens"]["access_token"], "new");
201 }
202
203 #[test]
204 fn claude_write_back_preserves_design_oauth_and_ms_expiry() {
205 use std::io::Write;
206 let dir = tempfile::tempdir().unwrap();
207 let path = dir.path().join(".credentials.json");
208 let mut f = std::fs::File::create(&path).unwrap();
209 write!(
210 f,
211 r#"{{"claudeAiOauth":{{"accessToken":"old","refreshToken":"oldr","expiresAt":1,"scopes":["user:inference"],"subscriptionType":"max","rateLimitTier":"t"}},"designOauth":{{"accessToken":"design"}}}}"#
212 )
213 .unwrap();
214 drop(f);
215
216 let mtime = file_mtime(&path);
217 let wrote = update_json_file_in_place(&path, mtime, |root| {
218 let o = root
219 .get_mut("claudeAiOauth")
220 .and_then(|v| v.as_object_mut())
221 .unwrap();
222 o.insert("accessToken".into(), serde_json::json!("newacc"));
223 o.insert("refreshToken".into(), serde_json::json!("newref"));
224 o.insert("expiresAt".into(), serde_json::json!(1783108188604i64));
225 Ok(())
226 })
227 .unwrap();
228 assert!(wrote);
229
230 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
231 assert_eq!(v["claudeAiOauth"]["accessToken"], "newacc");
232 assert_eq!(v["claudeAiOauth"]["refreshToken"], "newref");
233 assert_eq!(v["claudeAiOauth"]["expiresAt"], 1783108188604i64);
235 assert!(v["claudeAiOauth"]["expiresAt"].is_number());
236 assert_eq!(v["claudeAiOauth"]["subscriptionType"], "max");
238 assert_eq!(v["claudeAiOauth"]["rateLimitTier"], "t");
239 assert_eq!(v["designOauth"]["accessToken"], "design");
240 }
241}