1use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20
21use crate::cache::{Cache, acquire_lock_async};
22use crate::error::{AppError, Result};
23use crate::usage::{AntigravitySnapshot, UsageWindow};
24
25const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
26const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
27
28const QUOTA_RPC: &str = "exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
29const STATUS_RPC: &str = "exa.language_server_pb.LanguageServerService/GetUserStatus";
30
31const DEFAULT_PLAN: &str = "Antigravity";
32
33pub type FetchOutcome = crate::outcome::Outcome<AntigravitySnapshot>;
36
37impl From<FetchOutcome> for crate::vendor::VendorOutcome {
38 fn from(o: FetchOutcome) -> Self {
39 o.map(crate::usage::VendorSnapshot::Antigravity)
40 }
41}
42
43pub async fn fetch_snapshot(
44 client: &reqwest::Client,
45 cache: &Cache,
46 cache_ttl: Duration,
47) -> Result<FetchOutcome> {
48 fetch_snapshot_at(client, cache, cache_ttl, Utc::now()).await
49}
50
51pub async fn fetch_snapshot_at(
54 client: &reqwest::Client,
55 cache: &Cache,
56 cache_ttl: Duration,
57 now: DateTime<Utc>,
58) -> Result<FetchOutcome> {
59 cache.ensure_dir()?;
60 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
61
62 let session = open_session(client).await;
67 let account = session.as_ref().ok().map(|s| s.account.as_str());
68
69 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
70 && let Ok(outcome) = reuse_cache(bytes, cache, false, account, now)
71 {
72 return Ok(outcome);
73 }
74
75 match fetch_live(client, session).await {
76 Ok(snap) => {
77 let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
78 cache.write_payload(&bytes)?;
79 Ok(crate::outcome::Outcome::fresh(snap))
80 }
81 Err(e) if e.is_transient() => fallback_silent(cache, now, e),
82 Err(AppError::Http { status, body }) => {
83 cache.mark_stale();
84 let last_error = Some(cache.write_last_error(status, &body));
85 let reason = AppError::Http { status, body };
86 fallback_with_error(cache, last_error, reason, now)
87 }
88 Err(e) => {
89 cache.mark_stale();
90 let last_error = Some(cache.write_last_error(0, &e.to_string()));
91 fallback_with_error(cache, last_error, e, now)
92 }
93 }
94}
95
96struct Session {
99 base: String,
100 csrf: Option<String>,
101 plan: String,
102 account: String,
103}
104
105async fn open_session(client: &reqwest::Client) -> Result<Session> {
109 let bases = candidate_bases();
110 if bases.is_empty() {
111 return Err(AppError::Credentials(
112 "Antigravity: no local server found. Quota is only served while Antigravity is \
113 running — open the Antigravity app, or an interactive `agy` session, or point \
114 ANTIGRAVITY_LS_ADDRESS at a host:port."
115 .into(),
116 ));
117 }
118
119 let mut errors = Vec::new();
120 for base in bases {
121 let csrf = fetch_csrf(client, &base).await;
122 match post_rpc(client, &base, csrf.as_deref(), STATUS_RPC).await {
123 Ok(v) => {
124 return Ok(Session {
125 base,
126 csrf,
127 plan: plan_from_status(&v),
128 account: account_key(&v),
129 });
130 }
131 Err(e) => errors.push(e),
132 }
133 }
134 Err(select_probe_error(errors))
135}
136
137fn select_probe_error(errors: Vec<AppError>) -> AppError {
152 let mut actionable = None;
153 let mut last = None;
154 let mut echo = None;
155 for e in errors {
156 if actionable.is_none() && is_actionable(&e) {
157 actionable = Some(e);
158 } else if is_tls_echo(&e) {
159 echo = Some(e);
160 } else {
161 last = Some(e);
162 }
163 }
164 actionable.or(last).or(echo).unwrap_or_else(|| {
165 AppError::Other("antigravity: no local server answered GetUserStatus".into())
166 })
167}
168
169fn is_actionable(e: &AppError) -> bool {
173 matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
174}
175
176fn is_tls_echo(e: &AppError) -> bool {
189 matches!(
190 e,
191 AppError::Http { status: 400, body } if body.contains("HTTP request to an HTTPS server")
192 )
193}
194
195async fn fetch_live(
196 client: &reqwest::Client,
197 session: Result<Session>,
198) -> Result<AntigravitySnapshot> {
199 let session = session?;
200 let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
201 let mut snap = parse_quota_summary("a, session.plan)?;
202 snap.account = session.account;
203 Ok(snap)
204}
205
206fn account_key(user_status: &serde_json::Value) -> String {
211 let email = user_status["userStatus"]["email"]
212 .as_str()
213 .filter(|s| !s.is_empty());
214 match email {
215 Some(e) => {
216 use std::hash::{Hash, Hasher};
217 let mut h = std::collections::hash_map::DefaultHasher::new();
218 e.hash(&mut h);
219 format!("acct:{:016x}", h.finish())
220 }
221 None => "acct:unknown".to_string(),
222 }
223}
224
225async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
230 let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
231 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
234 .await
235 .ok()?;
236 let html = String::from_utf8_lossy(&bytes);
237 html.split("csrfToken\":\"")
238 .nth(1)
239 .and_then(|s| s.split('"').next())
240 .filter(|t| !t.is_empty())
241 .map(|t| t.to_string())
242}
243
244async fn post_rpc(
245 client: &reqwest::Client,
246 base: &str,
247 csrf: Option<&str>,
248 rpc: &str,
249) -> Result<serde_json::Value> {
250 let mut req = client
251 .post(format!("{base}/{rpc}"))
252 .header("Content-Type", "application/json")
253 .body("{}")
254 .timeout(HTTP_TIMEOUT);
255 if let Some(token) = csrf {
256 req = req.header("x-codeium-csrf-token", token);
257 }
258 let resp = req.send().await?;
259
260 let status = resp.status();
261 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
265 if !status.is_success() {
266 let body = String::from_utf8_lossy(&bytes).into_owned();
267 return Err(AppError::Http {
268 status: status.as_u16(),
269 body,
270 });
271 }
272
273 Ok(serde_json::from_slice(&bytes)?)
274}
275
276pub fn plan_from_status(v: &serde_json::Value) -> String {
277 v["userStatus"]["userTier"]["name"]
278 .as_str()
279 .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
280 .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
281 .filter(|s| !s.is_empty())
282 .unwrap_or(DEFAULT_PLAN)
283 .to_string()
284}
285
286fn describe_bucket(group_name: &str, id: &str, window: Option<&str>) -> String {
297 let id = if id.is_empty() { "<unnamed>" } else { id };
298 let group = if group_name.is_empty() {
299 String::new()
300 } else {
301 format!(" in {group_name:?}")
302 };
303 match window {
304 Some(window) if !window.is_empty() => format!("{id} (window {window}){group}"),
305 _ => format!("{id}{group}"),
306 }
307}
308
309fn no_usable_bucket(seen: &[String]) -> AppError {
315 if seen.is_empty() {
316 return AppError::Other(
317 "antigravity: quota summary has no buckets at all — the running product may \
318 not have a quota for this account yet"
319 .into(),
320 );
321 }
322 AppError::Other(format!(
323 "antigravity: quota summary has no bucket in a window we recognise (5h or \
324 weekly, Gemini or Claude/GPT); it offered: {}",
325 seen.join(", ")
326 ))
327}
328
329pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
330 let groups = v["response"]["groups"]
331 .as_array()
332 .or_else(|| v["groups"].as_array())
333 .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
334
335 let mut gemini_5h = None;
336 let mut gemini_weekly = None;
337 let mut tp_5h = None;
338 let mut tp_weekly = None;
339 let mut seen: Vec<String> = Vec::new();
343
344 for group in groups {
345 let group_name = group["displayName"].as_str().unwrap_or_default();
346 let Some(buckets) = group["buckets"].as_array() else {
347 continue;
348 };
349 for bucket in buckets {
350 let id = bucket["bucketId"].as_str().unwrap_or_default();
351 seen.push(describe_bucket(group_name, id, bucket["window"].as_str()));
352 let window = bucket["window"].as_str().unwrap_or_default();
353 let is_weekly = if id.ends_with("weekly") || window == "weekly" {
354 true
355 } else if id.ends_with("5h") || window == "5h" {
356 false
357 } else {
358 continue;
361 };
362 let is_gemini = if id.starts_with("gemini") {
363 true
364 } else if id.starts_with("3p") {
365 false
366 } else if group_name.contains("Gemini") {
367 true
368 } else if group_name.contains("Claude") || group_name.contains("GPT") {
369 false
370 } else {
371 continue;
374 };
375
376 let (slot, slot_name) = match (is_gemini, is_weekly) {
377 (true, false) => (&mut gemini_5h, "Gemini 5h"),
378 (true, true) => (&mut gemini_weekly, "Gemini weekly"),
379 (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
380 (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
381 };
382 let parsed = usage_window(bucket, is_weekly)?;
383 if slot.replace(parsed).is_some() {
384 return Err(AppError::Schema(format!(
385 "antigravity: duplicate {slot_name} bucket"
386 )));
387 }
388 }
389 }
390
391 if gemini_5h.is_none() && gemini_weekly.is_none() && tp_5h.is_none() && tp_weekly.is_none() {
395 return Err(no_usable_bucket(&seen));
396 }
397
398 Ok(AntigravitySnapshot {
399 plan,
400 account: String::new(),
402 session: gemini_5h,
403 weekly: gemini_weekly,
404 third_party_session: tp_5h,
405 third_party_weekly: tp_weekly,
406 })
407}
408
409fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
413 let remaining = bucket["remainingFraction"]
414 .as_f64()
415 .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
416 .ok_or_else(|| {
417 AppError::Schema(format!(
418 "antigravity: bucket {} has no valid remainingFraction in 0..=1",
419 bucket["bucketId"].as_str().unwrap_or("<unnamed>")
420 ))
421 })?;
422 Ok(UsageWindow {
423 utilization_pct: pct_used(remaining),
424 resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
425 window_duration: if is_weekly {
426 chrono::Duration::days(7)
427 } else {
428 chrono::Duration::hours(5)
429 },
430 })
431}
432
433fn pct_used(remaining_fraction: f64) -> i32 {
436 let used = (1.0 - remaining_fraction) * 100.0;
437 used.round() as i32
438}
439
440fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
441 match value {
442 serde_json::Value::Null => Ok(None),
443 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
444 .map(|dt| Some(dt.with_timezone(&Utc)))
445 .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
446 _ => Err(AppError::Schema(format!(
447 "antigravity: {field} must be a timestamp or null"
448 ))),
449 }
450}
451
452fn candidate_bases() -> Vec<String> {
458 candidate_bases_with(
459 std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
460 discover_ls_ports(),
461 )
462}
463
464fn candidate_bases_with(override_addr: Option<&str>, discovered: Vec<u16>) -> Vec<String> {
467 let mut bases = Vec::new();
468 if let Some(base) = override_addr.and_then(normalize_base) {
469 bases.push(base);
470 }
471
472 for p in discovered {
478 let candidate = format!("http://127.0.0.1:{p}");
479 if !bases.contains(&candidate) {
480 bases.push(candidate);
481 }
482 }
483
484 bases
485}
486
487fn normalize_base(addr: &str) -> Option<String> {
496 let trimmed = addr.trim();
497 let (scheme, authority) = match trimmed.split_once("://") {
498 Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
499 _ => ("http", trimmed),
500 };
501 let authority = authority.trim_end_matches('/');
502 (!authority.is_empty()).then(|| format!("{scheme}://{authority}"))
503}
504
505fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
512 let comm = comm.trim().to_lowercase();
513 let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
514 if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
515 return true;
516 }
517 exe.is_some_and(|p| {
518 let p = p.to_lowercase().replace('\\', "/");
519 let p = p.strip_suffix(".exe").unwrap_or(&p);
520 p.contains("antigravity") || p.ends_with("/agy")
521 })
522}
523
524#[cfg(any(test, target_os = "linux", target_os = "macos", target_os = "windows"))]
554fn probe_order(per_pid: std::collections::BTreeMap<u32, Vec<u16>>) -> Vec<u16> {
555 let groups: Vec<Vec<u16>> = per_pid
556 .into_values()
557 .map(|mut group| {
558 group.sort_unstable_by(|a, b| b.cmp(a));
559 group.dedup();
564 group
565 })
566 .collect();
567
568 let mut ports: Vec<u16> = Vec::new();
569 for rank in 0..groups.iter().map(Vec::len).max().unwrap_or(0) {
570 for port in groups.iter().filter_map(|group| group.get(rank)) {
571 if !ports.contains(port) {
572 ports.push(*port);
573 }
574 }
575 }
576 ports
577}
578
579#[cfg(target_os = "linux")]
586fn discover_ls_ports() -> Vec<u16> {
587 use std::collections::{BTreeMap, HashMap};
588
589 let mut owners: HashMap<u64, u32> = HashMap::new();
592 let Ok(entries) = std::fs::read_dir("/proc") else {
593 return Vec::new();
594 };
595 for entry in entries.flatten() {
596 let pid_dir = entry.path();
597 let Some(pid) = pid_dir
598 .file_name()
599 .and_then(|name| name.to_str())
600 .and_then(|name| name.parse::<u32>().ok())
601 else {
602 continue;
603 };
604 let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
605 continue;
606 };
607 let exe = std::fs::read_link(pid_dir.join("exe")).ok();
608 if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
609 continue;
610 }
611 let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
612 continue;
613 };
614 for fd in fds.flatten() {
615 let Ok(target) = std::fs::read_link(fd.path()) else {
616 continue;
617 };
618 if let Some(ino) = target
619 .to_str()
620 .and_then(|s| s.strip_prefix("socket:["))
621 .and_then(|s| s.strip_suffix(']'))
622 .and_then(|s| s.parse::<u64>().ok())
623 {
624 owners.insert(ino, pid);
625 }
626 }
627 }
628
629 if owners.is_empty() {
630 return Vec::new();
631 }
632
633 let mut per_pid: BTreeMap<u32, Vec<u16>> = BTreeMap::new();
634 for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
635 let Ok(contents) = std::fs::read_to_string(table) else {
636 continue;
637 };
638 for line in contents.lines().skip(1) {
639 if let Some((port, ino)) = parse_proc_net_line(line)
640 && let Some(&pid) = owners.get(&ino)
641 {
642 per_pid.entry(pid).or_default().push(port);
643 }
644 }
645 }
646 probe_order(per_pid)
647}
648
649#[cfg(target_os = "macos")]
656fn discover_ls_ports() -> Vec<u16> {
657 let Ok(output) = std::process::Command::new("lsof")
658 .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
659 .output()
660 else {
661 return Vec::new();
662 };
663 parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
667}
668
669#[cfg(any(test, target_os = "macos"))]
674fn parse_lsof_pcn(output: &str) -> Vec<u16> {
675 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
676 let mut pid = None;
679 let mut owner = None;
680 for line in output.lines() {
681 let Some(rest) = line.get(1..) else { continue };
682 match line.as_bytes().first() {
683 Some(b'p') => {
684 pid = rest.parse::<u32>().ok();
685 owner = None;
686 }
687 Some(b'c') => owner = pid.filter(|_| is_antigravity_process(rest, None)),
688 Some(b'n') => {
689 if let Some(pid) = owner
690 && let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
691 {
692 per_pid.entry(pid).or_default().push(port);
693 }
694 }
695 _ => {}
696 }
697 }
698 probe_order(per_pid)
699}
700
701#[cfg(any(test, target_os = "windows"))]
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
703struct WindowsTcpRow {
704 local_addr: [u8; 4],
705 local_port: u32,
706 pid: u32,
707}
708
709#[cfg(any(test, target_os = "windows"))]
710fn decode_windows_process_name(raw: &[u16]) -> String {
711 let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
712 String::from_utf16_lossy(&raw[..end])
713}
714
715#[cfg(any(test, target_os = "windows"))]
716fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
717 processes
718 .iter()
719 .filter(|(_, name)| is_antigravity_process(name, None))
720 .map(|(pid, _)| *pid)
721 .collect()
722}
723
724#[cfg(any(test, target_os = "windows"))]
727fn matching_windows_ports(
728 pids: &std::collections::HashSet<u32>,
729 rows: &[WindowsTcpRow],
730) -> Vec<u16> {
731 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
732 for row in rows {
733 if !pids.contains(&row.pid) || row.local_addr != [127, 0, 0, 1] {
734 continue;
735 }
736 let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
737 if port != 0 {
738 per_pid.entry(row.pid).or_default().push(port);
739 }
740 }
741 probe_order(per_pid)
742}
743
744#[cfg(any(test, target_os = "windows"))]
745fn checked_windows_row_count(
746 buffer_len: usize,
747 rows_offset: usize,
748 row_size: usize,
749 declared: usize,
750) -> Option<usize> {
751 let rows_len = row_size.checked_mul(declared)?;
752 let end = rows_offset.checked_add(rows_len)?;
753 (row_size != 0 && end <= buffer_len).then_some(declared)
754}
755
756#[cfg(target_os = "windows")]
757struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
758
759#[cfg(target_os = "windows")]
760impl Drop for WindowsHandle {
761 fn drop(&mut self) {
762 unsafe {
763 windows_sys::Win32::Foundation::CloseHandle(self.0);
764 }
765 }
766}
767
768#[cfg(target_os = "windows")]
769fn windows_processes() -> Vec<(u32, String)> {
770 use std::mem::size_of;
771 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
772 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
773 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
774 TH32CS_SNAPPROCESS,
775 };
776
777 let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
778 if handle == INVALID_HANDLE_VALUE {
779 return Vec::new();
780 }
781 let snapshot = WindowsHandle(handle);
782 let mut entry = PROCESSENTRY32W::default();
783 let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
784 return Vec::new();
785 };
786 entry.dwSize = entry_size;
787 if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
788 return Vec::new();
789 }
790
791 let mut processes = Vec::new();
792 loop {
793 processes.push((
794 entry.th32ProcessID,
795 decode_windows_process_name(&entry.szExeFile),
796 ));
797 if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
798 break;
799 }
800 }
801 processes
802}
803
804#[cfg(target_os = "windows")]
805fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
806 use std::mem::{offset_of, size_of, size_of_val};
807 use windows_sys::Win32::NetworkManagement::IpHelper::{
808 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
809 };
810
811 let available = used_bytes.min(size_of_val(buffer));
812 let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
813 if available < size_of::<u32>() || available < rows_offset {
814 return Vec::new();
815 }
816 let base = buffer.as_ptr().cast::<u8>();
817 let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
818 if checked_windows_row_count(
819 available,
820 rows_offset,
821 size_of::<MIB_TCPROW_OWNER_PID>(),
822 declared,
823 )
824 .is_none()
825 {
826 return Vec::new();
827 }
828
829 let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
830 (0..declared)
831 .map(|index| unsafe { rows.add(index).read_unaligned() })
832 .map(|row| WindowsTcpRow {
833 local_addr: row.dwLocalAddr.to_ne_bytes(),
834 local_port: row.dwLocalPort,
835 pid: row.dwOwningPid,
836 })
837 .collect()
838}
839
840#[cfg(target_os = "windows")]
841fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
842 use std::mem::size_of;
843 use std::ptr::null_mut;
844 use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
845 use windows_sys::Win32::NetworkManagement::IpHelper::{
846 GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
847 };
848 use windows_sys::Win32::Networking::WinSock::AF_INET;
849
850 let mut size = 0u32;
851 let status = unsafe {
852 GetExtendedTcpTable(
853 null_mut(),
854 &mut size,
855 0,
856 u32::from(AF_INET),
857 TCP_TABLE_OWNER_PID_LISTENER,
858 0,
859 )
860 };
861 if status != ERROR_INSUFFICIENT_BUFFER {
862 return Vec::new();
863 }
864
865 for _ in 0..3 {
866 let Some(words) = (size as usize)
867 .checked_add(size_of::<u32>() - 1)
868 .map(|bytes| bytes / size_of::<u32>())
869 else {
870 return Vec::new();
871 };
872 if words == 0 {
873 return Vec::new();
874 }
875 let mut buffer = Vec::<u32>::new();
876 if buffer.try_reserve_exact(words).is_err() {
877 return Vec::new();
878 }
879 buffer.resize(words, 0);
880 let mut used = size;
881 let status = unsafe {
882 GetExtendedTcpTable(
883 buffer.as_mut_ptr().cast(),
884 &mut used,
885 0,
886 u32::from(AF_INET),
887 TCP_TABLE_OWNER_PID_LISTENER,
888 0,
889 )
890 };
891 if status == ERROR_INSUFFICIENT_BUFFER {
892 size = used;
893 continue;
894 }
895 if status != 0 {
896 return Vec::new();
897 }
898 return parse_windows_tcp_rows(&buffer, used as usize);
899 }
900 Vec::new()
901}
902
903#[cfg(target_os = "windows")]
904fn discover_ls_ports() -> Vec<u16> {
905 let pids = matching_windows_process_ids(&windows_processes());
906 if pids.is_empty() {
907 return Vec::new();
908 }
909 matching_windows_ports(&pids, &windows_tcp_rows())
910}
911
912#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
913fn discover_ls_ports() -> Vec<u16> {
914 Vec::new()
915}
916
917#[cfg(target_os = "linux")]
920fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
921 let cols: Vec<&str> = line.split_whitespace().collect();
922 if cols.len() < 10 {
923 return None;
924 }
925 if cols[3] != "0A" {
927 return None;
928 }
929 let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
930 let inode = cols[9].parse::<u64>().ok()?;
931 Some((port, inode))
932}
933
934fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
939 crate::outcome::fallback(cache, None, original, |bytes| {
940 parse_cache_at(bytes, None, now)
941 })
942}
943
944fn fallback_with_error(
949 cache: &Cache,
950 last_error: Option<(u16, String)>,
951 reason: AppError,
952 now: DateTime<Utc>,
953) -> Result<FetchOutcome> {
954 crate::outcome::fallback(cache, last_error, reason, |bytes| {
955 parse_cache_at(bytes, None, now)
956 })
957}
958
959fn reuse_cache(
960 bytes: Vec<u8>,
961 cache: &Cache,
962 stale: bool,
963 account: Option<&str>,
964 now: DateTime<Utc>,
965) -> Result<FetchOutcome> {
966 let snap = parse_cache_at(&bytes, account, now)?;
967 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
968}
969
970pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
976 parse_cache_at(bytes, account, Utc::now())
977}
978
979fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
989 [
990 ("Gemini 5h", snap.session.as_ref()),
991 ("Gemini weekly", snap.weekly.as_ref()),
992 ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
993 ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
994 ]
995 .into_iter()
996 .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
997 .map(|(name, _)| name)
998}
999
1000pub fn parse_cache_at(
1001 bytes: &[u8],
1002 account: Option<&str>,
1003 now: DateTime<Utc>,
1004) -> Result<AntigravitySnapshot> {
1005 let v: serde_json::Value = serde_json::from_slice(bytes)?;
1006
1007 let cached_account = v.get("account").and_then(serde_json::Value::as_str);
1008 if let Some(expected) = account
1009 && cached_account != Some(expected)
1010 {
1011 return Err(AppError::Schema(
1012 "antigravity cache belongs to a different account; refetching".into(),
1013 ));
1014 }
1015
1016 let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
1026 match v.get(pct_key) {
1027 None => Err(AppError::Schema(format!(
1028 "antigravity: cached payload is missing {pct_key}"
1029 ))),
1030 Some(serde_json::Value::Null) => Ok(None),
1031 Some(value) => value
1032 .as_i64()
1033 .filter(|pct| (0..=100).contains(pct))
1034 .map(|pct| Some(pct as i32))
1035 .ok_or_else(|| {
1036 AppError::Schema(format!(
1037 "antigravity: cached {pct_key} must be an integer in 0..=100"
1038 ))
1039 }),
1040 }
1041 };
1042
1043 let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1044 let Some(pct) = cached_pct(pct_key)? else {
1045 return Ok(None);
1046 };
1047 Ok::<_, AppError>(Some(UsageWindow {
1048 utilization_pct: pct,
1049 resets_at: parse_reset(&v[reset_key], reset_key)?,
1050 window_duration: if weekly {
1051 chrono::Duration::days(7)
1052 } else {
1053 chrono::Duration::hours(5)
1054 },
1055 }))
1056 };
1057
1058 let snap = AntigravitySnapshot {
1059 plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
1060 account: cached_account.unwrap_or_default().to_string(),
1061 session: optional("session_pct", "session_reset", false)?,
1062 weekly: optional("weekly_pct", "weekly_reset", true)?,
1063 third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
1064 third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
1065 };
1066
1067 if snap.session.is_none()
1070 && snap.weekly.is_none()
1071 && snap.third_party_session.is_none()
1072 && snap.third_party_weekly.is_none()
1073 {
1074 return Err(AppError::Schema(
1075 "antigravity cache holds no usable window; refetching".into(),
1076 ));
1077 }
1078
1079 if let Some(window) = expired_window(&snap, now) {
1080 return Err(AppError::Schema(format!(
1081 "antigravity cache is past its {window} reset; refetching"
1082 )));
1083 }
1084 Ok(snap)
1085}
1086
1087pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
1088 serde_json::json!({
1089 "plan": snap.plan,
1090 "account": snap.account,
1091 "session_pct": snap.session.as_ref().map(|w| w.utilization_pct),
1092 "session_reset": snap.session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1093 "weekly_pct": snap.weekly.as_ref().map(|w| w.utilization_pct),
1094 "weekly_reset": snap.weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1095 "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
1096 "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1097 "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
1098 "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1099 })
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105
1106 const QUOTA_JSON: &str = r#"{
1110 "response": {
1111 "groups": [
1112 {
1113 "displayName": "Gemini Models",
1114 "buckets": [
1115 {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
1116 "window": "weekly", "remainingFraction": 0.9191212,
1117 "resetTime": "2026-07-28T17:39:58Z"},
1118 {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
1119 "window": "5h", "remainingFraction": 0.5672253,
1120 "resetTime": "2026-07-22T17:47:00Z"}
1121 ]
1122 },
1123 {
1124 "displayName": "Claude and GPT models",
1125 "buckets": [
1126 {"bucketId": "3p-weekly", "window": "weekly",
1127 "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
1128 {"bucketId": "3p-5h", "window": "5h",
1129 "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
1130 ]
1131 }
1132 ]
1133 }
1134 }"#;
1135
1136 fn now() -> DateTime<Utc> {
1139 DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
1140 .unwrap()
1141 .with_timezone(&Utc)
1142 }
1143
1144 fn parsed() -> AntigravitySnapshot {
1145 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1146 parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
1147 }
1148
1149 #[test]
1150 fn quota_summary_maps_four_distinct_windows() {
1151 let snap = parsed();
1152 assert_eq!(snap.plan, "Google AI Pro");
1153 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
1155 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
1156 assert_eq!(
1157 snap.third_party_session.as_ref().unwrap().utilization_pct,
1158 75
1159 );
1160 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
1161 }
1162
1163 #[test]
1164 fn each_window_keeps_its_own_reset_time() {
1165 let snap = parsed();
1166 let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
1167 assert_eq!(
1168 snap.session.as_ref().unwrap().resets_at,
1169 at("2026-07-22T17:47:00Z")
1170 );
1171 assert_eq!(
1172 snap.weekly.as_ref().unwrap().resets_at,
1173 at("2026-07-28T17:39:58Z")
1174 );
1175 assert_eq!(
1176 snap.third_party_weekly.as_ref().unwrap().resets_at,
1177 at("2026-07-29T12:47:00Z")
1178 );
1179 assert_ne!(
1181 snap.session.as_ref().unwrap().resets_at,
1182 snap.weekly.as_ref().unwrap().resets_at
1183 );
1184 }
1185
1186 #[test]
1187 fn window_durations_match_their_bucket() {
1188 let snap = parsed();
1189 assert_eq!(
1190 snap.session.as_ref().unwrap().window_duration,
1191 chrono::Duration::hours(5)
1192 );
1193 assert_eq!(
1194 snap.weekly.as_ref().unwrap().window_duration,
1195 chrono::Duration::days(7)
1196 );
1197 assert_eq!(
1198 snap.third_party_weekly.as_ref().unwrap().window_duration,
1199 chrono::Duration::days(7)
1200 );
1201 }
1202
1203 #[test]
1204 fn groups_are_matched_by_display_name_when_bucket_ids_change() {
1205 let v: serde_json::Value = serde_json::from_str(
1206 r#"{"response":{"groups":[
1207 {"displayName":"Gemini Models","buckets":[
1208 {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
1209 {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
1210 {"displayName":"Claude and GPT models","buckets":[
1211 {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
1212 ]}}"#,
1213 )
1214 .unwrap();
1215 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
1216 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 50);
1217 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 10);
1218 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
1219 assert!(snap.third_party_weekly.is_none());
1220 }
1221
1222 #[test]
1223 fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
1224 let duplicate: serde_json::Value = serde_json::from_str(
1225 r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
1226 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1227 {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
1228 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
1229 ]}]}}"#,
1230 )
1231 .unwrap();
1232 let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
1233 assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
1234
1235 let unrelated: serde_json::Value = serde_json::from_str(
1238 r#"{"response":{"groups":[
1239 {"displayName":"Gemini Models","buckets":[
1240 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1241 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
1242 {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
1243 ]},
1244 {"displayName":"Future Models","buckets":[
1245 {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
1246 ]}
1247 ]}}"#,
1248 )
1249 .unwrap();
1250 let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
1251 assert!(snap.third_party_session.is_none());
1252 assert!(snap.third_party_weekly.is_none());
1253 }
1254
1255 #[test]
1258 fn a_bucket_without_a_usable_fraction_is_rejected() {
1259 for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
1260 let v: serde_json::Value = serde_json::from_str(&format!(
1261 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
1262 {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
1263 {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
1264 ))
1265 .unwrap();
1266 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1267 assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
1268 }
1269 }
1270
1271 #[test]
1272 fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
1273 for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
1274 let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1275 v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
1276 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1277 assert!(err.to_string().contains("resetTime"), "{err}");
1278 }
1279 }
1280
1281 #[test]
1285 fn a_weekly_only_snapshot_round_trips_through_the_cache() {
1286 let mut snap = parsed();
1287 snap.session = None;
1288 snap.third_party_session = None;
1289
1290 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1291 let back = parse_cache_at(&bytes, None, now()).expect("weekly-only cache is usable");
1292
1293 assert!(back.session.is_none());
1294 assert_eq!(
1295 back.weekly.as_ref().unwrap().utilization_pct,
1296 snap.weekly.as_ref().unwrap().utilization_pct
1297 );
1298 }
1299
1300 #[test]
1305 fn a_product_reporting_only_weekly_buckets_still_renders_them() {
1306 let summary = serde_json::json!({
1307 "groups": [
1308 {
1309 "displayName": "Gemini Models",
1310 "buckets": [{
1311 "bucketId": "gemini-weekly", "window": "weekly",
1312 "remainingFraction": 0.42,
1313 }],
1314 },
1315 {
1316 "displayName": "Claude and GPT models",
1317 "buckets": [{
1318 "bucketId": "3p-weekly", "window": "weekly",
1319 "remainingFraction": 0.9,
1320 }],
1321 },
1322 ],
1323 });
1324
1325 let snap = parse_quota_summary(&summary, "Pro".into()).expect("weekly-only is usable");
1326
1327 assert!(snap.session.is_none(), "no 5h bucket arrived");
1328 assert!(snap.third_party_session.is_none());
1329 assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 58);
1330 assert_eq!(
1331 snap.third_party_weekly.as_ref().unwrap().utilization_pct,
1332 10
1333 );
1334 }
1335
1336 #[test]
1339 fn a_product_reporting_only_five_hour_buckets_still_renders_them() {
1340 let summary = serde_json::json!({
1341 "groups": [{
1342 "displayName": "Gemini Models",
1343 "buckets": [{
1344 "bucketId": "gemini-5h", "window": "5h", "remainingFraction": 0.25,
1345 }],
1346 }],
1347 });
1348
1349 let snap = parse_quota_summary(&summary, "Pro".into()).expect("5h-only is usable");
1350
1351 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 75);
1352 assert!(snap.weekly.is_none());
1353 }
1354
1355 #[test]
1358 fn a_summary_with_no_recognisable_bucket_errors_and_names_what_it_had() {
1359 let summary = serde_json::json!({
1360 "groups": [{
1361 "displayName": "Gemini Models",
1362 "buckets": [{
1363 "bucketId": "gemini-daily", "window": "daily", "remainingFraction": 0.9,
1364 }],
1365 }],
1366 });
1367
1368 let rendered = parse_quota_summary(&summary, "Pro".into())
1369 .expect_err("an unrecognised cadence alone is not a snapshot")
1370 .to_string();
1371
1372 assert!(
1373 rendered.contains("no bucket in a window we recognise"),
1374 "{rendered}"
1375 );
1376 assert!(rendered.contains("gemini-daily"), "{rendered}");
1377 assert!(rendered.contains("window daily"), "{rendered}");
1378 }
1379
1380 #[test]
1383 fn a_summary_with_no_buckets_at_all_says_that_rather_than_listing_nothing() {
1384 let summary = serde_json::json!({
1385 "groups": [{"displayName": "Gemini", "buckets": []}],
1386 });
1387
1388 let rendered = parse_quota_summary(&summary, "Pro".into())
1389 .expect_err("no buckets is an error")
1390 .to_string();
1391
1392 assert!(rendered.contains("no buckets at all"), "{rendered}");
1393 assert!(!rendered.contains("it offered:"), "{rendered}");
1394 }
1395
1396 #[test]
1399 fn buckets_without_an_id_are_still_named_in_the_error() {
1400 let summary = serde_json::json!({
1401 "groups": [{"displayName": "", "buckets": [{"remainingFraction": 0.5}]}],
1402 });
1403
1404 let rendered = parse_quota_summary(&summary, "Pro".into())
1405 .expect_err("an unusable summary is an error")
1406 .to_string();
1407
1408 assert!(rendered.contains("<unnamed>"), "{rendered}");
1409 }
1410
1411 #[test]
1412 fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
1413 let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
1414 assert!(parse_quota_summary(&v, "Pro".into()).is_err());
1415 }
1416
1417 #[test]
1418 fn cache_round_trip_preserves_every_window() {
1419 let snap = parsed();
1420 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1421 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1422 }
1423
1424 #[test]
1428 fn a_truncated_cached_payload_is_rejected_not_zeroed() {
1429 let full = snap_to_json(&parsed());
1430 for missing in ["session_pct", "weekly_pct"] {
1431 let mut v = full.clone();
1432 v.as_object_mut().unwrap().remove(missing);
1433 let bytes = serde_json::to_vec(&v).unwrap();
1434 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1435 assert!(err.to_string().contains(missing), "{missing}: {err}");
1436 }
1437 assert!(parse_cache_at(b"{}", None, now()).is_err());
1439 }
1440
1441 #[test]
1442 fn cached_percentages_are_range_checked_before_narrowing() {
1443 let full = snap_to_json(&parsed());
1444 for (key, bad) in [
1445 ("session_pct", serde_json::json!(-1)),
1446 ("weekly_pct", serde_json::json!(101)),
1447 ("session_pct", serde_json::json!(i64::MAX)),
1448 ("tp_session_pct", serde_json::json!("75")),
1449 ] {
1450 let mut v = full.clone();
1451 v[key] = bad;
1452 let bytes = serde_json::to_vec(&v).unwrap();
1453 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1454 assert!(err.to_string().contains(key), "{key}: {err}");
1455 }
1456 }
1457
1458 #[test]
1459 fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
1460 let mut v = snap_to_json(&parsed());
1461 v["session_reset"] = serde_json::json!("not-a-time");
1462 let bytes = serde_json::to_vec(&v).unwrap();
1463 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1464 assert!(err.to_string().contains("session_reset"), "{err}");
1465 }
1466
1467 #[test]
1472 fn a_cache_past_its_reset_is_refused() {
1473 let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
1474 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1475
1476 assert!(parse_cache_at(&bytes, None, now()).is_ok());
1478 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
1480
1481 let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
1483 assert!(err.to_string().contains("5h"), "{err}");
1484
1485 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
1487 }
1488
1489 #[test]
1492 fn expiry_names_the_window_that_rolled_over() {
1493 let mut snap = parsed();
1494 snap.session.as_mut().unwrap().resets_at = None;
1496 snap.third_party_session = None;
1497 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1498 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1499
1500 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
1502
1503 let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
1505 assert!(err.to_string().contains("Gemini weekly"), "{err}");
1506 }
1507
1508 #[test]
1510 fn a_window_without_a_reset_never_expires() {
1511 let mut snap = parsed();
1512 for w in [&mut snap.session, &mut snap.weekly].into_iter().flatten() {
1513 w.resets_at = None;
1514 }
1515 snap.third_party_session = None;
1516 snap.third_party_weekly = None;
1517 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1518 let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1519 .unwrap()
1520 .with_timezone(&Utc);
1521 assert!(parse_cache_at(&bytes, None, far_future).is_ok());
1522 }
1523
1524 #[test]
1526 fn a_cache_from_another_account_is_rejected() {
1527 let mut snap = parsed();
1528 snap.account = "acct:aaaa".into();
1529 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1530
1531 assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
1532 assert_eq!(
1533 parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
1534 snap
1535 );
1536
1537 let mut legacy = snap_to_json(&snap);
1539 legacy.as_object_mut().unwrap().remove("account");
1540 let legacy = serde_json::to_vec(&legacy).unwrap();
1541 assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
1542 }
1543
1544 #[test]
1547 fn an_unverifiable_cache_is_served_rather_than_discarded() {
1548 let mut snap = parsed();
1549 snap.account = "acct:aaaa".into();
1550 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1551 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1552 }
1553
1554 #[test]
1555 fn account_key_fingerprints_rather_than_storing_the_address() {
1556 let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
1557 let a = with("someone@example.com");
1558 assert!(!a.contains("someone"), "{a}");
1559 assert!(!a.contains('@'), "{a}");
1560 assert_eq!(a, with("someone@example.com"), "must be stable");
1561 assert_ne!(a, with("other@example.com"));
1562 let unknown = account_key(&serde_json::json!({}));
1564 assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1565 assert_ne!(unknown, a);
1566 }
1567
1568 #[test]
1571 fn absent_third_party_windows_are_not_treated_as_corruption() {
1572 let mut snap = parsed();
1573 snap.third_party_session = None;
1574 snap.third_party_weekly = None;
1575 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1576 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1577 }
1578
1579 #[test]
1580 fn cache_round_trip_preserves_absent_third_party_windows() {
1581 let mut snap = parsed();
1582 snap.third_party_session = None;
1583 snap.third_party_weekly = None;
1584 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1585 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1586 }
1587
1588 #[test]
1589 fn pct_used_inverts_valid_fractions() {
1590 assert_eq!(pct_used(1.0), 0);
1591 assert_eq!(pct_used(0.0), 100);
1592 assert_eq!(pct_used(0.5), 50);
1593 }
1594
1595 #[test]
1596 fn plan_falls_back_through_the_status_payload() {
1597 let tier: serde_json::Value =
1598 serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1599 .unwrap();
1600 assert_eq!(plan_from_status(&tier), "Google AI Pro");
1601
1602 let plan_only: serde_json::Value = serde_json::from_str(
1603 r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1604 )
1605 .unwrap();
1606 assert_eq!(plan_from_status(&plan_only), "Pro");
1607
1608 let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1609 assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1610 }
1611
1612 #[cfg(target_os = "linux")]
1613 #[test]
1614 fn proc_net_parser_keeps_only_listening_rows() {
1615 let listen = " 0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1616 00:00000000 00000000 1000 0 123456 1 0000 100 0";
1617 assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1618
1619 let established = " 1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1620 00:00000000 00000000 1000 0 123457 1 0000 100 0";
1621 assert_eq!(parse_proc_net_line(established), None);
1622
1623 assert_eq!(parse_proc_net_line("garbage"), None);
1624 }
1625
1626 #[test]
1627 fn explicit_address_comes_first_and_gets_a_scheme() {
1628 assert_eq!(
1629 candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1630 vec![
1631 "http://127.0.0.1:1234".to_string(),
1632 "http://127.0.0.1:5678".to_string(),
1633 ]
1634 );
1635 assert_eq!(
1637 candidate_bases_with(Some("127.0.0.1:1234/"), vec![5678]),
1638 vec![
1639 "http://127.0.0.1:1234".to_string(),
1640 "http://127.0.0.1:5678".to_string(),
1641 ]
1642 );
1643 assert_eq!(
1645 candidate_bases_with(Some("127.0.0.1:5678"), vec![5678]),
1646 vec!["http://127.0.0.1:5678".to_string()]
1647 );
1648 assert_eq!(
1650 candidate_bases_with(None, vec![5678, 5678]),
1651 vec!["http://127.0.0.1:5678".to_string()]
1652 );
1653 assert_eq!(
1655 candidate_bases_with(Some("https://host:9"), vec![]),
1656 vec!["https://host:9".to_string()]
1657 );
1658 }
1659
1660 fn http(status: u16) -> AppError {
1661 AppError::Http {
1662 status,
1663 body: String::new(),
1664 }
1665 }
1666
1667 #[test]
1671 fn an_auth_failure_outranks_later_transport_noise() {
1672 let err = select_probe_error(vec![
1673 http(401),
1674 AppError::Transport("connection refused".into()),
1675 ]);
1676 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1677
1678 let err = select_probe_error(vec![
1679 AppError::Transport("connection refused".into()),
1680 http(403),
1681 ]);
1682 assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
1683 }
1684
1685 #[test]
1688 fn the_first_auth_failure_wins() {
1689 let err = select_probe_error(vec![http(401), http(403)]);
1690 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1691 }
1692
1693 #[test]
1697 fn without_an_auth_failure_the_last_error_stands() {
1698 let err = select_probe_error(vec![
1699 AppError::Transport("first".into()),
1700 http(500),
1701 AppError::Transport("last".into()),
1702 ]);
1703 assert!(
1704 matches!(&err, AppError::Transport(m) if m == "last"),
1705 "{err}"
1706 );
1707 assert!(err.is_transient());
1708 }
1709
1710 #[test]
1713 fn a_server_error_is_not_treated_as_actionable() {
1714 let err = select_probe_error(vec![http(500), http(401)]);
1715 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1716 }
1717
1718 #[test]
1719 fn no_candidates_at_all_yields_a_generic_error() {
1720 let err = select_probe_error(Vec::new());
1721 assert!(
1722 err.to_string().contains("no local server answered"),
1723 "{err}"
1724 );
1725 }
1726
1727 fn tls_echo() -> AppError {
1731 AppError::Http {
1732 status: 400,
1733 body: "Client sent an HTTP request to an HTTPS server.\n".into(),
1734 }
1735 }
1736
1737 #[test]
1742 fn a_tls_echo_does_not_mask_what_the_rpc_listener_said() {
1743 let err = select_probe_error(vec![
1744 AppError::Http {
1745 status: 500,
1746 body: "GetUserStatus: internal".into(),
1747 },
1748 tls_echo(),
1749 ]);
1750 assert!(
1751 matches!(&err, AppError::Http { status: 500, body } if body.contains("internal")),
1752 "{err}"
1753 );
1754 }
1755
1756 #[test]
1761 fn a_tls_echo_does_not_cost_the_silent_fallback() {
1762 let err = select_probe_error(vec![
1763 AppError::Transport("connection refused".into()),
1764 tls_echo(),
1765 ]);
1766 assert!(
1767 matches!(&err, AppError::Transport(m) if m == "connection refused"),
1768 "{err}"
1769 );
1770 assert!(
1771 err.is_transient(),
1772 "the echo must not make the run non-transient: {err}"
1773 );
1774 }
1775
1776 #[test]
1779 fn a_tls_echo_still_stands_when_it_is_the_only_thing_that_answered() {
1780 let err = select_probe_error(vec![tls_echo()]);
1781 assert!(matches!(err, AppError::Http { status: 400, .. }), "{err}");
1782 }
1783
1784 #[test]
1787 fn a_genuine_bad_request_is_not_mistaken_for_a_tls_echo() {
1788 let err = select_probe_error(vec![
1789 AppError::Http {
1790 status: 400,
1791 body: "unknown method GetUserStatus".into(),
1792 },
1793 tls_echo(),
1794 ]);
1795 assert!(
1796 matches!(&err, AppError::Http { status: 400, body } if body.contains("unknown method")),
1797 "{err}"
1798 );
1799 }
1800
1801 #[test]
1803 fn an_auth_failure_still_outranks_a_tls_echo() {
1804 let err = select_probe_error(vec![tls_echo(), http(401)]);
1805 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1806 }
1807
1808 #[test]
1809 fn every_discovered_port_is_probed_in_order() {
1810 assert_eq!(
1811 candidate_bases_with(None, vec![33875, 37435]),
1812 vec![
1813 "http://127.0.0.1:33875".to_string(),
1814 "http://127.0.0.1:37435".to_string(),
1815 ]
1816 );
1817 }
1818
1819 #[test]
1824 fn empty_discovery_yields_no_candidates() {
1825 assert!(candidate_bases_with(None, vec![]).is_empty());
1826 assert!(candidate_bases_with(Some(""), vec![]).is_empty());
1827 }
1828
1829 #[test]
1830 fn every_antigravity_product_is_recognised() {
1831 assert!(is_antigravity_process(
1833 "language_server\n",
1834 Some("/opt/antigravity/resources/bin/language_server")
1835 ));
1836 assert!(is_antigravity_process(
1838 "agy\n",
1839 Some("/home/u/.local/bin/agy")
1840 ));
1841 assert!(is_antigravity_process(
1843 "node",
1844 Some("/opt/antigravity/bin/helper")
1845 ));
1846 assert!(is_antigravity_process("antigravity", None));
1847 assert!(is_antigravity_process("agy.exe", None));
1848 assert!(is_antigravity_process("Antigravity.exe", None));
1849 assert!(is_antigravity_process("language_server.exe", None));
1850 assert!(is_antigravity_process(
1851 "language_server_windows_x64.exe",
1852 None
1853 ));
1854 assert!(is_antigravity_process(
1855 "node.exe",
1856 Some(r"C:\Users\u\AppData\Local\agy.exe")
1857 ));
1858 }
1859
1860 #[test]
1861 fn unrelated_processes_are_not_probed() {
1862 assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
1863 assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
1864 assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
1866 assert!(!is_antigravity_process("legacy.exe", None));
1867 assert!(!is_antigravity_process("not-agy.exe", None));
1868 assert!(!is_antigravity_process("", None));
1869 }
1870
1871 #[test]
1872 fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
1873 let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
1874 raw.extend([0, b'x' as u16]);
1875 assert_eq!(decode_windows_process_name(&raw), "agy.exe");
1876 assert_eq!(decode_windows_process_name(&[0xd800]), "�");
1877 assert_eq!(decode_windows_process_name(&[]), "");
1878 }
1879
1880 #[test]
1881 fn windows_process_filter_keeps_only_antigravity_pids() {
1882 let processes = vec![
1883 (10, "agy.exe".to_string()),
1884 (20, "language_server_windows_x64.exe".to_string()),
1885 (30, "sshd.exe".to_string()),
1886 ];
1887 let pids = matching_windows_process_ids(&processes);
1888 assert_eq!(pids, std::collections::HashSet::from([10, 20]));
1889 }
1890
1891 #[test]
1892 fn windows_listener_filter_joins_pid_loopback_and_port() {
1893 let pids = std::collections::HashSet::from([10]);
1894 let rows = [
1895 WindowsTcpRow {
1896 local_addr: [127, 0, 0, 1],
1897 local_port: u32::from(59870u16.to_be()),
1898 pid: 10,
1899 },
1900 WindowsTcpRow {
1901 local_addr: [127, 0, 0, 1],
1902 local_port: u32::from(59868u16.to_be()),
1903 pid: 10,
1904 },
1905 WindowsTcpRow {
1906 local_addr: [127, 0, 0, 1],
1907 local_port: u32::from(59870u16.to_be()),
1908 pid: 10,
1909 },
1910 WindowsTcpRow {
1911 local_addr: [0, 0, 0, 0],
1912 local_port: u32::from(50000u16.to_be()),
1913 pid: 10,
1914 },
1915 WindowsTcpRow {
1916 local_addr: [127, 0, 0, 1],
1917 local_port: u32::from(50001u16.to_be()),
1918 pid: 99,
1919 },
1920 WindowsTcpRow {
1921 local_addr: [127, 0, 0, 1],
1922 local_port: 0,
1923 pid: 10,
1924 },
1925 ];
1926 assert_eq!(matching_windows_ports(&pids, &rows), vec![59870, 59868]);
1927 }
1928
1929 #[test]
1933 fn windows_ports_from_two_products_keep_tls_listeners_last() {
1934 let pids = std::collections::HashSet::from([10, 20]);
1935 let row = |port: u16, pid: u32| WindowsTcpRow {
1936 local_addr: [127, 0, 0, 1],
1937 local_port: u32::from(port.to_be()),
1938 pid,
1939 };
1940 let rows = [
1941 row(40000, 10),
1942 row(40001, 10),
1943 row(50000, 20),
1944 row(50001, 20),
1945 ];
1946 assert_eq!(
1947 matching_windows_ports(&pids, &rows),
1948 vec![40001, 50001, 40000, 50000]
1949 );
1950 }
1951
1952 #[test]
1956 fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
1957 use std::collections::BTreeMap;
1958
1959 assert_eq!(
1961 probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
1962 vec![59870, 59868]
1963 );
1964 assert_eq!(
1969 probe_order(BTreeMap::from([
1970 (10, vec![40000, 40001]),
1971 (20, vec![50000, 50001]),
1972 ])),
1973 vec![40001, 50001, 40000, 50000]
1974 );
1975 assert_eq!(
1978 probe_order(BTreeMap::from([
1979 (10, vec![6000, 5000, 4000]),
1980 (20, vec![6000, 7000]),
1981 ])),
1982 vec![6000, 7000, 5000, 4000]
1983 );
1984 assert!(probe_order(BTreeMap::new()).is_empty());
1985 }
1986
1987 #[test]
1992 fn a_port_named_twice_by_one_product_still_occupies_one_rank() {
1993 use std::collections::BTreeMap;
1994
1995 assert_eq!(
1996 probe_order(BTreeMap::from([
1997 (10, vec![40001, 40001, 40000, 40000]),
1998 (20, vec![50001, 50000]),
1999 ])),
2000 vec![40001, 50001, 40000, 50000],
2001 "duplicate rows must not reorder the ranks below them"
2002 );
2003 }
2004
2005 #[test]
2010 fn a_half_started_product_is_the_documented_exception() {
2011 use std::collections::BTreeMap;
2012
2013 assert_eq!(
2014 probe_order(BTreeMap::from([
2015 (10, vec![40000]),
2016 (20, vec![50001, 50000])
2017 ])),
2018 vec![40000, 50001, 50000]
2019 );
2020 }
2021
2022 #[test]
2027 fn an_override_with_no_authority_is_dropped_not_probed() {
2028 for junk in ["/", "///", "http://", "https://", " / "] {
2029 assert_eq!(
2030 candidate_bases_with(Some(junk), vec![4242]),
2031 vec!["http://127.0.0.1:4242".to_string()],
2032 "{junk:?} should not survive as a candidate"
2033 );
2034 }
2035 assert!(candidate_bases_with(Some("/"), vec![]).is_empty());
2036 }
2037
2038 #[test]
2039 fn windows_table_bounds_reject_truncation_and_overflow() {
2040 assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
2041 assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
2042 assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
2043 assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
2044 assert_eq!(
2045 checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
2046 None
2047 );
2048 }
2049
2050 #[cfg(target_os = "windows")]
2051 #[test]
2052 fn windows_tcp_table_parser_copies_complete_rows_only() {
2053 use std::mem::{offset_of, size_of};
2054 use windows_sys::Win32::NetworkManagement::IpHelper::{
2055 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
2056 };
2057
2058 let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
2059 let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
2060 let words = used.div_ceil(size_of::<u32>());
2061 let mut buffer = vec![0u32; words];
2062 let first = MIB_TCPROW_OWNER_PID {
2063 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
2064 dwLocalPort: u32::from(59868u16.to_be()),
2065 dwOwningPid: 10,
2066 ..Default::default()
2067 };
2068 let second = MIB_TCPROW_OWNER_PID {
2069 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
2070 dwLocalPort: u32::from(59870u16.to_be()),
2071 dwOwningPid: 10,
2072 ..Default::default()
2073 };
2074 unsafe {
2075 buffer.as_mut_ptr().write_unaligned(2);
2076 let rows = buffer
2077 .as_mut_ptr()
2078 .cast::<u8>()
2079 .add(offset)
2080 .cast::<MIB_TCPROW_OWNER_PID>();
2081 rows.write_unaligned(first);
2082 rows.add(1).write_unaligned(second);
2083 }
2084
2085 assert_eq!(
2086 parse_windows_tcp_rows(&buffer, used),
2087 vec![
2088 WindowsTcpRow {
2089 local_addr: [127, 0, 0, 1],
2090 local_port: u32::from(59868u16.to_be()),
2091 pid: 10,
2092 },
2093 WindowsTcpRow {
2094 local_addr: [127, 0, 0, 1],
2095 local_port: u32::from(59870u16.to_be()),
2096 pid: 10,
2097 },
2098 ]
2099 );
2100 assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
2101 }
2102
2103 #[test]
2104 fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
2105 let output = "p74101\ncagy\nf10\nn127.0.0.1:8829\nf11\nn127.0.0.1:61289\nf12\nn127.0.0.1:61290\np200\ncsshd\nf5\nn*:22\n";
2108 assert_eq!(parse_lsof_pcn(output), vec![61290, 61289, 8829]);
2109 }
2110
2111 #[test]
2115 fn lsof_parser_keeps_each_products_ports_in_its_own_group() {
2116 let output = concat!(
2117 "p100\ncagy\nf3\nn127.0.0.1:40000\nf4\nn127.0.0.1:40001\n",
2118 "p200\nclanguage_server\nf5\nn127.0.0.1:50000\nf6\nn127.0.0.1:50001\n",
2119 );
2120 assert_eq!(
2121 parse_lsof_pcn(output),
2122 vec![40001, 50001, 40000, 50000],
2123 "both HTTP listeners must precede both TLS listeners"
2124 );
2125 }
2126
2127 #[test]
2128 fn lsof_parser_matches_the_capitalised_macos_app_name() {
2129 let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
2130 assert_eq!(parse_lsof_pcn(output), vec![54321]);
2131 }
2132
2133 #[test]
2134 fn lsof_parser_deduplicates_and_handles_empty_output() {
2135 let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
2136 assert_eq!(parse_lsof_pcn(output), vec![9000]);
2137 assert!(parse_lsof_pcn("").is_empty());
2138 }
2139
2140 #[test]
2143 fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
2144 let dir = tempfile::tempdir().unwrap();
2145 let cache = Cache::at(dir.path().join("usage.json"));
2146 let reason = AppError::Credentials("Antigravity: no local language server found".into());
2147
2148 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
2149 let msg = err.to_string();
2150 assert!(msg.contains("no local language server found"), "{msg}");
2151 assert!(!msg.contains("no usable cache"), "{msg}");
2152 }
2153
2154 #[test]
2155 fn unusable_cache_does_not_replace_the_live_diagnosis() {
2156 let dir = tempfile::tempdir().unwrap();
2157 let cache = Cache::at(dir.path().join("antigravity"));
2158 cache.write_payload(b"{}").unwrap();
2159
2160 let reason = AppError::Credentials("Antigravity must be running".into());
2161 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
2162 assert!(err.to_string().contains("must be running"), "{err}");
2163
2164 let original = AppError::Transport("original loopback failure".into());
2165 let err = fallback_silent(&cache, now(), original).unwrap_err();
2166 assert!(
2167 err.to_string().contains("original loopback failure"),
2168 "{err}"
2169 );
2170 }
2171
2172 #[tokio::test]
2173 async fn rpc_error_bodies_are_bounded_too() {
2174 let mut server = mockito::Server::new_async().await;
2175 let path = format!("/{STATUS_RPC}");
2176 server
2177 .mock("POST", path.as_str())
2178 .with_status(500)
2179 .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
2180 .create_async()
2181 .await;
2182
2183 let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
2184 .await
2185 .unwrap_err();
2186 assert!(err.to_string().contains("exceeds"), "{err}");
2187 }
2188
2189 #[test]
2190 fn blank_override_falls_through_to_discovery() {
2191 assert_eq!(
2192 candidate_bases_with(Some(" "), vec![4242]),
2193 vec!["http://127.0.0.1:4242".to_string()]
2194 );
2195 }
2196}