1use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20
21use crate::cache::{Cache, MAX_STALE, 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
33#[derive(Debug, Clone)]
34pub struct FetchOutcome {
35 pub snapshot: AntigravitySnapshot,
36 pub stale: bool,
37 pub last_error: Option<(u16, String)>,
38 pub cache_age: Option<Duration>,
39}
40
41impl From<FetchOutcome> for crate::vendor::VendorOutcome {
42 fn from(o: FetchOutcome) -> Self {
43 Self {
44 snapshot: crate::usage::VendorSnapshot::Antigravity(o.snapshot),
45 stale: o.stale,
46 last_error: o.last_error,
47 cache_age: o.cache_age,
48 }
49 }
50}
51
52pub async fn fetch_snapshot(
53 client: &reqwest::Client,
54 cache: &Cache,
55 cache_ttl: Duration,
56) -> Result<FetchOutcome> {
57 fetch_snapshot_at(client, cache, cache_ttl, Utc::now()).await
58}
59
60pub async fn fetch_snapshot_at(
63 client: &reqwest::Client,
64 cache: &Cache,
65 cache_ttl: Duration,
66 now: DateTime<Utc>,
67) -> Result<FetchOutcome> {
68 cache.ensure_dir()?;
69 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
70
71 let session = open_session(client).await;
76 let account = session.as_ref().ok().map(|s| s.account.as_str());
77
78 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
79 && let Ok(outcome) = reuse_cache(bytes, cache, false, account, now)
80 {
81 return Ok(outcome);
82 }
83
84 match fetch_live(client, session).await {
85 Ok(snap) => {
86 let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
87 cache.write_payload(&bytes)?;
88 Ok(FetchOutcome {
89 snapshot: snap,
90 stale: false,
91 last_error: None,
92 cache_age: Some(Duration::ZERO),
93 })
94 }
95 Err(e) if e.is_transient() => fallback_silent(cache, now, e),
96 Err(AppError::Http { status, body }) => {
97 cache.mark_stale();
98 let last_error = Some(cache.write_last_error(status, &body));
99 let reason = AppError::Http { status, body };
100 fallback_with_error(cache, last_error, reason, now)
101 }
102 Err(e) => {
103 cache.mark_stale();
104 let last_error = Some(cache.write_last_error(0, &e.to_string()));
105 fallback_with_error(cache, last_error, e, now)
106 }
107 }
108}
109
110struct Session {
113 base: String,
114 csrf: Option<String>,
115 plan: String,
116 account: String,
117}
118
119async fn open_session(client: &reqwest::Client) -> Result<Session> {
123 let bases = candidate_bases();
124 if bases.is_empty() {
125 return Err(AppError::Credentials(
126 "Antigravity: no local server found. Quota is only served while Antigravity is \
127 running — open the Antigravity app, or an interactive `agy` session, or point \
128 ANTIGRAVITY_LS_ADDRESS at a host:port."
129 .into(),
130 ));
131 }
132
133 let mut errors = Vec::new();
134 for base in bases {
135 let csrf = fetch_csrf(client, &base).await;
136 match post_rpc(client, &base, csrf.as_deref(), STATUS_RPC).await {
137 Ok(v) => {
138 return Ok(Session {
139 base,
140 csrf,
141 plan: plan_from_status(&v),
142 account: account_key(&v),
143 });
144 }
145 Err(e) => errors.push(e),
146 }
147 }
148 Err(select_probe_error(errors))
149}
150
151fn select_probe_error(errors: Vec<AppError>) -> AppError {
166 let mut actionable = None;
167 let mut last = None;
168 let mut echo = None;
169 for e in errors {
170 if actionable.is_none() && is_actionable(&e) {
171 actionable = Some(e);
172 } else if is_tls_echo(&e) {
173 echo = Some(e);
174 } else {
175 last = Some(e);
176 }
177 }
178 actionable.or(last).or(echo).unwrap_or_else(|| {
179 AppError::Other("antigravity: no local server answered GetUserStatus".into())
180 })
181}
182
183fn is_actionable(e: &AppError) -> bool {
187 matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
188}
189
190fn is_tls_echo(e: &AppError) -> bool {
203 matches!(
204 e,
205 AppError::Http { status: 400, body } if body.contains("HTTP request to an HTTPS server")
206 )
207}
208
209async fn fetch_live(
210 client: &reqwest::Client,
211 session: Result<Session>,
212) -> Result<AntigravitySnapshot> {
213 let session = session?;
214 let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
215 let mut snap = parse_quota_summary("a, session.plan)?;
216 snap.account = session.account;
217 Ok(snap)
218}
219
220fn account_key(user_status: &serde_json::Value) -> String {
225 let email = user_status["userStatus"]["email"]
226 .as_str()
227 .filter(|s| !s.is_empty());
228 match email {
229 Some(e) => {
230 use std::hash::{Hash, Hasher};
231 let mut h = std::collections::hash_map::DefaultHasher::new();
232 e.hash(&mut h);
233 format!("acct:{:016x}", h.finish())
234 }
235 None => "acct:unknown".to_string(),
236 }
237}
238
239async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
244 let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
245 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
248 .await
249 .ok()?;
250 let html = String::from_utf8_lossy(&bytes);
251 html.split("csrfToken\":\"")
252 .nth(1)
253 .and_then(|s| s.split('"').next())
254 .filter(|t| !t.is_empty())
255 .map(|t| t.to_string())
256}
257
258async fn post_rpc(
259 client: &reqwest::Client,
260 base: &str,
261 csrf: Option<&str>,
262 rpc: &str,
263) -> Result<serde_json::Value> {
264 let mut req = client
265 .post(format!("{base}/{rpc}"))
266 .header("Content-Type", "application/json")
267 .body("{}")
268 .timeout(HTTP_TIMEOUT);
269 if let Some(token) = csrf {
270 req = req.header("x-codeium-csrf-token", token);
271 }
272 let resp = req.send().await?;
273
274 let status = resp.status();
275 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
279 if !status.is_success() {
280 let body = String::from_utf8_lossy(&bytes).into_owned();
281 return Err(AppError::Http {
282 status: status.as_u16(),
283 body,
284 });
285 }
286
287 Ok(serde_json::from_slice(&bytes)?)
288}
289
290pub fn plan_from_status(v: &serde_json::Value) -> String {
291 v["userStatus"]["userTier"]["name"]
292 .as_str()
293 .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
294 .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
295 .filter(|s| !s.is_empty())
296 .unwrap_or(DEFAULT_PLAN)
297 .to_string()
298}
299
300pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
310 let groups = v["response"]["groups"]
311 .as_array()
312 .or_else(|| v["groups"].as_array())
313 .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
314
315 let mut gemini_5h = None;
316 let mut gemini_weekly = None;
317 let mut tp_5h = None;
318 let mut tp_weekly = None;
319
320 for group in groups {
321 let group_name = group["displayName"].as_str().unwrap_or_default();
322 let Some(buckets) = group["buckets"].as_array() else {
323 continue;
324 };
325 for bucket in buckets {
326 let id = bucket["bucketId"].as_str().unwrap_or_default();
327 let window = bucket["window"].as_str().unwrap_or_default();
328 let is_weekly = if id.ends_with("weekly") || window == "weekly" {
329 true
330 } else if id.ends_with("5h") || window == "5h" {
331 false
332 } else {
333 continue;
336 };
337 let is_gemini = if id.starts_with("gemini") {
338 true
339 } else if id.starts_with("3p") {
340 false
341 } else if group_name.contains("Gemini") {
342 true
343 } else if group_name.contains("Claude") || group_name.contains("GPT") {
344 false
345 } else {
346 continue;
349 };
350
351 let (slot, slot_name) = match (is_gemini, is_weekly) {
352 (true, false) => (&mut gemini_5h, "Gemini 5h"),
353 (true, true) => (&mut gemini_weekly, "Gemini weekly"),
354 (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
355 (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
356 };
357 let parsed = usage_window(bucket, is_weekly)?;
358 if slot.replace(parsed).is_some() {
359 return Err(AppError::Schema(format!(
360 "antigravity: duplicate {slot_name} bucket"
361 )));
362 }
363 }
364 }
365
366 let session = gemini_5h.ok_or_else(|| {
367 AppError::Other("antigravity: quota summary has no Gemini 5h bucket".into())
368 })?;
369 let weekly = gemini_weekly.ok_or_else(|| {
370 AppError::Other("antigravity: quota summary has no Gemini weekly bucket".into())
371 })?;
372
373 Ok(AntigravitySnapshot {
374 plan,
375 account: String::new(),
377 session,
378 weekly,
379 third_party_session: tp_5h,
380 third_party_weekly: tp_weekly,
381 })
382}
383
384fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
388 let remaining = bucket["remainingFraction"]
389 .as_f64()
390 .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
391 .ok_or_else(|| {
392 AppError::Schema(format!(
393 "antigravity: bucket {} has no valid remainingFraction in 0..=1",
394 bucket["bucketId"].as_str().unwrap_or("<unnamed>")
395 ))
396 })?;
397 Ok(UsageWindow {
398 utilization_pct: pct_used(remaining),
399 resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
400 window_duration: if is_weekly {
401 chrono::Duration::days(7)
402 } else {
403 chrono::Duration::hours(5)
404 },
405 })
406}
407
408fn pct_used(remaining_fraction: f64) -> i32 {
411 let used = (1.0 - remaining_fraction) * 100.0;
412 used.round() as i32
413}
414
415fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
416 match value {
417 serde_json::Value::Null => Ok(None),
418 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
419 .map(|dt| Some(dt.with_timezone(&Utc)))
420 .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
421 _ => Err(AppError::Schema(format!(
422 "antigravity: {field} must be a timestamp or null"
423 ))),
424 }
425}
426
427fn candidate_bases() -> Vec<String> {
433 candidate_bases_with(
434 std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
435 discover_ls_ports(),
436 )
437}
438
439fn candidate_bases_with(override_addr: Option<&str>, discovered: Vec<u16>) -> Vec<String> {
442 let mut bases = Vec::new();
443 if let Some(base) = override_addr.and_then(normalize_base) {
444 bases.push(base);
445 }
446
447 for p in discovered {
453 let candidate = format!("http://127.0.0.1:{p}");
454 if !bases.contains(&candidate) {
455 bases.push(candidate);
456 }
457 }
458
459 bases
460}
461
462fn normalize_base(addr: &str) -> Option<String> {
471 let trimmed = addr.trim();
472 let (scheme, authority) = match trimmed.split_once("://") {
473 Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
474 _ => ("http", trimmed),
475 };
476 let authority = authority.trim_end_matches('/');
477 (!authority.is_empty()).then(|| format!("{scheme}://{authority}"))
478}
479
480fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
487 let comm = comm.trim().to_lowercase();
488 let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
489 if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
490 return true;
491 }
492 exe.is_some_and(|p| {
493 let p = p.to_lowercase().replace('\\', "/");
494 let p = p.strip_suffix(".exe").unwrap_or(&p);
495 p.contains("antigravity") || p.ends_with("/agy")
496 })
497}
498
499#[cfg(any(test, target_os = "linux", target_os = "macos", target_os = "windows"))]
529fn probe_order(per_pid: std::collections::BTreeMap<u32, Vec<u16>>) -> Vec<u16> {
530 let groups: Vec<Vec<u16>> = per_pid
531 .into_values()
532 .map(|mut group| {
533 group.sort_unstable_by(|a, b| b.cmp(a));
534 group.dedup();
539 group
540 })
541 .collect();
542
543 let mut ports: Vec<u16> = Vec::new();
544 for rank in 0..groups.iter().map(Vec::len).max().unwrap_or(0) {
545 for port in groups.iter().filter_map(|group| group.get(rank)) {
546 if !ports.contains(port) {
547 ports.push(*port);
548 }
549 }
550 }
551 ports
552}
553
554#[cfg(target_os = "linux")]
561fn discover_ls_ports() -> Vec<u16> {
562 use std::collections::{BTreeMap, HashMap};
563
564 let mut owners: HashMap<u64, u32> = HashMap::new();
567 let Ok(entries) = std::fs::read_dir("/proc") else {
568 return Vec::new();
569 };
570 for entry in entries.flatten() {
571 let pid_dir = entry.path();
572 let Some(pid) = pid_dir
573 .file_name()
574 .and_then(|name| name.to_str())
575 .and_then(|name| name.parse::<u32>().ok())
576 else {
577 continue;
578 };
579 let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
580 continue;
581 };
582 let exe = std::fs::read_link(pid_dir.join("exe")).ok();
583 if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
584 continue;
585 }
586 let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
587 continue;
588 };
589 for fd in fds.flatten() {
590 let Ok(target) = std::fs::read_link(fd.path()) else {
591 continue;
592 };
593 if let Some(ino) = target
594 .to_str()
595 .and_then(|s| s.strip_prefix("socket:["))
596 .and_then(|s| s.strip_suffix(']'))
597 .and_then(|s| s.parse::<u64>().ok())
598 {
599 owners.insert(ino, pid);
600 }
601 }
602 }
603
604 if owners.is_empty() {
605 return Vec::new();
606 }
607
608 let mut per_pid: BTreeMap<u32, Vec<u16>> = BTreeMap::new();
609 for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
610 let Ok(contents) = std::fs::read_to_string(table) else {
611 continue;
612 };
613 for line in contents.lines().skip(1) {
614 if let Some((port, ino)) = parse_proc_net_line(line)
615 && let Some(&pid) = owners.get(&ino)
616 {
617 per_pid.entry(pid).or_default().push(port);
618 }
619 }
620 }
621 probe_order(per_pid)
622}
623
624#[cfg(target_os = "macos")]
631fn discover_ls_ports() -> Vec<u16> {
632 let Ok(output) = std::process::Command::new("lsof")
633 .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
634 .output()
635 else {
636 return Vec::new();
637 };
638 parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
642}
643
644#[cfg(any(test, target_os = "macos"))]
649fn parse_lsof_pcn(output: &str) -> Vec<u16> {
650 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
651 let mut pid = None;
654 let mut owner = None;
655 for line in output.lines() {
656 let Some(rest) = line.get(1..) else { continue };
657 match line.as_bytes().first() {
658 Some(b'p') => {
659 pid = rest.parse::<u32>().ok();
660 owner = None;
661 }
662 Some(b'c') => owner = pid.filter(|_| is_antigravity_process(rest, None)),
663 Some(b'n') => {
664 if let Some(pid) = owner
665 && let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
666 {
667 per_pid.entry(pid).or_default().push(port);
668 }
669 }
670 _ => {}
671 }
672 }
673 probe_order(per_pid)
674}
675
676#[cfg(any(test, target_os = "windows"))]
677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
678struct WindowsTcpRow {
679 local_addr: [u8; 4],
680 local_port: u32,
681 pid: u32,
682}
683
684#[cfg(any(test, target_os = "windows"))]
685fn decode_windows_process_name(raw: &[u16]) -> String {
686 let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
687 String::from_utf16_lossy(&raw[..end])
688}
689
690#[cfg(any(test, target_os = "windows"))]
691fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
692 processes
693 .iter()
694 .filter(|(_, name)| is_antigravity_process(name, None))
695 .map(|(pid, _)| *pid)
696 .collect()
697}
698
699#[cfg(any(test, target_os = "windows"))]
702fn matching_windows_ports(
703 pids: &std::collections::HashSet<u32>,
704 rows: &[WindowsTcpRow],
705) -> Vec<u16> {
706 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
707 for row in rows {
708 if !pids.contains(&row.pid) || row.local_addr != [127, 0, 0, 1] {
709 continue;
710 }
711 let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
712 if port != 0 {
713 per_pid.entry(row.pid).or_default().push(port);
714 }
715 }
716 probe_order(per_pid)
717}
718
719#[cfg(any(test, target_os = "windows"))]
720fn checked_windows_row_count(
721 buffer_len: usize,
722 rows_offset: usize,
723 row_size: usize,
724 declared: usize,
725) -> Option<usize> {
726 let rows_len = row_size.checked_mul(declared)?;
727 let end = rows_offset.checked_add(rows_len)?;
728 (row_size != 0 && end <= buffer_len).then_some(declared)
729}
730
731#[cfg(target_os = "windows")]
732struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
733
734#[cfg(target_os = "windows")]
735impl Drop for WindowsHandle {
736 fn drop(&mut self) {
737 unsafe {
738 windows_sys::Win32::Foundation::CloseHandle(self.0);
739 }
740 }
741}
742
743#[cfg(target_os = "windows")]
744fn windows_processes() -> Vec<(u32, String)> {
745 use std::mem::size_of;
746 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
747 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
748 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
749 TH32CS_SNAPPROCESS,
750 };
751
752 let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
753 if handle == INVALID_HANDLE_VALUE {
754 return Vec::new();
755 }
756 let snapshot = WindowsHandle(handle);
757 let mut entry = PROCESSENTRY32W::default();
758 let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
759 return Vec::new();
760 };
761 entry.dwSize = entry_size;
762 if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
763 return Vec::new();
764 }
765
766 let mut processes = Vec::new();
767 loop {
768 processes.push((
769 entry.th32ProcessID,
770 decode_windows_process_name(&entry.szExeFile),
771 ));
772 if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
773 break;
774 }
775 }
776 processes
777}
778
779#[cfg(target_os = "windows")]
780fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
781 use std::mem::{offset_of, size_of, size_of_val};
782 use windows_sys::Win32::NetworkManagement::IpHelper::{
783 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
784 };
785
786 let available = used_bytes.min(size_of_val(buffer));
787 let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
788 if available < size_of::<u32>() || available < rows_offset {
789 return Vec::new();
790 }
791 let base = buffer.as_ptr().cast::<u8>();
792 let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
793 if checked_windows_row_count(
794 available,
795 rows_offset,
796 size_of::<MIB_TCPROW_OWNER_PID>(),
797 declared,
798 )
799 .is_none()
800 {
801 return Vec::new();
802 }
803
804 let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
805 (0..declared)
806 .map(|index| unsafe { rows.add(index).read_unaligned() })
807 .map(|row| WindowsTcpRow {
808 local_addr: row.dwLocalAddr.to_ne_bytes(),
809 local_port: row.dwLocalPort,
810 pid: row.dwOwningPid,
811 })
812 .collect()
813}
814
815#[cfg(target_os = "windows")]
816fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
817 use std::mem::size_of;
818 use std::ptr::null_mut;
819 use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
820 use windows_sys::Win32::NetworkManagement::IpHelper::{
821 GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
822 };
823 use windows_sys::Win32::Networking::WinSock::AF_INET;
824
825 let mut size = 0u32;
826 let status = unsafe {
827 GetExtendedTcpTable(
828 null_mut(),
829 &mut size,
830 0,
831 u32::from(AF_INET),
832 TCP_TABLE_OWNER_PID_LISTENER,
833 0,
834 )
835 };
836 if status != ERROR_INSUFFICIENT_BUFFER {
837 return Vec::new();
838 }
839
840 for _ in 0..3 {
841 let Some(words) = (size as usize)
842 .checked_add(size_of::<u32>() - 1)
843 .map(|bytes| bytes / size_of::<u32>())
844 else {
845 return Vec::new();
846 };
847 if words == 0 {
848 return Vec::new();
849 }
850 let mut buffer = Vec::<u32>::new();
851 if buffer.try_reserve_exact(words).is_err() {
852 return Vec::new();
853 }
854 buffer.resize(words, 0);
855 let mut used = size;
856 let status = unsafe {
857 GetExtendedTcpTable(
858 buffer.as_mut_ptr().cast(),
859 &mut used,
860 0,
861 u32::from(AF_INET),
862 TCP_TABLE_OWNER_PID_LISTENER,
863 0,
864 )
865 };
866 if status == ERROR_INSUFFICIENT_BUFFER {
867 size = used;
868 continue;
869 }
870 if status != 0 {
871 return Vec::new();
872 }
873 return parse_windows_tcp_rows(&buffer, used as usize);
874 }
875 Vec::new()
876}
877
878#[cfg(target_os = "windows")]
879fn discover_ls_ports() -> Vec<u16> {
880 let pids = matching_windows_process_ids(&windows_processes());
881 if pids.is_empty() {
882 return Vec::new();
883 }
884 matching_windows_ports(&pids, &windows_tcp_rows())
885}
886
887#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
888fn discover_ls_ports() -> Vec<u16> {
889 Vec::new()
890}
891
892#[cfg(target_os = "linux")]
895fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
896 let cols: Vec<&str> = line.split_whitespace().collect();
897 if cols.len() < 10 {
898 return None;
899 }
900 if cols[3] != "0A" {
902 return None;
903 }
904 let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
905 let inode = cols[9].parse::<u64>().ok()?;
906 Some((port, inode))
907}
908
909fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
914 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
915 return Err(original);
916 };
917 reuse_cache(bytes, cache, true, None, now).or(Err(original))
918}
919
920fn fallback_with_error(
925 cache: &Cache,
926 last_error: Option<(u16, String)>,
927 reason: AppError,
928 now: DateTime<Utc>,
929) -> Result<FetchOutcome> {
930 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
931 return Err(reason);
932 };
933 let Ok(mut outcome) = reuse_cache(bytes, cache, true, None, now) else {
934 return Err(reason);
935 };
936 outcome.last_error = last_error;
937 Ok(outcome)
938}
939
940fn reuse_cache(
941 bytes: Vec<u8>,
942 cache: &Cache,
943 stale: bool,
944 account: Option<&str>,
945 now: DateTime<Utc>,
946) -> Result<FetchOutcome> {
947 let snap = parse_cache_at(&bytes, account, now)?;
948 Ok(FetchOutcome {
949 snapshot: snap,
950 stale,
951 last_error: cache.read_last_error(),
952 cache_age: cache.payload_age(),
953 })
954}
955
956pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
962 parse_cache_at(bytes, account, Utc::now())
963}
964
965fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
975 [
976 ("Gemini 5h", Some(&snap.session)),
977 ("Gemini weekly", Some(&snap.weekly)),
978 ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
979 ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
980 ]
981 .into_iter()
982 .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
983 .map(|(name, _)| name)
984}
985
986pub fn parse_cache_at(
987 bytes: &[u8],
988 account: Option<&str>,
989 now: DateTime<Utc>,
990) -> Result<AntigravitySnapshot> {
991 let v: serde_json::Value = serde_json::from_slice(bytes)?;
992
993 let cached_account = v.get("account").and_then(serde_json::Value::as_str);
994 if let Some(expected) = account
995 && cached_account != Some(expected)
996 {
997 return Err(AppError::Schema(
998 "antigravity cache belongs to a different account; refetching".into(),
999 ));
1000 }
1001
1002 let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
1007 match v.get(pct_key) {
1008 None | Some(serde_json::Value::Null) => Ok(None),
1009 Some(value) => value
1010 .as_i64()
1011 .filter(|pct| (0..=100).contains(pct))
1012 .map(|pct| Some(pct as i32))
1013 .ok_or_else(|| {
1014 AppError::Schema(format!(
1015 "antigravity: cached {pct_key} must be an integer in 0..=100"
1016 ))
1017 }),
1018 }
1019 };
1020
1021 let window = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1022 let pct = cached_pct(pct_key)?.ok_or_else(|| {
1023 AppError::Schema(format!("antigravity: cached payload missing {pct_key}"))
1024 })?;
1025 Ok::<_, AppError>(UsageWindow {
1026 utilization_pct: pct,
1027 resets_at: parse_reset(&v[reset_key], reset_key)?,
1028 window_duration: if weekly {
1029 chrono::Duration::days(7)
1030 } else {
1031 chrono::Duration::hours(5)
1032 },
1033 })
1034 };
1035
1036 let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1037 let Some(pct) = cached_pct(pct_key)? else {
1038 return Ok(None);
1039 };
1040 Ok::<_, AppError>(Some(UsageWindow {
1041 utilization_pct: pct,
1042 resets_at: parse_reset(&v[reset_key], reset_key)?,
1043 window_duration: if weekly {
1044 chrono::Duration::days(7)
1045 } else {
1046 chrono::Duration::hours(5)
1047 },
1048 }))
1049 };
1050
1051 let snap = AntigravitySnapshot {
1052 plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
1053 account: cached_account.unwrap_or_default().to_string(),
1054 session: window("session_pct", "session_reset", false)?,
1055 weekly: window("weekly_pct", "weekly_reset", true)?,
1056 third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
1057 third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
1058 };
1059
1060 if let Some(window) = expired_window(&snap, now) {
1061 return Err(AppError::Schema(format!(
1062 "antigravity cache is past its {window} reset; refetching"
1063 )));
1064 }
1065 Ok(snap)
1066}
1067
1068pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
1069 serde_json::json!({
1070 "plan": snap.plan,
1071 "account": snap.account,
1072 "session_pct": snap.session.utilization_pct,
1073 "session_reset": snap.session.resets_at.map(|dt| dt.to_rfc3339()),
1074 "weekly_pct": snap.weekly.utilization_pct,
1075 "weekly_reset": snap.weekly.resets_at.map(|dt| dt.to_rfc3339()),
1076 "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
1077 "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1078 "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
1079 "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1080 })
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085 use super::*;
1086
1087 const QUOTA_JSON: &str = r#"{
1091 "response": {
1092 "groups": [
1093 {
1094 "displayName": "Gemini Models",
1095 "buckets": [
1096 {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
1097 "window": "weekly", "remainingFraction": 0.9191212,
1098 "resetTime": "2026-07-28T17:39:58Z"},
1099 {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
1100 "window": "5h", "remainingFraction": 0.5672253,
1101 "resetTime": "2026-07-22T17:47:00Z"}
1102 ]
1103 },
1104 {
1105 "displayName": "Claude and GPT models",
1106 "buckets": [
1107 {"bucketId": "3p-weekly", "window": "weekly",
1108 "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
1109 {"bucketId": "3p-5h", "window": "5h",
1110 "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
1111 ]
1112 }
1113 ]
1114 }
1115 }"#;
1116
1117 fn now() -> DateTime<Utc> {
1120 DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
1121 .unwrap()
1122 .with_timezone(&Utc)
1123 }
1124
1125 fn parsed() -> AntigravitySnapshot {
1126 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1127 parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
1128 }
1129
1130 #[test]
1131 fn quota_summary_maps_four_distinct_windows() {
1132 let snap = parsed();
1133 assert_eq!(snap.plan, "Google AI Pro");
1134 assert_eq!(snap.session.utilization_pct, 43);
1136 assert_eq!(snap.weekly.utilization_pct, 8);
1137 assert_eq!(
1138 snap.third_party_session.as_ref().unwrap().utilization_pct,
1139 75
1140 );
1141 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
1142 }
1143
1144 #[test]
1145 fn each_window_keeps_its_own_reset_time() {
1146 let snap = parsed();
1147 let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
1148 assert_eq!(snap.session.resets_at, at("2026-07-22T17:47:00Z"));
1149 assert_eq!(snap.weekly.resets_at, at("2026-07-28T17:39:58Z"));
1150 assert_eq!(
1151 snap.third_party_weekly.as_ref().unwrap().resets_at,
1152 at("2026-07-29T12:47:00Z")
1153 );
1154 assert_ne!(snap.session.resets_at, snap.weekly.resets_at);
1156 }
1157
1158 #[test]
1159 fn window_durations_match_their_bucket() {
1160 let snap = parsed();
1161 assert_eq!(snap.session.window_duration, chrono::Duration::hours(5));
1162 assert_eq!(snap.weekly.window_duration, chrono::Duration::days(7));
1163 assert_eq!(
1164 snap.third_party_weekly.as_ref().unwrap().window_duration,
1165 chrono::Duration::days(7)
1166 );
1167 }
1168
1169 #[test]
1170 fn groups_are_matched_by_display_name_when_bucket_ids_change() {
1171 let v: serde_json::Value = serde_json::from_str(
1172 r#"{"response":{"groups":[
1173 {"displayName":"Gemini Models","buckets":[
1174 {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
1175 {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
1176 {"displayName":"Claude and GPT models","buckets":[
1177 {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
1178 ]}}"#,
1179 )
1180 .unwrap();
1181 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
1182 assert_eq!(snap.session.utilization_pct, 50);
1183 assert_eq!(snap.weekly.utilization_pct, 10);
1184 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
1185 assert!(snap.third_party_weekly.is_none());
1186 }
1187
1188 #[test]
1189 fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
1190 let duplicate: serde_json::Value = serde_json::from_str(
1191 r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
1192 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1193 {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
1194 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
1195 ]}]}}"#,
1196 )
1197 .unwrap();
1198 let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
1199 assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
1200
1201 let unrelated: serde_json::Value = serde_json::from_str(
1204 r#"{"response":{"groups":[
1205 {"displayName":"Gemini Models","buckets":[
1206 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1207 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
1208 {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
1209 ]},
1210 {"displayName":"Future Models","buckets":[
1211 {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
1212 ]}
1213 ]}}"#,
1214 )
1215 .unwrap();
1216 let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
1217 assert!(snap.third_party_session.is_none());
1218 assert!(snap.third_party_weekly.is_none());
1219 }
1220
1221 #[test]
1224 fn a_bucket_without_a_usable_fraction_is_rejected() {
1225 for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
1226 let v: serde_json::Value = serde_json::from_str(&format!(
1227 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
1228 {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
1229 {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
1230 ))
1231 .unwrap();
1232 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1233 assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
1234 }
1235 }
1236
1237 #[test]
1238 fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
1239 for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
1240 let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1241 v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
1242 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1243 assert!(err.to_string().contains("resetTime"), "{err}");
1244 }
1245 }
1246
1247 #[test]
1248 fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
1249 let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
1250 assert!(parse_quota_summary(&v, "Pro".into()).is_err());
1251 }
1252
1253 #[test]
1254 fn cache_round_trip_preserves_every_window() {
1255 let snap = parsed();
1256 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1257 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1258 }
1259
1260 #[test]
1264 fn a_truncated_cached_payload_is_rejected_not_zeroed() {
1265 let full = snap_to_json(&parsed());
1266 for missing in ["session_pct", "weekly_pct"] {
1267 let mut v = full.clone();
1268 v.as_object_mut().unwrap().remove(missing);
1269 let bytes = serde_json::to_vec(&v).unwrap();
1270 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1271 assert!(err.to_string().contains(missing), "{missing}: {err}");
1272 }
1273 assert!(parse_cache_at(b"{}", None, now()).is_err());
1275 }
1276
1277 #[test]
1278 fn cached_percentages_are_range_checked_before_narrowing() {
1279 let full = snap_to_json(&parsed());
1280 for (key, bad) in [
1281 ("session_pct", serde_json::json!(-1)),
1282 ("weekly_pct", serde_json::json!(101)),
1283 ("session_pct", serde_json::json!(i64::MAX)),
1284 ("tp_session_pct", serde_json::json!("75")),
1285 ] {
1286 let mut v = full.clone();
1287 v[key] = bad;
1288 let bytes = serde_json::to_vec(&v).unwrap();
1289 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1290 assert!(err.to_string().contains(key), "{key}: {err}");
1291 }
1292 }
1293
1294 #[test]
1295 fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
1296 let mut v = snap_to_json(&parsed());
1297 v["session_reset"] = serde_json::json!("not-a-time");
1298 let bytes = serde_json::to_vec(&v).unwrap();
1299 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1300 assert!(err.to_string().contains("session_reset"), "{err}");
1301 }
1302
1303 #[test]
1308 fn a_cache_past_its_reset_is_refused() {
1309 let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
1310 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1311
1312 assert!(parse_cache_at(&bytes, None, now()).is_ok());
1314 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
1316
1317 let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
1319 assert!(err.to_string().contains("5h"), "{err}");
1320
1321 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
1323 }
1324
1325 #[test]
1328 fn expiry_names_the_window_that_rolled_over() {
1329 let mut snap = parsed();
1330 snap.session.resets_at = None;
1332 snap.third_party_session = None;
1333 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1334 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1335
1336 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
1338
1339 let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
1341 assert!(err.to_string().contains("Gemini weekly"), "{err}");
1342 }
1343
1344 #[test]
1346 fn a_window_without_a_reset_never_expires() {
1347 let mut snap = parsed();
1348 for w in [&mut snap.session, &mut snap.weekly] {
1349 w.resets_at = None;
1350 }
1351 snap.third_party_session = None;
1352 snap.third_party_weekly = None;
1353 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1354 let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1355 .unwrap()
1356 .with_timezone(&Utc);
1357 assert!(parse_cache_at(&bytes, None, far_future).is_ok());
1358 }
1359
1360 #[test]
1362 fn a_cache_from_another_account_is_rejected() {
1363 let mut snap = parsed();
1364 snap.account = "acct:aaaa".into();
1365 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1366
1367 assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
1368 assert_eq!(
1369 parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
1370 snap
1371 );
1372
1373 let mut legacy = snap_to_json(&snap);
1375 legacy.as_object_mut().unwrap().remove("account");
1376 let legacy = serde_json::to_vec(&legacy).unwrap();
1377 assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
1378 }
1379
1380 #[test]
1383 fn an_unverifiable_cache_is_served_rather_than_discarded() {
1384 let mut snap = parsed();
1385 snap.account = "acct:aaaa".into();
1386 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1387 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1388 }
1389
1390 #[test]
1391 fn account_key_fingerprints_rather_than_storing_the_address() {
1392 let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
1393 let a = with("someone@example.com");
1394 assert!(!a.contains("someone"), "{a}");
1395 assert!(!a.contains('@'), "{a}");
1396 assert_eq!(a, with("someone@example.com"), "must be stable");
1397 assert_ne!(a, with("other@example.com"));
1398 let unknown = account_key(&serde_json::json!({}));
1400 assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1401 assert_ne!(unknown, a);
1402 }
1403
1404 #[test]
1407 fn absent_third_party_windows_are_not_treated_as_corruption() {
1408 let mut snap = parsed();
1409 snap.third_party_session = None;
1410 snap.third_party_weekly = None;
1411 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1412 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1413 }
1414
1415 #[test]
1416 fn cache_round_trip_preserves_absent_third_party_windows() {
1417 let mut snap = parsed();
1418 snap.third_party_session = None;
1419 snap.third_party_weekly = None;
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]
1425 fn pct_used_inverts_valid_fractions() {
1426 assert_eq!(pct_used(1.0), 0);
1427 assert_eq!(pct_used(0.0), 100);
1428 assert_eq!(pct_used(0.5), 50);
1429 }
1430
1431 #[test]
1432 fn plan_falls_back_through_the_status_payload() {
1433 let tier: serde_json::Value =
1434 serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1435 .unwrap();
1436 assert_eq!(plan_from_status(&tier), "Google AI Pro");
1437
1438 let plan_only: serde_json::Value = serde_json::from_str(
1439 r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1440 )
1441 .unwrap();
1442 assert_eq!(plan_from_status(&plan_only), "Pro");
1443
1444 let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1445 assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1446 }
1447
1448 #[cfg(target_os = "linux")]
1449 #[test]
1450 fn proc_net_parser_keeps_only_listening_rows() {
1451 let listen = " 0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1452 00:00000000 00000000 1000 0 123456 1 0000 100 0";
1453 assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1454
1455 let established = " 1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1456 00:00000000 00000000 1000 0 123457 1 0000 100 0";
1457 assert_eq!(parse_proc_net_line(established), None);
1458
1459 assert_eq!(parse_proc_net_line("garbage"), None);
1460 }
1461
1462 #[test]
1463 fn explicit_address_comes_first_and_gets_a_scheme() {
1464 assert_eq!(
1465 candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1466 vec![
1467 "http://127.0.0.1:1234".to_string(),
1468 "http://127.0.0.1:5678".to_string(),
1469 ]
1470 );
1471 assert_eq!(
1473 candidate_bases_with(Some("127.0.0.1:1234/"), vec![5678]),
1474 vec![
1475 "http://127.0.0.1:1234".to_string(),
1476 "http://127.0.0.1:5678".to_string(),
1477 ]
1478 );
1479 assert_eq!(
1481 candidate_bases_with(Some("127.0.0.1:5678"), vec![5678]),
1482 vec!["http://127.0.0.1:5678".to_string()]
1483 );
1484 assert_eq!(
1486 candidate_bases_with(None, vec![5678, 5678]),
1487 vec!["http://127.0.0.1:5678".to_string()]
1488 );
1489 assert_eq!(
1491 candidate_bases_with(Some("https://host:9"), vec![]),
1492 vec!["https://host:9".to_string()]
1493 );
1494 }
1495
1496 fn http(status: u16) -> AppError {
1497 AppError::Http {
1498 status,
1499 body: String::new(),
1500 }
1501 }
1502
1503 #[test]
1507 fn an_auth_failure_outranks_later_transport_noise() {
1508 let err = select_probe_error(vec![
1509 http(401),
1510 AppError::Transport("connection refused".into()),
1511 ]);
1512 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1513
1514 let err = select_probe_error(vec![
1515 AppError::Transport("connection refused".into()),
1516 http(403),
1517 ]);
1518 assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
1519 }
1520
1521 #[test]
1524 fn the_first_auth_failure_wins() {
1525 let err = select_probe_error(vec![http(401), http(403)]);
1526 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1527 }
1528
1529 #[test]
1533 fn without_an_auth_failure_the_last_error_stands() {
1534 let err = select_probe_error(vec![
1535 AppError::Transport("first".into()),
1536 http(500),
1537 AppError::Transport("last".into()),
1538 ]);
1539 assert!(
1540 matches!(&err, AppError::Transport(m) if m == "last"),
1541 "{err}"
1542 );
1543 assert!(err.is_transient());
1544 }
1545
1546 #[test]
1549 fn a_server_error_is_not_treated_as_actionable() {
1550 let err = select_probe_error(vec![http(500), http(401)]);
1551 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1552 }
1553
1554 #[test]
1555 fn no_candidates_at_all_yields_a_generic_error() {
1556 let err = select_probe_error(Vec::new());
1557 assert!(
1558 err.to_string().contains("no local server answered"),
1559 "{err}"
1560 );
1561 }
1562
1563 fn tls_echo() -> AppError {
1567 AppError::Http {
1568 status: 400,
1569 body: "Client sent an HTTP request to an HTTPS server.\n".into(),
1570 }
1571 }
1572
1573 #[test]
1578 fn a_tls_echo_does_not_mask_what_the_rpc_listener_said() {
1579 let err = select_probe_error(vec![
1580 AppError::Http {
1581 status: 500,
1582 body: "GetUserStatus: internal".into(),
1583 },
1584 tls_echo(),
1585 ]);
1586 assert!(
1587 matches!(&err, AppError::Http { status: 500, body } if body.contains("internal")),
1588 "{err}"
1589 );
1590 }
1591
1592 #[test]
1597 fn a_tls_echo_does_not_cost_the_silent_fallback() {
1598 let err = select_probe_error(vec![
1599 AppError::Transport("connection refused".into()),
1600 tls_echo(),
1601 ]);
1602 assert!(
1603 matches!(&err, AppError::Transport(m) if m == "connection refused"),
1604 "{err}"
1605 );
1606 assert!(
1607 err.is_transient(),
1608 "the echo must not make the run non-transient: {err}"
1609 );
1610 }
1611
1612 #[test]
1615 fn a_tls_echo_still_stands_when_it_is_the_only_thing_that_answered() {
1616 let err = select_probe_error(vec![tls_echo()]);
1617 assert!(matches!(err, AppError::Http { status: 400, .. }), "{err}");
1618 }
1619
1620 #[test]
1623 fn a_genuine_bad_request_is_not_mistaken_for_a_tls_echo() {
1624 let err = select_probe_error(vec![
1625 AppError::Http {
1626 status: 400,
1627 body: "unknown method GetUserStatus".into(),
1628 },
1629 tls_echo(),
1630 ]);
1631 assert!(
1632 matches!(&err, AppError::Http { status: 400, body } if body.contains("unknown method")),
1633 "{err}"
1634 );
1635 }
1636
1637 #[test]
1639 fn an_auth_failure_still_outranks_a_tls_echo() {
1640 let err = select_probe_error(vec![tls_echo(), http(401)]);
1641 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1642 }
1643
1644 #[test]
1645 fn every_discovered_port_is_probed_in_order() {
1646 assert_eq!(
1647 candidate_bases_with(None, vec![33875, 37435]),
1648 vec![
1649 "http://127.0.0.1:33875".to_string(),
1650 "http://127.0.0.1:37435".to_string(),
1651 ]
1652 );
1653 }
1654
1655 #[test]
1660 fn empty_discovery_yields_no_candidates() {
1661 assert!(candidate_bases_with(None, vec![]).is_empty());
1662 assert!(candidate_bases_with(Some(""), vec![]).is_empty());
1663 }
1664
1665 #[test]
1666 fn every_antigravity_product_is_recognised() {
1667 assert!(is_antigravity_process(
1669 "language_server\n",
1670 Some("/opt/antigravity/resources/bin/language_server")
1671 ));
1672 assert!(is_antigravity_process(
1674 "agy\n",
1675 Some("/home/u/.local/bin/agy")
1676 ));
1677 assert!(is_antigravity_process(
1679 "node",
1680 Some("/opt/antigravity/bin/helper")
1681 ));
1682 assert!(is_antigravity_process("antigravity", None));
1683 assert!(is_antigravity_process("agy.exe", None));
1684 assert!(is_antigravity_process("Antigravity.exe", None));
1685 assert!(is_antigravity_process("language_server.exe", None));
1686 assert!(is_antigravity_process(
1687 "language_server_windows_x64.exe",
1688 None
1689 ));
1690 assert!(is_antigravity_process(
1691 "node.exe",
1692 Some(r"C:\Users\u\AppData\Local\agy.exe")
1693 ));
1694 }
1695
1696 #[test]
1697 fn unrelated_processes_are_not_probed() {
1698 assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
1699 assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
1700 assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
1702 assert!(!is_antigravity_process("legacy.exe", None));
1703 assert!(!is_antigravity_process("not-agy.exe", None));
1704 assert!(!is_antigravity_process("", None));
1705 }
1706
1707 #[test]
1708 fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
1709 let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
1710 raw.extend([0, b'x' as u16]);
1711 assert_eq!(decode_windows_process_name(&raw), "agy.exe");
1712 assert_eq!(decode_windows_process_name(&[0xd800]), "�");
1713 assert_eq!(decode_windows_process_name(&[]), "");
1714 }
1715
1716 #[test]
1717 fn windows_process_filter_keeps_only_antigravity_pids() {
1718 let processes = vec![
1719 (10, "agy.exe".to_string()),
1720 (20, "language_server_windows_x64.exe".to_string()),
1721 (30, "sshd.exe".to_string()),
1722 ];
1723 let pids = matching_windows_process_ids(&processes);
1724 assert_eq!(pids, std::collections::HashSet::from([10, 20]));
1725 }
1726
1727 #[test]
1728 fn windows_listener_filter_joins_pid_loopback_and_port() {
1729 let pids = std::collections::HashSet::from([10]);
1730 let rows = [
1731 WindowsTcpRow {
1732 local_addr: [127, 0, 0, 1],
1733 local_port: u32::from(59870u16.to_be()),
1734 pid: 10,
1735 },
1736 WindowsTcpRow {
1737 local_addr: [127, 0, 0, 1],
1738 local_port: u32::from(59868u16.to_be()),
1739 pid: 10,
1740 },
1741 WindowsTcpRow {
1742 local_addr: [127, 0, 0, 1],
1743 local_port: u32::from(59870u16.to_be()),
1744 pid: 10,
1745 },
1746 WindowsTcpRow {
1747 local_addr: [0, 0, 0, 0],
1748 local_port: u32::from(50000u16.to_be()),
1749 pid: 10,
1750 },
1751 WindowsTcpRow {
1752 local_addr: [127, 0, 0, 1],
1753 local_port: u32::from(50001u16.to_be()),
1754 pid: 99,
1755 },
1756 WindowsTcpRow {
1757 local_addr: [127, 0, 0, 1],
1758 local_port: 0,
1759 pid: 10,
1760 },
1761 ];
1762 assert_eq!(matching_windows_ports(&pids, &rows), vec![59870, 59868]);
1763 }
1764
1765 #[test]
1769 fn windows_ports_from_two_products_keep_tls_listeners_last() {
1770 let pids = std::collections::HashSet::from([10, 20]);
1771 let row = |port: u16, pid: u32| WindowsTcpRow {
1772 local_addr: [127, 0, 0, 1],
1773 local_port: u32::from(port.to_be()),
1774 pid,
1775 };
1776 let rows = [
1777 row(40000, 10),
1778 row(40001, 10),
1779 row(50000, 20),
1780 row(50001, 20),
1781 ];
1782 assert_eq!(
1783 matching_windows_ports(&pids, &rows),
1784 vec![40001, 50001, 40000, 50000]
1785 );
1786 }
1787
1788 #[test]
1792 fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
1793 use std::collections::BTreeMap;
1794
1795 assert_eq!(
1797 probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
1798 vec![59870, 59868]
1799 );
1800 assert_eq!(
1805 probe_order(BTreeMap::from([
1806 (10, vec![40000, 40001]),
1807 (20, vec![50000, 50001]),
1808 ])),
1809 vec![40001, 50001, 40000, 50000]
1810 );
1811 assert_eq!(
1814 probe_order(BTreeMap::from([
1815 (10, vec![6000, 5000, 4000]),
1816 (20, vec![6000, 7000]),
1817 ])),
1818 vec![6000, 7000, 5000, 4000]
1819 );
1820 assert!(probe_order(BTreeMap::new()).is_empty());
1821 }
1822
1823 #[test]
1828 fn a_port_named_twice_by_one_product_still_occupies_one_rank() {
1829 use std::collections::BTreeMap;
1830
1831 assert_eq!(
1832 probe_order(BTreeMap::from([
1833 (10, vec![40001, 40001, 40000, 40000]),
1834 (20, vec![50001, 50000]),
1835 ])),
1836 vec![40001, 50001, 40000, 50000],
1837 "duplicate rows must not reorder the ranks below them"
1838 );
1839 }
1840
1841 #[test]
1846 fn a_half_started_product_is_the_documented_exception() {
1847 use std::collections::BTreeMap;
1848
1849 assert_eq!(
1850 probe_order(BTreeMap::from([
1851 (10, vec![40000]),
1852 (20, vec![50001, 50000])
1853 ])),
1854 vec![40000, 50001, 50000]
1855 );
1856 }
1857
1858 #[test]
1863 fn an_override_with_no_authority_is_dropped_not_probed() {
1864 for junk in ["/", "///", "http://", "https://", " / "] {
1865 assert_eq!(
1866 candidate_bases_with(Some(junk), vec![4242]),
1867 vec!["http://127.0.0.1:4242".to_string()],
1868 "{junk:?} should not survive as a candidate"
1869 );
1870 }
1871 assert!(candidate_bases_with(Some("/"), vec![]).is_empty());
1872 }
1873
1874 #[test]
1875 fn windows_table_bounds_reject_truncation_and_overflow() {
1876 assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
1877 assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
1878 assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
1879 assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
1880 assert_eq!(
1881 checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
1882 None
1883 );
1884 }
1885
1886 #[cfg(target_os = "windows")]
1887 #[test]
1888 fn windows_tcp_table_parser_copies_complete_rows_only() {
1889 use std::mem::{offset_of, size_of};
1890 use windows_sys::Win32::NetworkManagement::IpHelper::{
1891 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
1892 };
1893
1894 let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
1895 let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
1896 let words = used.div_ceil(size_of::<u32>());
1897 let mut buffer = vec![0u32; words];
1898 let first = MIB_TCPROW_OWNER_PID {
1899 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
1900 dwLocalPort: u32::from(59868u16.to_be()),
1901 dwOwningPid: 10,
1902 ..Default::default()
1903 };
1904 let second = MIB_TCPROW_OWNER_PID {
1905 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
1906 dwLocalPort: u32::from(59870u16.to_be()),
1907 dwOwningPid: 10,
1908 ..Default::default()
1909 };
1910 unsafe {
1911 buffer.as_mut_ptr().write_unaligned(2);
1912 let rows = buffer
1913 .as_mut_ptr()
1914 .cast::<u8>()
1915 .add(offset)
1916 .cast::<MIB_TCPROW_OWNER_PID>();
1917 rows.write_unaligned(first);
1918 rows.add(1).write_unaligned(second);
1919 }
1920
1921 assert_eq!(
1922 parse_windows_tcp_rows(&buffer, used),
1923 vec![
1924 WindowsTcpRow {
1925 local_addr: [127, 0, 0, 1],
1926 local_port: u32::from(59868u16.to_be()),
1927 pid: 10,
1928 },
1929 WindowsTcpRow {
1930 local_addr: [127, 0, 0, 1],
1931 local_port: u32::from(59870u16.to_be()),
1932 pid: 10,
1933 },
1934 ]
1935 );
1936 assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
1937 }
1938
1939 #[test]
1940 fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
1941 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";
1944 assert_eq!(parse_lsof_pcn(output), vec![61290, 61289, 8829]);
1945 }
1946
1947 #[test]
1951 fn lsof_parser_keeps_each_products_ports_in_its_own_group() {
1952 let output = concat!(
1953 "p100\ncagy\nf3\nn127.0.0.1:40000\nf4\nn127.0.0.1:40001\n",
1954 "p200\nclanguage_server\nf5\nn127.0.0.1:50000\nf6\nn127.0.0.1:50001\n",
1955 );
1956 assert_eq!(
1957 parse_lsof_pcn(output),
1958 vec![40001, 50001, 40000, 50000],
1959 "both HTTP listeners must precede both TLS listeners"
1960 );
1961 }
1962
1963 #[test]
1964 fn lsof_parser_matches_the_capitalised_macos_app_name() {
1965 let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
1966 assert_eq!(parse_lsof_pcn(output), vec![54321]);
1967 }
1968
1969 #[test]
1970 fn lsof_parser_deduplicates_and_handles_empty_output() {
1971 let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
1972 assert_eq!(parse_lsof_pcn(output), vec![9000]);
1973 assert!(parse_lsof_pcn("").is_empty());
1974 }
1975
1976 #[test]
1979 fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
1980 let dir = tempfile::tempdir().unwrap();
1981 let cache = Cache::at(dir.path().join("usage.json"));
1982 let reason = AppError::Credentials("Antigravity: no local language server found".into());
1983
1984 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1985 let msg = err.to_string();
1986 assert!(msg.contains("no local language server found"), "{msg}");
1987 assert!(!msg.contains("no usable cache"), "{msg}");
1988 }
1989
1990 #[test]
1991 fn unusable_cache_does_not_replace_the_live_diagnosis() {
1992 let dir = tempfile::tempdir().unwrap();
1993 let cache = Cache::at(dir.path().join("antigravity"));
1994 cache.write_payload(b"{}").unwrap();
1995
1996 let reason = AppError::Credentials("Antigravity must be running".into());
1997 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1998 assert!(err.to_string().contains("must be running"), "{err}");
1999
2000 let original = AppError::Transport("original loopback failure".into());
2001 let err = fallback_silent(&cache, now(), original).unwrap_err();
2002 assert!(
2003 err.to_string().contains("original loopback failure"),
2004 "{err}"
2005 );
2006 }
2007
2008 #[tokio::test]
2009 async fn rpc_error_bodies_are_bounded_too() {
2010 let mut server = mockito::Server::new_async().await;
2011 let path = format!("/{STATUS_RPC}");
2012 server
2013 .mock("POST", path.as_str())
2014 .with_status(500)
2015 .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
2016 .create_async()
2017 .await;
2018
2019 let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
2020 .await
2021 .unwrap_err();
2022 assert!(err.to_string().contains("exceeds"), "{err}");
2023 }
2024
2025 #[test]
2026 fn blank_override_falls_through_to_discovery() {
2027 assert_eq!(
2028 candidate_bases_with(Some(" "), vec![4242]),
2029 vec!["http://127.0.0.1:4242".to_string()]
2030 );
2031 }
2032}