1use serde::{Deserialize, Serialize};
7use std::env;
8use std::path::PathBuf;
9use std::time::{Duration, Instant};
10
11fn timeout() -> Duration {
14 std::env::var("PACKSET_TIMEOUT_MS")
15 .ok()
16 .and_then(|v| v.trim().parse::<u64>().ok())
17 .filter(|ms| *ms > 0)
18 .map_or(Duration::from_secs(30), Duration::from_millis)
19}
20
21const BUSY_WAIT: Duration = Duration::from_millis(50);
23const BUSY_RETRIES: u32 = 3;
25
26fn send(
31 req: ureq::Request,
32 body: Option<&serde_json::Value>,
33 budget: Duration,
34) -> Result<ureq::Response, Box<ureq::Error>> {
35 let started = Instant::now();
36 let mut wait = BUSY_WAIT;
37 let mut retries = 0;
38 loop {
39 let left = budget
40 .saturating_sub(started.elapsed())
41 .max(Duration::from_millis(1));
42 let attempt = req.clone().timeout(left);
43 let answered = match body {
44 Some(json) => attempt.send_json(json),
45 None => attempt.call(),
46 };
47 match answered {
48 Err(ureq::Error::Status(503, _))
49 if retries < BUSY_RETRIES && started.elapsed() + wait < budget =>
50 {
51 std::thread::sleep(wait);
52 wait *= 3;
53 retries += 1;
54 }
55 other => return other.map_err(Box::new),
56 }
57 }
58}
59
60fn path_seg(id: &str) -> String {
61 let mut out = String::with_capacity(id.len());
62 for b in id.bytes() {
63 match b {
64 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
65 out.push(b as char)
66 }
67 _ => out.push_str(&format!("%{b:02X}")),
68 }
69 }
70 out
71}
72
73pub const DEFAULT_PORT: u16 = 8761;
76
77pub fn load_seat_env() {
80 let Some(home) = env::var_os("HOME") else {
81 return;
82 };
83 let path = PathBuf::from(home).join(".config/ljos/env");
84 let Ok(text) = std::fs::read_to_string(path) else {
85 return;
86 };
87 for line in text.lines() {
88 let line = line.trim();
89 if line.is_empty() || line.starts_with('#') {
90 continue;
91 }
92 let Some((k, v)) = line.split_once('=') else {
93 continue;
94 };
95 let k = k.trim();
96 if k.is_empty() || env::var_os(k).is_some() {
97 continue;
98 }
99 env::set_var(k, v.trim());
100 }
101}
102
103#[must_use]
108pub fn resolved_workspace() -> String {
109 load_seat_env();
110 env::var("PACKSET_WORKSPACE")
111 .ok()
112 .map(|w| w.trim().to_string())
113 .filter(|w| !w.is_empty())
114 .unwrap_or_else(|| "seat".to_string())
115}
116
117#[must_use]
119pub fn default_port() -> u16 {
120 env::var("PACKSET_PORT")
121 .or_else(|_| env::var("GROK_MEM_PORT"))
122 .ok()
123 .and_then(|raw| raw.trim().parse().ok())
124 .unwrap_or(DEFAULT_PORT)
125}
126
127#[must_use]
133pub fn token_path() -> Option<PathBuf> {
134 if let Some(file) = env::var_os("PACKSET_TOKEN_FILE").filter(|v| !v.is_empty()) {
135 return Some(PathBuf::from(file));
136 }
137 packset_core::home::resolve().map(|home| home.join(TOKEN_FILE))
138}
139
140pub const TOKEN_FILE: &str = "token";
142
143#[must_use]
147pub fn token() -> Option<String> {
148 if let Ok(t) = env::var("PACKSET_TOKEN") {
149 let t = t.trim().to_string();
150 if !t.is_empty() {
151 return Some(t);
152 }
153 }
154 let text = std::fs::read_to_string(token_path()?).ok()?;
155 let t = text.lines().next()?.trim().to_string();
156 (!t.is_empty()).then_some(t)
157}
158
159pub fn authorize(request: ureq::Request) -> ureq::Request {
166 if foreign_listener(request.url()) {
167 return request;
168 }
169 match token() {
170 Some(t) => request.set("Authorization", &format!("Bearer {t}")),
171 None => request,
172 }
173}
174
175fn loopback_port(url: &str) -> Option<u16> {
177 let rest = url
178 .strip_prefix("http://")
179 .or_else(|| url.strip_prefix("https://"))?;
180 let authority = rest.split(['/', '?', '#']).next()?;
181 let (host, port) = match authority.rsplit_once(':') {
182 Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
183 _ => (authority, 80),
184 };
185 matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
186}
187
188fn listen_uids(table: &str, port: u16) -> Vec<u32> {
192 table
193 .lines()
194 .skip(1)
195 .filter_map(|line| {
196 let fields: Vec<&str> = line.split_whitespace().collect();
197 let local = fields.get(1)?;
198 if fields.get(3) != Some(&"0A") {
199 return None;
200 }
201 let hex = local.rsplit(':').next()?;
202 if u16::from_str_radix(hex, 16).ok()? != port {
203 return None;
204 }
205 fields.get(7)?.parse().ok()
206 })
207 .collect()
208}
209
210fn foreign_listener(url: &str) -> bool {
214 #[cfg(target_os = "linux")]
215 {
216 use std::os::unix::fs::MetadataExt;
217 let Some(port) = loopback_port(url) else {
218 return false;
219 };
220 let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
221 return false;
222 };
223 let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
224 .iter()
225 .filter_map(|t| std::fs::read_to_string(t).ok())
226 .flat_map(|text| listen_uids(&text, port))
227 .collect();
228 !owners.is_empty() && owners.iter().all(|uid| *uid != me)
229 }
230 #[cfg(not(target_os = "linux"))]
231 {
232 let _ = url;
233 false
234 }
235}
236
237#[derive(Debug, thiserror::Error)]
238pub enum Error {
239 #[error("packset url missing")]
240 NoUrl,
241 #[error("http: {0}")]
242 Http(#[from] Box<ureq::Error>),
243 #[error("io: {0}")]
244 Io(#[from] std::io::Error),
245 #[error("json: {0}")]
246 Json(#[from] serde_json::Error),
247 #[error("bad response: {0}")]
248 Bad(String),
249}
250
251#[derive(Debug, Clone)]
252pub struct PacksetClient {
253 base: String,
254 workspace: Option<String>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct Hit {
261 pub id: Option<String>,
262 pub text: String,
263 #[serde(default)]
264 pub score: f64,
265 #[serde(default)]
266 pub kind: String,
267 #[serde(default)]
270 pub ts: Option<String>,
271 #[serde(default)]
276 pub entities: Vec<String>,
277 #[serde(default)]
278 pub ballots: Option<u32>,
279 #[serde(default)]
280 pub of: Option<u32>,
281}
282
283fn refused(url: &str, e: Box<ureq::Error>) -> Error {
285 match *e {
286 ureq::Error::Status(code, response) => {
287 let text = response.into_string().unwrap_or_default();
288 let reason = serde_json::from_str::<serde_json::Value>(&text)
289 .ok()
290 .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
291 .unwrap_or(text);
292 let reason = reason.trim();
293 if code == 401 {
294 return Error::Bad(format!(
295 "{url}: 401: {reason}; this seat's token does not match the writer's, \
296 so it is another user's writer or PACKSET_HOME names another pack"
297 ));
298 }
299 if reason.is_empty() {
300 Error::Bad(format!("{url}: status code {code}"))
301 } else {
302 Error::Bad(format!("{url}: {code}: {reason}"))
303 }
304 }
305 other => Error::Http(Box::new(other)),
306 }
307}
308
309impl PacksetClient {
310 pub fn new(base: impl Into<String>) -> Self {
311 let mut base = base.into();
312 while base.ends_with('/') {
313 base.pop();
314 }
315 Self {
316 base,
317 workspace: None,
318 }
319 }
320
321 #[must_use]
325 pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
326 let workspace = workspace.into();
327 self.workspace = (!workspace.is_empty()).then_some(workspace);
328 self
329 }
330
331 pub fn from_env() -> Result<Self, Error> {
336 load_seat_env();
337 let url = env::var("PACKSET_URL")
338 .or_else(|_| env::var("INSIDE_MEMORY_URL"))
339 .ok()
340 .filter(|url| !url.is_empty());
341 match url {
342 Some(url) if url == "off" => Err(Error::NoUrl),
343 Some(url) => Ok(Self::new(url)),
344 None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
345 }
346 }
347
348 pub fn base(&self) -> &str {
349 &self.base
350 }
351
352 pub fn workspace(&self) -> String {
353 if let Some(w) = &self.workspace {
354 return w.clone();
355 }
356 if let Ok(w) = env::var("PACKSET_WORKSPACE") {
357 if !w.is_empty() {
358 return w;
359 }
360 }
361 let cwd = env::var("GROKOS_WORKSPACE")
362 .ok()
363 .map(|s| s.trim().to_string())
364 .filter(|s| !s.is_empty())
365 .map(std::path::PathBuf::from)
366 .or_else(|| env::current_dir().ok())
367 .unwrap_or_else(|| std::path::PathBuf::from("."));
368 self.workspace_for_cwd(&cwd)
369 }
370
371 pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
373 let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
374 let url = format!("{}/v1/identity", self.base);
375 let body = send(
376 authorize(ureq::get(&url)).query("cwd", abs.to_string_lossy().as_ref()),
377 None,
378 timeout(),
379 )
380 .ok()
381 .and_then(|r| r.into_string().ok());
382 if let Some(body) = body {
383 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
384 if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
385 if !ws.is_empty() {
386 return ws.to_string();
387 }
388 }
389 }
390 }
391 format!("dir:{}", abs.display())
392 }
393
394 pub fn health(&self) -> Result<String, Error> {
395 let url = format!("{}/health", self.base);
396 let body = send(authorize(ureq::get(&url)), None, timeout())
397 .map_err(|e| refused(&url, e))?
398 .into_string()?;
399 Ok(body)
400 }
401
402 pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
403 let encoded = path_seg(id);
404 let url = format!("{}/v1/atoms/{encoded}", self.base);
405 let resp = match send(
406 authorize(ureq::get(&url)).query("workspace", workspace),
407 None,
408 timeout(),
409 ) {
410 Ok(resp) => resp,
411 Err(e) if matches!(*e, ureq::Error::Status(404, _)) => {
412 return Err(Error::Bad(format!("no atom {id}")));
413 }
414 Err(e) => return Err(Error::Http(e)),
415 };
416 Ok(resp.into_json()?)
417 }
418
419 pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
420 self.atoms_as_of(workspace, None)
421 }
422
423 pub fn atoms_as_of(
429 &self,
430 workspace: &str,
431 as_of: Option<&str>,
432 ) -> Result<Vec<serde_json::Value>, Error> {
433 let url = format!("{}/v1/atoms", self.base);
434 let mut req = authorize(ureq::get(&url)).query("workspace", workspace);
435 if let Some(at) = as_of {
436 req = req.query("as_of", at);
437 }
438 let mut body: serde_json::Value = send(req, None, timeout())
439 .map_err(|e| refused(&url, e))?
440 .into_json()?;
441 let atoms = body
442 .get_mut("atoms")
443 .map(serde_json::Value::take)
444 .unwrap_or(serde_json::Value::Array(vec![]));
445 Ok(serde_json::from_value(atoms)?)
446 }
447
448 pub fn atoms_without_vectors(
456 &self,
457 workspace: &str,
458 as_of: Option<&str>,
459 ) -> Result<Vec<serde_json::Value>, Error> {
460 let url = format!("{}/v1/atoms", self.base);
461 let mut req = authorize(ureq::get(&url))
462 .query("workspace", workspace)
463 .query("embedding", "omit");
464 if let Some(at) = as_of {
465 req = req.query("as_of", at);
466 }
467 let mut body: serde_json::Value = send(req, None, timeout())
468 .map_err(|e| refused(&url, e))?
469 .into_json()?;
470 let atoms = body
471 .get_mut("atoms")
472 .map(serde_json::Value::take)
473 .unwrap_or(serde_json::Value::Array(vec![]));
474 Ok(serde_json::from_value(atoms)?)
475 }
476
477 pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
478 self.search_as_of(workspace, q, limit, None)
479 }
480
481 pub fn search_as_of(
487 &self,
488 workspace: &str,
489 q: &str,
490 limit: u32,
491 as_of: Option<&str>,
492 ) -> Result<Vec<Hit>, Error> {
493 self.search_opts(workspace, q, limit, as_of, false)
494 }
495
496 pub fn search_opts(
506 &self,
507 workspace: &str,
508 q: &str,
509 limit: u32,
510 as_of: Option<&str>,
511 rerank: bool,
512 ) -> Result<Vec<Hit>, Error> {
513 let url = format!("{}/v1/search", self.base);
514 let budget = if rerank {
515 Duration::from_secs(60).max(timeout())
516 } else {
517 timeout()
518 };
519 let mut req = authorize(ureq::get(&url))
520 .query("workspace", workspace)
521 .query("q", q)
522 .query("limit", &limit.to_string());
523 if let Some(at) = as_of {
524 req = req.query("as_of", at);
525 }
526 if rerank {
527 req = req.query("rerank", "1");
528 }
529 let body: serde_json::Value = send(req, None, budget)
530 .map_err(|e| refused(&url, e))?
531 .into_json()?;
532 let hits = body
533 .get("hits")
534 .cloned()
535 .unwrap_or(serde_json::Value::Array(vec![]));
536 Ok(serde_json::from_value(hits)?)
537 }
538
539 pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
545 let url = format!("{}/v1/status", self.base);
546 let mut req = authorize(ureq::get(&url));
547 if let Some(workspace) = workspace {
548 req = req.query("workspace", workspace);
549 }
550 Ok(send(req, None, timeout())
551 .map_err(|e| refused(&url, e))?
552 .into_json()?)
553 }
554
555 pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
561 let url = format!("{}/v1/pin", self.base);
562 Ok(send(
563 authorize(ureq::get(&url)).query("workspace", workspace),
564 None,
565 timeout(),
566 )
567 .map_err(|e| refused(&url, e))?
568 .into_json()?)
569 }
570
571 pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
577 let url = format!("{}/v1/pin", self.base);
578 Ok(send(
579 authorize(ureq::put(&url)),
580 Some(&serde_json::json!({ "workspace": workspace, "name": name })),
581 timeout(),
582 )
583 .map_err(|e| refused(&url, e))?
584 .into_json()?)
585 }
586
587 pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
596 let url = format!("{}/v1/accessions", self.base);
597 let body: serde_json::Value = send(
598 authorize(ureq::get(&url)).query("workspace", workspace),
599 None,
600 timeout(),
601 )
602 .map_err(|e| refused(&url, e))?
603 .into_json()?;
604 let found = body
605 .get("accessions")
606 .cloned()
607 .unwrap_or(serde_json::Value::Array(vec![]));
608 Ok(serde_json::from_value(found)?)
609 }
610 pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
619 self.atoms_as_of(workspace, None)
620 }
621
622 pub fn atoms_of_kind(
629 &self,
630 workspace: &str,
631 kind: &str,
632 ) -> Result<Vec<serde_json::Value>, Error> {
633 let url = format!("{}/v1/atoms", self.base);
634 let body: serde_json::Value = send(
635 authorize(ureq::get(&url))
636 .query("workspace", workspace)
637 .query("kind", kind),
638 None,
639 timeout(),
640 )
641 .map_err(|e| refused(&url, e))?
642 .into_json()?;
643 Ok(body
644 .get("atoms")
645 .and_then(serde_json::Value::as_array)
646 .cloned()
647 .unwrap_or_default())
648 }
649
650 pub fn citers(
656 &self,
657 workspace: &str,
658 accession: &str,
659 ) -> Result<Vec<serde_json::Value>, Error> {
660 let url = format!("{}/v1/citers", self.base);
661 let body: serde_json::Value = send(
662 authorize(ureq::get(&url))
663 .query("workspace", workspace)
664 .query("accession", accession),
665 None,
666 timeout(),
667 )
668 .map_err(|e| refused(&url, e))?
669 .into_json()?;
670 let found = body
671 .get("atoms")
672 .cloned()
673 .unwrap_or(serde_json::Value::Array(vec![]));
674 Ok(serde_json::from_value(found)?)
675 }
676
677 pub fn delete_atom(
690 &self,
691 workspace: &str,
692 id: &str,
693 why: Option<&str>,
694 ) -> Result<serde_json::Value, Error> {
695 let url = format!("{}/v1/atoms/delete", self.base);
696 let mut body = serde_json::json!({
697 "workspace": workspace,
698 "id": id,
699 });
700 if let Some(accession) = why {
701 body["why"] = serde_json::Value::String(accession.to_string());
702 }
703 let resp = match send(authorize(ureq::post(&url)), Some(&body), timeout()) {
704 Ok(resp) => resp,
705 Err(e) if matches!(*e, ureq::Error::Status(404, _)) => {
706 return Err(Error::Bad(format!("no atom {id}")));
707 }
708 Err(e) => return Err(refused(&url, e)),
709 };
710 Ok(resp.into_json()?)
711 }
712
713 pub fn grade(
715 &self,
716 workspace: &str,
717 id: &str,
718 recalled: bool,
719 ) -> Result<serde_json::Value, Error> {
720 let url = format!("{}/v1/grade", self.base);
721 let body: serde_json::Value = send(
722 authorize(ureq::post(&url)),
723 Some(&serde_json::json!({
724 "workspace": workspace,
725 "id": id,
726 "recalled": recalled,
727 })),
728 timeout(),
729 )
730 .map_err(|e| refused(&url, e))?
731 .into_json()?;
732 Ok(body)
733 }
734
735 pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
738 let url = format!("{}/v1/hubs", self.base);
739 let body: serde_json::Value = send(
740 authorize(ureq::get(&url))
741 .query("workspace", workspace)
742 .query("limit", &limit.to_string()),
743 None,
744 timeout(),
745 )
746 .map_err(|e| refused(&url, e))?
747 .into_json()?;
748 Ok(body)
749 }
750
751 pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
752 let url = format!("{}/v1/islands", self.base);
753 let body: serde_json::Value = send(
754 authorize(ureq::get(&url)).query("workspace", workspace),
755 None,
756 timeout(),
757 )
758 .map_err(|e| refused(&url, e))?
759 .into_json()?;
760 Ok(body)
761 }
762
763 pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
765 self.fire_as(workspace, ids, None)
766 }
767
768 pub fn fire_as(
775 &self,
776 workspace: &str,
777 ids: &[String],
778 lens: Option<&str>,
779 ) -> Result<serde_json::Value, Error> {
780 let url = format!("{}/v1/fire", self.base);
781 let body: serde_json::Value = send(
782 authorize(ureq::post(&url)),
783 Some(
784 &serde_json::json!({"workspace": workspace, "ids": ids, "as": lens.unwrap_or("")}),
785 ),
786 timeout(),
787 )
788 .map_err(|e| refused(&url, e))?
789 .into_json()?;
790 Ok(body)
791 }
792
793 pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
797 let url = format!("{}/v1/consolidate", self.base);
798 let body: serde_json::Value = send(
799 authorize(ureq::post(&url)),
800 Some(&serde_json::json!({"workspace": workspace, "apply": apply})),
801 timeout(),
802 )
803 .map_err(|e| refused(&url, e))?
804 .into_json()?;
805 Ok(body)
806 }
807
808 pub fn sweep(&self, workspace: &str) -> Result<serde_json::Value, Error> {
815 let url = format!("{}/v1/sweep", self.base);
816 let body: serde_json::Value = send(
817 authorize(ureq::post(&url)),
818 Some(&serde_json::json!({"workspace": workspace})),
819 timeout(),
820 )
821 .map_err(|e| refused(&url, e))?
822 .into_json()?;
823 Ok(body)
824 }
825
826 pub fn forget_workspace(&self, workspace: &str) -> Result<serde_json::Value, Error> {
834 let url = format!("{}/v1/forget", self.base);
835 let body: serde_json::Value = send(
836 authorize(ureq::post(&url)),
837 Some(&serde_json::json!({"workspace": workspace})),
838 timeout(),
839 )
840 .map_err(|e| refused(&url, e))?
841 .into_json()?;
842 Ok(body)
843 }
844
845 pub fn activate(
848 &self,
849 workspace: &str,
850 q: &str,
851 limit: u32,
852 fire: bool,
853 ) -> Result<serde_json::Value, Error> {
854 self.activate_as(workspace, q, limit, fire, None)
855 }
856
857 pub fn activate_as(
864 &self,
865 workspace: &str,
866 q: &str,
867 limit: u32,
868 fire: bool,
869 lens: Option<&str>,
870 ) -> Result<serde_json::Value, Error> {
871 let url = format!("{}/v1/activate", self.base);
872 let mut req = authorize(ureq::get(&url))
873 .query("workspace", workspace)
874 .query("q", q)
875 .query("limit", &limit.to_string())
876 .query("fire", if fire { "1" } else { "0" });
877 if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
878 req = req.query("as", name);
879 }
880 let body: serde_json::Value = send(req, None, timeout())
881 .map_err(|e| refused(&url, e))?
882 .into_json()?;
883 Ok(body)
884 }
885
886 pub fn atoms_in_set(
893 &self,
894 workspace: &str,
895 set: &str,
896 ) -> Result<Vec<serde_json::Value>, Error> {
897 let url = format!("{}/v1/pack", self.base);
898 let body: serde_json::Value = send(
899 authorize(ureq::get(&url))
900 .query("workspace", workspace)
901 .query("set", set),
902 None,
903 timeout(),
904 )
905 .map_err(|e| refused(&url, e))?
906 .into_json()?;
907 Ok(body
908 .get("atoms")
909 .and_then(serde_json::Value::as_array)
910 .cloned()
911 .unwrap_or_default())
912 }
913
914 pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
915 let url = format!("{}/v1/atoms", self.base);
916 let body: serde_json::Value = send(authorize(ureq::post(&url)), Some(atom), timeout())
917 .map_err(|e| refused(&url, e))?
918 .into_json()?;
919 Ok(body)
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
932
933 #[test]
934 fn only_a_loopback_url_has_a_port_to_check() {
935 assert_eq!(loopback_port("http://127.0.0.1:8761/health"), Some(8761));
936 assert_eq!(loopback_port("http://localhost:18462"), Some(18462));
937 assert_eq!(loopback_port("http://[::1]:8761/v1/search?q=a"), Some(8761));
938 assert_eq!(loopback_port("http://127.0.0.1/health"), Some(80));
939 assert_eq!(loopback_port("http://example.org:8761/"), None);
940 assert_eq!(loopback_port("http://[::1]/"), Some(80));
941 }
942
943 #[test]
944 fn the_owner_of_a_listening_port_is_read_off_the_table() {
945 let table = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
946 0: 0100007F:223A 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001 0 4242 1 0 100 0 0 10 0
947 1: 0100007F:223A 0100007F:9C40 01 00000000:00000000 00:00000000 00000000 1000 0 4343 1 0 20 4 30 10 -1
948 2: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 4444 1 0 100 0 0 10 0
949";
950 assert_eq!(listen_uids(table, 8762), vec![1001], "only the listener");
951 assert_eq!(listen_uids(table, 8080), vec![1000]);
952 assert!(listen_uids(table, 9).is_empty());
953 }
954
955 #[cfg(target_os = "linux")]
956 #[test]
957 fn a_port_this_user_holds_is_not_foreign() {
958 let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
959 let port = held.local_addr().unwrap().port();
960 assert!(!foreign_listener(&format!(
961 "http://127.0.0.1:{port}/health"
962 )));
963 drop(held);
964 assert!(
965 !foreign_listener(&format!("http://127.0.0.1:{port}/health")),
966 "nobody listening is not foreign"
967 );
968 }
969
970 #[test]
971 fn resolved_workspace_reads_ljos_env_not_default() {
972 let _env = ENV_LOCK.lock().unwrap();
973 let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
974 std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
975 std::fs::write(
976 dir.join(".config/ljos/env"),
977 "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
978 )
979 .unwrap();
980 let old_home = env::var("HOME").ok();
981 let old_ws = env::var("PACKSET_WORKSPACE").ok();
982 unsafe {
983 env::remove_var("PACKSET_WORKSPACE");
984 env::set_var("HOME", &dir);
985 }
986 let got = resolved_workspace();
987 unsafe {
988 match old_home {
989 Some(h) => env::set_var("HOME", h),
990 None => env::remove_var("HOME"),
991 }
992 match old_ws {
993 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
994 None => env::remove_var("PACKSET_WORKSPACE"),
995 }
996 }
997 assert_eq!(got, "git:example.com/seat/notes");
998 }
999
1000 #[test]
1001 fn resolved_workspace_without_env_is_seat_not_default() {
1002 let _env = ENV_LOCK.lock().unwrap();
1003 let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
1004 std::fs::create_dir_all(&dir).unwrap();
1005 let old_home = env::var("HOME").ok();
1006 let old_ws = env::var("PACKSET_WORKSPACE").ok();
1007 unsafe {
1008 env::remove_var("PACKSET_WORKSPACE");
1009 env::set_var("HOME", &dir);
1010 }
1011 let got = resolved_workspace();
1012 unsafe {
1013 match old_home {
1014 Some(h) => env::set_var("HOME", h),
1015 None => env::remove_var("HOME"),
1016 }
1017 match old_ws {
1018 Some(w) => env::set_var("PACKSET_WORKSPACE", w),
1019 None => env::remove_var("PACKSET_WORKSPACE"),
1020 }
1021 }
1022 assert_eq!(got, "seat");
1023 assert_ne!(got, "default");
1024 }
1025
1026 fn busy_for(
1029 busy: usize,
1030 ) -> (
1031 PacksetClient,
1032 std::sync::Arc<std::sync::atomic::AtomicUsize>,
1033 ) {
1034 use std::io::{BufRead, BufReader, Read, Write};
1035 use std::sync::atomic::Ordering;
1036 let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
1037 let client = PacksetClient::new(format!("http://{}", listener.local_addr().unwrap()));
1038 let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1039 let counted = std::sync::Arc::clone(&seen);
1040 std::thread::spawn(move || {
1041 for stream in listener.incoming().flatten() {
1042 let mut reader = BufReader::new(&stream);
1043 let mut length = 0;
1044 let mut line = String::new();
1045 while reader.read_line(&mut line).is_ok_and(|n| n > 2) {
1046 if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
1047 length = v.trim().parse().unwrap_or(0);
1048 }
1049 line.clear();
1050 }
1051 let mut body = vec![0u8; length];
1052 let _ = reader.read_exact(&mut body);
1053 let (status, text) = if counted.fetch_add(1, Ordering::SeqCst) < busy {
1054 ("503 Service Unavailable", r#"{"error":"busy"}"#)
1055 } else {
1056 ("200 OK", r#"{"ok":true}"#)
1057 };
1058 let _ = (&stream).write_all(
1059 format!(
1060 "HTTP/1.1 {status}\r\nRetry-After: 1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
1061 text.len()
1062 )
1063 .as_bytes(),
1064 );
1065 }
1066 });
1067 (client, seen)
1068 }
1069
1070 #[test]
1071 fn a_busy_writer_is_asked_again_for_a_read_and_a_write() {
1072 use std::sync::atomic::Ordering;
1073 let _env = ENV_LOCK.lock().unwrap();
1074 let (client, seen) = busy_for(2);
1075 assert_eq!(client.status(None).unwrap()["ok"], true);
1076 assert_eq!(seen.load(Ordering::SeqCst), 3);
1077 let (client, seen) = busy_for(1);
1078 let stored = client
1079 .post_atom(&serde_json::json!({"text": "one write", "workspace": "w"}))
1080 .unwrap();
1081 assert_eq!(stored["ok"], true);
1082 assert_eq!(
1083 seen.load(Ordering::SeqCst),
1084 2,
1085 "the write was not sent again"
1086 );
1087 }
1088
1089 #[test]
1090 fn a_writer_busy_past_three_retries_is_a_refusal() {
1091 use std::sync::atomic::Ordering;
1092 let _env = ENV_LOCK.lock().unwrap();
1093 let (client, seen) = busy_for(usize::MAX);
1094 let err = client.status(None).unwrap_err().to_string();
1095 assert!(err.contains("503") && err.contains("busy"), "{err}");
1096 assert_eq!(seen.load(Ordering::SeqCst), 4);
1097 }
1098
1099 #[test]
1101 fn the_retries_stop_where_the_budget_does() {
1102 use std::sync::atomic::Ordering;
1103 let _env = ENV_LOCK.lock().unwrap();
1104 let old = env::var_os("PACKSET_TIMEOUT_MS");
1105 unsafe { env::set_var("PACKSET_TIMEOUT_MS", "100") };
1106 let (client, seen) = busy_for(usize::MAX);
1107 let started = Instant::now();
1108 let answered = client.status(None);
1109 let took = started.elapsed();
1110 unsafe {
1111 match old {
1112 Some(v) => env::set_var("PACKSET_TIMEOUT_MS", v),
1113 None => env::remove_var("PACKSET_TIMEOUT_MS"),
1114 }
1115 }
1116 assert!(answered.is_err());
1117 assert_eq!(seen.load(Ordering::SeqCst), 2);
1118 assert!(took < Duration::from_millis(300), "{took:?}");
1119 }
1120}