Skip to main content

ljos_cli/
pack_link.rs

1//! The only speaker that may carry this seat's packsetd token.
2//!
3//! The token stays in this module. Each request is one byte stream to the
4//! socket address resolved for the writer's URL, with no proxy and no
5//! redirect. The proof runs before the token is written on that stream. A
6//! stream that closes is not remembered: the next call proves again before
7//! the token goes out. packsetd answers one request and closes, so the two
8//! refusals and the call that carries the token are three sockets in a row;
9//! the proof is not kept for a later call.
10
11use std::collections::HashMap;
12use std::io::{Read, Write};
13use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use std::sync::{Arc, Mutex, OnceLock};
16use std::time::{Duration, Instant};
17
18use packset_client::{Error, Hit};
19use serde::de::DeserializeOwned;
20use serde_json::Value;
21use url::Url;
22
23/// The seat's pack writer. The token field is private to this module.
24pub struct PacksetClient {
25    base: String,
26    origin: Url,
27    addr: Option<SocketAddr>,
28    workspace: Option<String>,
29    token: Mutex<Option<String>>,
30}
31
32impl Clone for PacksetClient {
33    fn clone(&self) -> Self {
34        Self {
35            base: self.base.clone(),
36            origin: self.origin.clone(),
37            addr: self.addr,
38            workspace: self.workspace.clone(),
39            token: Mutex::new(self.token.lock().unwrap_or_else(|p| p.into_inner()).clone()),
40        }
41    }
42}
43
44/// One answer. `trusted` means the token was on this answer, after the
45/// proof held.
46#[derive(Debug, Clone)]
47pub(crate) struct Answer {
48    pub status: u16,
49    pub body: String,
50    pub trusted: bool,
51}
52
53/// Answers a hook process already has for a search or a status read, by
54/// request, for [`ANSWER_MEMO`]. A prompt hook asks the same search
55/// twice and the status twice; the second asks are answered here. Only a
56/// hook keeps one, and any write through the client drops it.
57type KeptAnswer = (String, Instant, Answer);
58static KEPT_ANSWERS: Mutex<Vec<KeptAnswer>> = Mutex::new(Vec::new());
59const ANSWER_MEMO: Duration = Duration::from_secs(2);
60
61fn answer_memo_key(
62    method: &str,
63    path: &str,
64    query: &[(&str, &str)],
65    bearer: bool,
66) -> Option<String> {
67    if method != "GET" || !matches!(path, "/v1/search" | "/v1/status") {
68        return None;
69    }
70    std::env::var_os("LJOS_IN_HOOK")?;
71    let mut key = format!("{path}|{bearer}");
72    for (k, v) in query {
73        key.push('|');
74        key.push_str(k);
75        key.push('=');
76        key.push_str(v);
77    }
78    Some(key)
79}
80
81fn kept_answer(key: &str) -> Option<Answer> {
82    let kept = KEPT_ANSWERS.lock().unwrap_or_else(|p| p.into_inner());
83    kept.iter()
84        .find(|(k, at, _)| k == key && at.elapsed() < ANSWER_MEMO)
85        .map(|(_, _, answer)| answer.clone())
86}
87
88fn keep_answer(key: String, answer: &Answer) {
89    let mut kept = KEPT_ANSWERS.lock().unwrap_or_else(|p| p.into_inner());
90    kept.retain(|(k, _, _)| k != &key);
91    kept.push((key, Instant::now(), answer.clone()));
92}
93
94/// Drop every kept answer. Called on any write through the client.
95pub(crate) fn forget_kept_answers() {
96    KEPT_ANSWERS
97        .lock()
98        .unwrap_or_else(|p| p.into_inner())
99        .clear();
100}
101
102struct Slot {
103    stream: Option<TcpStream>,
104    /// The proof held on `stream` and the stream is still the one it held on.
105    proven: bool,
106    token_tag: u64,
107    /// The last answer on this socket carried the token.
108    carried: bool,
109}
110
111fn slots() -> &'static Mutex<HashMap<SocketAddr, Arc<Mutex<Slot>>>> {
112    static SLOTS: OnceLock<Mutex<HashMap<SocketAddr, Arc<Mutex<Slot>>>>> = OnceLock::new();
113    SLOTS.get_or_init(|| Mutex::new(HashMap::new()))
114}
115
116fn slot_for(addr: SocketAddr) -> Arc<Mutex<Slot>> {
117    let mut map = slots().lock().unwrap_or_else(|p| p.into_inner());
118    map.entry(addr)
119        .or_insert_with(|| {
120            Arc::new(Mutex::new(Slot {
121                stream: None,
122                proven: false,
123                token_tag: 0,
124                carried: false,
125            }))
126        })
127        .clone()
128}
129
130fn load_token() -> Option<String> {
131    packset_client::token()
132}
133
134fn token_tag(token: Option<&str>) -> u64 {
135    let Some(token) = token else {
136        return 0;
137    };
138    let mut hash = 0xcbf29ce484222325u64;
139    for byte in token.as_bytes() {
140        hash ^= u64::from(*byte);
141        hash = hash.wrapping_mul(0x100000001b3);
142    }
143    hash
144}
145
146fn nonce() -> Option<String> {
147    use std::io::Read as _;
148    let mut raw = [0u8; 32];
149    std::fs::File::open("/dev/urandom")
150        .and_then(|mut file| file.read_exact(&mut raw))
151        .ok()
152        .map(|()| hex::encode(raw))
153}
154
155fn pin(url: &Url) -> Option<SocketAddr> {
156    let host = url.host_str()?;
157    let port = url.port_or_known_default()?;
158    (host, port).to_socket_addrs().ok()?.next()
159}
160
161fn host_header(url: &Url) -> String {
162    match (url.host_str(), url.port()) {
163        (Some(host), Some(port)) if host.contains(':') => format!("[{host}]:{port}"),
164        (Some(host), Some(port)) => format!("{host}:{port}"),
165        (Some(host), None) => host.to_string(),
166        _ => "localhost".to_string(),
167    }
168}
169
170fn budget() -> Duration {
171    let base = crate::pack_budget_ms()
172        .or_else(|| {
173            std::env::var("PACKSET_TIMEOUT_MS")
174                .ok()
175                .and_then(|v| v.trim().parse::<u64>().ok())
176                .filter(|ms| *ms > 0)
177        })
178        .map_or(Duration::from_secs(30), Duration::from_millis);
179    match crate::wall_left() {
180        Some(left) => base.min(left).max(Duration::from_millis(1)),
181        None => base,
182    }
183}
184
185struct Clock {
186    deadline: Instant,
187    depth: u32,
188}
189
190static CLOCK: Mutex<Option<Clock>> = Mutex::new(None);
191
192/// Bound every pack socket call until this guard drops.
193/// A nested guard keeps the earlier deadline, so a hook's budget is not
194/// stretched by a later mail read.
195pub struct PackClock;
196
197impl PackClock {
198    #[must_use]
199    pub fn enter(total: Duration) -> Self {
200        let mut slot = CLOCK.lock().unwrap_or_else(|p| p.into_inner());
201        match slot.as_mut() {
202            Some(clock) => clock.depth = clock.depth.saturating_add(1),
203            None => {
204                *slot = Some(Clock {
205                    deadline: Instant::now() + total,
206                    depth: 1,
207                });
208            }
209        }
210        Self
211    }
212}
213
214impl Drop for PackClock {
215    fn drop(&mut self) {
216        let mut slot = CLOCK.lock().unwrap_or_else(|p| p.into_inner());
217        if let Some(clock) = slot.as_mut() {
218            clock.depth = clock.depth.saturating_sub(1);
219            if clock.depth == 0 {
220                *slot = None;
221            }
222        }
223    }
224}
225
226fn clock_deadline() -> Option<Instant> {
227    CLOCK
228        .lock()
229        .unwrap_or_else(|p| p.into_inner())
230        .as_ref()
231        .map(|clock| clock.deadline)
232}
233
234pub(crate) fn clock_armed() -> bool {
235    clock_deadline().is_some()
236}
237
238/// Time left on the armed pack clock. `None` when no clock is armed.
239pub(crate) fn clock_left() -> Option<Duration> {
240    clock_deadline().map(|deadline| deadline.saturating_duration_since(Instant::now()))
241}
242
243/// A search or island read in this clock already went quiet, or the clock
244/// is spent. Later reads must not wait it out again.
245pub(crate) fn search_stalled() -> bool {
246    pack_quiet() || clock_deadline().is_some_and(|deadline| Instant::now() >= deadline)
247}
248
249pub(crate) fn error_is_timeout(err: &Error) -> bool {
250    match err {
251        Error::Io(io) => io_is_quiet(io) || io.to_string().contains("the pack did not answer"),
252        _ => false,
253    }
254}
255
256/// Whether every socket listening on the loopback port of `url` belongs to
257/// another user. Unknown is not foreign.
258fn foreign(url: &str) -> bool {
259    // Answered once per process for each url: the socket tables are read
260    // in full each time, and a hook connects several times.
261    static KNOWN: std::sync::LazyLock<Mutex<Vec<(String, bool)>>> =
262        std::sync::LazyLock::new(|| Mutex::new(Vec::new()));
263    if let Some((_, known)) = KNOWN
264        .lock()
265        .unwrap_or_else(|p| p.into_inner())
266        .iter()
267        .find(|(u, _)| u == url)
268    {
269        return *known;
270    }
271    let answer = foreign_read(url);
272    KNOWN
273        .lock()
274        .unwrap_or_else(|p| p.into_inner())
275        .push((url.to_string(), answer));
276    answer
277}
278
279fn foreign_read(url: &str) -> bool {
280    #[cfg(target_os = "linux")]
281    {
282        use std::os::unix::fs::MetadataExt;
283        let Some(port) = loopback_port(url) else {
284            return false;
285        };
286        let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
287            return false;
288        };
289        let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
290            .iter()
291            .filter_map(|table| std::fs::read_to_string(table).ok())
292            .flat_map(|text| listen_uids(&text, port))
293            .collect();
294        !owners.is_empty() && owners.iter().all(|uid| *uid != me)
295    }
296    #[cfg(not(target_os = "linux"))]
297    {
298        let _ = url;
299        false
300    }
301}
302
303#[cfg(target_os = "linux")]
304fn loopback_port(url: &str) -> Option<u16> {
305    let rest = url
306        .strip_prefix("http://")
307        .or_else(|| url.strip_prefix("https://"))?;
308    let authority = rest.split(['/', '?', '#']).next()?;
309    let (host, port) = match authority.rsplit_once(':') {
310        Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
311        _ => (authority, 80),
312    };
313    matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
314}
315
316#[cfg(target_os = "linux")]
317fn listen_uids(table: &str, port: u16) -> Vec<u32> {
318    table
319        .lines()
320        .skip(1)
321        .filter_map(|line| {
322            let fields: Vec<&str> = line.split_whitespace().collect();
323            if fields.get(3) != Some(&"0A") {
324                return None;
325            }
326            let hex = fields.get(1)?.rsplit(':').next()?;
327            if u16::from_str_radix(hex, 16).ok()? != port {
328                return None;
329            }
330            fields.get(7)?.parse().ok()
331        })
332        .collect()
333}
334
335fn target(path: &str, query: &[(&str, &str)]) -> String {
336    if query.is_empty() {
337        return path.to_string();
338    }
339    let mut ser = url::form_urlencoded::Serializer::new(String::new());
340    for (key, value) in query {
341        ser.append_pair(key, value);
342    }
343    format!("{path}?{}", ser.finish())
344}
345
346struct Raw {
347    status: u16,
348    body: String,
349    keep: bool,
350}
351
352/// The pack accepted a connection and then stayed silent, or the connect
353/// itself never finished. While a [`QuietPack`] guard is alive, the first
354/// such read still waits out its budget and a later read returns before it
355/// opens another socket. A consensus reading asks several times. Without
356/// the stop, each ask waited the whole budget and the command sat for the
357/// sum. Dropping the guard clears it, so a later call can try the pack again.
358static PACK_QUIET: AtomicBool = AtomicBool::new(false);
359static QUIET_HELD: AtomicUsize = AtomicUsize::new(0);
360
361/// Remember one quiet pack for the reads this guard covers.
362pub struct QuietPack;
363
364impl QuietPack {
365    /// Start remembering a silent pack. The first guard of a pile drops a
366    /// quiet read already seen, so the pile starts with a fresh try.
367    #[must_use]
368    pub fn enter() -> Self {
369        if QUIET_HELD.fetch_add(1, Ordering::AcqRel) == 0 {
370            PACK_QUIET.store(false, Ordering::Relaxed);
371        }
372        Self
373    }
374}
375
376impl Drop for QuietPack {
377    fn drop(&mut self) {
378        if QUIET_HELD.fetch_sub(1, Ordering::AcqRel) == 1 {
379            PACK_QUIET.store(false, Ordering::Relaxed);
380        }
381    }
382}
383
384fn holding_quiet() -> bool {
385    QUIET_HELD.load(Ordering::Relaxed) > 0
386}
387
388fn pack_quiet() -> bool {
389    holding_quiet() && PACK_QUIET.load(Ordering::Relaxed)
390}
391
392fn mark_pack_quiet() {
393    if holding_quiet() {
394        PACK_QUIET.store(true, Ordering::Relaxed);
395    }
396}
397
398fn quiet_io() -> std::io::Error {
399    std::io::Error::new(std::io::ErrorKind::TimedOut, "the pack did not answer")
400}
401
402/// A read or a connect that ended because the socket stayed silent.
403/// A refused connect is not this: the writer may still be starting.
404fn io_is_quiet(err: &std::io::Error) -> bool {
405    matches!(
406        err.kind(),
407        std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
408    )
409}
410
411fn connect(addr: SocketAddr, timeout: Duration) -> std::io::Result<TcpStream> {
412    if pack_quiet() {
413        return Err(quiet_io());
414    }
415    if side_budget_spent() {
416        return Err(budget_spent());
417    }
418    let started = Instant::now();
419    let result = connect_once(addr, timeout);
420    // A connect that returns a socket is the pack accepting. Time spent
421    // there is not a quiet wait. A connect that fails spends the budget.
422    // A connect that never finishes is remembered, so the next read does
423    // not wait the budget out again.
424    if let Err(err) = &result {
425        if io_is_quiet(err) {
426            mark_pack_quiet();
427        }
428        super::charge_side_wait(started.elapsed());
429    }
430    result
431}
432
433/// A side event's pack budget is gone. Opening a socket for a one
434/// millisecond timer still blocks, and a prompt makes several of those
435/// reads, so the call returns before the connect.
436fn side_budget_spent() -> bool {
437    super::side_remaining_ms() == Some(0)
438}
439
440fn budget_spent() -> std::io::Error {
441    std::io::Error::new(
442        std::io::ErrorKind::TimedOut,
443        "the side event's pack budget is spent",
444    )
445}
446
447fn connect_once(addr: SocketAddr, timeout: Duration) -> std::io::Result<TcpStream> {
448    let wait = bounded_wait(timeout, true)?;
449    let stream = TcpStream::connect_timeout(&addr, wait)?;
450    let io_wait = bounded_wait(timeout, false)?;
451    stream.set_read_timeout(Some(io_wait))?;
452    stream.set_write_timeout(Some(io_wait))?;
453    let _ = stream.set_nodelay(true);
454    Ok(stream)
455}
456
457/// The wait a socket call may use. With a [`PackClock`], a connect is
458/// capped by [`super::pack_connect_budget`] and every call stops at the
459/// clock. Past the clock the call returns before it opens a socket.
460fn bounded_wait(requested: Duration, connect: bool) -> std::io::Result<Duration> {
461    let mut wait = capped_wait(requested);
462    if let Some(deadline) = clock_deadline() {
463        let left = deadline.saturating_duration_since(Instant::now());
464        if left.is_zero() {
465            return Err(std::io::Error::new(
466                std::io::ErrorKind::TimedOut,
467                "the pack did not answer",
468            ));
469        }
470        wait = wait.min(left);
471        if connect {
472            wait = wait.min(super::pack_connect_budget());
473        }
474        if wait.is_zero() {
475            return Err(std::io::Error::new(
476                std::io::ErrorKind::TimedOut,
477                "the pack did not answer",
478            ));
479        }
480    }
481    Ok(wait)
482}
483
484/// A side-event bound shrinks this wait. A zero bound is one millisecond,
485/// so a later read fails at once instead of taking the default.
486fn capped_wait(timeout: Duration) -> Duration {
487    let wait = super::cap_pack_wait(timeout);
488    if wait.is_zero() {
489        Duration::from_millis(1)
490    } else {
491        wait
492    }
493}
494
495fn write_all(stream: &mut TcpStream, bytes: &[u8]) -> std::io::Result<()> {
496    let mut sent = 0;
497    while sent < bytes.len() {
498        match stream.write(&bytes[sent..]) {
499            Ok(0) => {
500                return Err(std::io::Error::new(
501                    std::io::ErrorKind::WriteZero,
502                    "the pack closed the socket",
503                ))
504            }
505            Ok(n) => sent += n,
506            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
507            Err(err) => return Err(err),
508        }
509    }
510    stream.flush()
511}
512
513fn read_response(stream: &mut TcpStream, deadline: Instant) -> std::io::Result<Raw> {
514    let mut buf = Vec::new();
515    let mut tmp = [0u8; 4096];
516    let head_end = loop {
517        if buf.len() > 64 * 1024 {
518            return Err(std::io::Error::new(
519                std::io::ErrorKind::InvalidData,
520                "the pack's headers are too long",
521            ));
522        }
523        arm_read(stream, deadline)?;
524        let n = read_some(stream, &mut tmp)?;
525        buf.extend_from_slice(&tmp[..n]);
526        if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
527            break at;
528        }
529    };
530    let header = String::from_utf8_lossy(&buf[..head_end]).into_owned();
531    let status = header
532        .lines()
533        .next()
534        .and_then(|line| line.split_whitespace().nth(1))
535        .and_then(|code| code.parse().ok())
536        .ok_or_else(|| {
537            std::io::Error::new(
538                std::io::ErrorKind::InvalidData,
539                "the pack's status line is not HTTP",
540            )
541        })?;
542    let mut length = None;
543    let mut close = header.starts_with("HTTP/1.0");
544    for line in header.lines().skip(1) {
545        let Some((name, value)) = line.split_once(':') else {
546            continue;
547        };
548        if name.eq_ignore_ascii_case("content-length") {
549            length = value.trim().parse().ok();
550        } else if name.eq_ignore_ascii_case("connection")
551            && value.trim().eq_ignore_ascii_case("close")
552        {
553            close = true;
554        }
555    }
556    let mut body = buf.split_off(head_end + 4);
557    if let Some(length) = length {
558        while body.len() < length {
559            arm_read(stream, deadline)?;
560            let n = read_some(stream, &mut tmp)?;
561            body.extend_from_slice(&tmp[..n]);
562            if body.len() > length.saturating_add(64 * 1024) {
563                break;
564            }
565        }
566        body.truncate(length);
567    } else {
568        loop {
569            if deadline.saturating_duration_since(Instant::now()).is_zero() {
570                break;
571            }
572            let _ = arm_read(stream, deadline);
573            match stream.read(&mut tmp) {
574                Ok(0) => break,
575                Ok(n) => body.extend_from_slice(&tmp[..n]),
576                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
577                Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
578                Err(err) if err.kind() == std::io::ErrorKind::TimedOut => break,
579                Err(_) => break,
580            }
581        }
582        close = true;
583    }
584    // A redirect is the answer. The token must not follow it.
585    Ok(Raw {
586        status,
587        body: String::from_utf8_lossy(&body).into_owned(),
588        keep: !close && !(300..400).contains(&status),
589    })
590}
591
592fn arm_read(stream: &TcpStream, deadline: Instant) -> std::io::Result<()> {
593    let remain = deadline.saturating_duration_since(Instant::now());
594    if remain.is_zero() {
595        return Err(std::io::Error::new(
596            std::io::ErrorKind::TimedOut,
597            "the pack did not answer",
598        ));
599    }
600    stream.set_read_timeout(Some(remain))?;
601    Ok(())
602}
603
604fn read_some(stream: &mut TcpStream, tmp: &mut [u8]) -> std::io::Result<usize> {
605    loop {
606        match stream.read(tmp) {
607            Ok(0) => {
608                return Err(std::io::Error::new(
609                    std::io::ErrorKind::UnexpectedEof,
610                    "the pack closed the socket",
611                ))
612            }
613            Ok(n) => return Ok(n),
614            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
615            Err(err) => return Err(err),
616        }
617    }
618}
619
620fn render(
621    method: &str,
622    host: &str,
623    path: &str,
624    token: Option<&str>,
625    body: Option<&[u8]>,
626) -> Vec<u8> {
627    let mut head =
628        format!("{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: keep-alive\r\n");
629    if let Some(token) = token {
630        head.push_str("Authorization: Bearer ");
631        head.push_str(token);
632        head.push_str("\r\n");
633    }
634    let mut out = Vec::new();
635    if let Some(body) = body {
636        head.push_str("Content-Type: application/json\r\n");
637        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
638        out.extend(head.into_bytes());
639        out.extend_from_slice(body);
640    } else {
641        head.push_str("\r\n");
642        out.extend(head.into_bytes());
643    }
644    out
645}
646
647struct Wire<'a> {
648    method: &'a str,
649    path: &'a str,
650    query: &'a [(&'a str, &'a str)],
651    body: Option<&'a [u8]>,
652    token: Option<&'a str>,
653}
654
655enum Gate {
656    /// The proof held. The call may carry the token.
657    Send,
658    /// The listener did not ask for the token.
659    Bare,
660    /// The writer is busy. The call is not sent.
661    Busy(Raw),
662}
663
664impl PacksetClient {
665    pub fn new(base: impl Into<String>) -> Self {
666        let mut base = base.into();
667        while base.ends_with('/') {
668            base.pop();
669        }
670        let origin = Url::parse(&base).unwrap_or_else(|_| Url::parse("http://127.0.0.1/").unwrap());
671        let addr = Url::parse(&base).ok().as_ref().and_then(pin);
672        Self {
673            base,
674            origin,
675            addr,
676            workspace: None,
677            token: Mutex::new(load_token()),
678        }
679    }
680
681    /// The writer the seat talks to. `PACKSET_URL=off` is no pack.
682    pub fn from_env() -> Result<Self, Error> {
683        packset_client::load_seat_env();
684        let url = std::env::var("PACKSET_URL")
685            .or_else(|_| std::env::var("INSIDE_MEMORY_URL"))
686            .ok()
687            .filter(|url| !url.is_empty());
688        match url {
689            Some(url) if url == "off" => Err(Error::NoUrl),
690            Some(url) => Ok(Self::new(url)),
691            None => Ok(Self::new(format!(
692                "http://127.0.0.1:{}",
693                packset_client::default_port()
694            ))),
695        }
696    }
697
698    #[must_use]
699    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
700        let workspace = workspace.into();
701        self.workspace = (!workspace.is_empty()).then_some(workspace);
702        self
703    }
704
705    pub fn base(&self) -> &str {
706        &self.base
707    }
708
709    /// Whether this seat has a token to show. The token itself stays here.
710    #[must_use]
711    pub(crate) fn has_token(&self) -> bool {
712        load_token().is_some()
713    }
714
715    /// Whether a call may carry the token: the seat has one, and another
716    /// user does not hold the loopback port.
717    #[must_use]
718    pub(crate) fn may_carry(&self) -> bool {
719        load_token().is_some() && !foreign(&self.base)
720    }
721
722    /// Whether the last answer on this socket carried the token. A closed
723    /// socket does not keep this for the next call: that call proves again.
724    #[must_use]
725    pub(crate) fn carried(&self) -> bool {
726        let Some(addr) = self.addr else {
727            return false;
728        };
729        let map = slots().lock().unwrap_or_else(|p| p.into_inner());
730        map.get(&addr)
731            .is_some_and(|slot| slot.lock().unwrap_or_else(|p| p.into_inner()).carried)
732    }
733
734    pub fn workspace(&self) -> String {
735        if let Some(workspace) = &self.workspace {
736            return workspace.clone();
737        }
738        if let Ok(workspace) = std::env::var("PACKSET_WORKSPACE") {
739            if !workspace.is_empty() {
740                return workspace;
741            }
742        }
743        let cwd = std::env::var("HOSTPACK_WORKSPACE")
744            .ok()
745            .map(|s| s.trim().to_string())
746            .filter(|s| !s.is_empty())
747            .map(std::path::PathBuf::from)
748            .or_else(|| std::env::current_dir().ok())
749            .unwrap_or_else(|| std::path::PathBuf::from("."));
750        let abs = cwd.canonicalize().unwrap_or(cwd);
751        let shown = abs.to_string_lossy().into_owned();
752        if let Ok(answer) = self.roundtrip(
753            "GET",
754            "/v1/identity",
755            &[("cwd", shown.as_str())],
756            None,
757            true,
758            budget(),
759        ) {
760            if answer.status == 200 {
761                if let Ok(val) = serde_json::from_str::<Value>(&answer.body) {
762                    if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
763                        if !ws.is_empty() {
764                            return ws.to_string();
765                        }
766                    }
767                }
768            }
769        }
770        format!("dir:{}", abs.display())
771    }
772
773    /// One request. `bearer` asks to carry the token; it is left off when
774    /// the proof does not hold or another user owns the port. A 3xx is
775    /// returned as the answer and not followed. HTTP 503 is one answer:
776    /// the caller decides whether to ask again.
777    pub(crate) fn roundtrip(
778        &self,
779        method: &str,
780        path: &str,
781        query: &[(&str, &str)],
782        body: Option<&[u8]>,
783        bearer: bool,
784        timeout: Duration,
785    ) -> Result<Answer, Error> {
786        // A write changes what a listing would say: the memo this process
787        // keeps and the gate's freshness mark both go.
788        if method != "GET" {
789            crate::forget_atom_listings();
790            crate::forget_gate_fresh();
791            forget_kept_answers();
792        }
793        let memo_key = answer_memo_key(method, path, query, bearer);
794        if let Some(answer) = memo_key.as_deref().and_then(kept_answer) {
795            return Ok(answer);
796        }
797        let Some(addr) = self.addr else {
798            return Err(Error::Bad(format!(
799                "{} is not a socket this seat can pin",
800                self.base
801            )));
802        };
803        let token = load_token();
804        *self.token.lock().unwrap_or_else(|p| p.into_inner()) = token.clone();
805        let allow = bearer && token.is_some() && !foreign(&self.base);
806        let answer = self.exchange(
807            addr,
808            &Wire {
809                method,
810                path,
811                query,
812                body,
813                token: token.as_deref(),
814            },
815            allow,
816            timeout,
817        )?;
818        if let Some(key) = memo_key {
819            if (200..300).contains(&answer.status) {
820                keep_answer(key, &answer);
821            }
822        }
823        Ok(answer)
824    }
825
826    fn exchange(
827        &self,
828        addr: SocketAddr,
829        call: &Wire<'_>,
830        allow: bool,
831        timeout: Duration,
832    ) -> Result<Answer, Error> {
833        // The proof and the call share this budget. A nested guard keeps
834        // the command's earlier deadline, so a hook stays under 2s and an
835        // interactive command under 5s across every socket of the call.
836        // A restart does not start the budget over.
837        let _budget = PackClock::enter(timeout);
838        let slot = slot_for(addr);
839        let mut live = slot.lock().unwrap_or_else(|p| p.into_inner());
840        let tag = token_tag(call.token);
841        if live.token_tag != tag {
842            live.stream = None;
843            live.proven = false;
844            live.token_tag = tag;
845        }
846        let carried = if allow {
847            match self.gate(&mut live, addr, timeout)? {
848                Gate::Bare => None,
849                Gate::Send => call.token,
850                Gate::Busy(raw) => {
851                    live.carried = false;
852                    live.proven = false;
853                    return Ok(Answer {
854                        status: raw.status,
855                        body: raw.body,
856                        trusted: false,
857                    });
858                }
859            }
860        } else {
861            None
862        };
863        if live.stream.is_none() {
864            match connect(addr, timeout) {
865                Ok(stream) => live.stream = Some(stream),
866                Err(err) => {
867                    live.proven = false;
868                    live.carried = false;
869                    return Err(Error::Io(err));
870                }
871            }
872            // The probes ran on sockets the writer closed. This socket is
873            // the one the token rides, the third step of the same proof,
874            // not a later call.
875            live.proven = false;
876        }
877        let raw = match send(
878            &mut live,
879            &self.origin,
880            &Wire {
881                token: carried,
882                ..*call
883            },
884            timeout,
885        ) {
886            Ok(raw) => raw,
887            Err(err) => {
888                live.stream = None;
889                live.proven = false;
890                live.carried = false;
891                return Err(Error::Io(err));
892            }
893        };
894        let trusted = carried.is_some();
895        live.carried = trusted;
896        if trusted && live.stream.is_some() {
897            live.proven = true;
898        } else if live.stream.is_none() {
899            live.proven = false;
900        }
901        Ok(Answer {
902            status: raw.status,
903            body: raw.body,
904            trusted,
905        })
906    }
907
908    /// Two refusals on this socket, then the call may carry the token.
909    /// A busy writer (503) is returned as that answer; the call is not sent.
910    fn gate(&self, live: &mut Slot, addr: SocketAddr, timeout: Duration) -> Result<Gate, Error> {
911        if live.proven && live.stream.is_some() {
912            return Ok(Gate::Send);
913        }
914        live.proven = false;
915        let (Some(id), Some(bogus)) = (nonce(), nonce()) else {
916            return Err(Error::Io(std::io::Error::other(
917                "no randomness for the pack proof",
918            )));
919        };
920        let workspace = self.proof_workspace();
921        let path = format!("/v1/atoms/{}", &id[..32]);
922        let query = [("workspace", workspace.as_str())];
923        let mut stage = 0u8;
924        let mut restarts = 0u8;
925        loop {
926            if live.stream.is_none() {
927                live.stream = Some(connect(addr, timeout).map_err(Error::Io)?);
928                live.proven = false;
929            }
930            let shown = if stage == 0 {
931                None
932            } else {
933                Some(bogus.as_str())
934            };
935            let raw = match send(
936                live,
937                &self.origin,
938                &Wire {
939                    method: "GET",
940                    path: &path,
941                    query: &query,
942                    body: None,
943                    token: shown,
944                },
945                timeout,
946            ) {
947                Ok(raw) => raw,
948                Err(err) => {
949                    live.stream = None;
950                    live.proven = false;
951                    // A silent writer is the answer. Another socket would
952                    // wait the budget out again, and three of those is a
953                    // minute and a half. A closed socket may be tried
954                    // again, inside the same deadline.
955                    if io_is_quiet(&err) {
956                        return Err(Error::Io(err));
957                    }
958                    restarts += 1;
959                    let spent = clock_deadline().is_some_and(|deadline| Instant::now() >= deadline);
960                    if spent || restarts > 2 {
961                        return Err(Error::Io(err));
962                    }
963                    stage = 0;
964                    continue;
965                }
966            };
967            if raw.status == 503 {
968                return Ok(Gate::Busy(raw));
969            }
970            if raw.status != 401 {
971                return Ok(Gate::Bare);
972            }
973            if stage == 0 {
974                stage = 1;
975                continue;
976            }
977            return Ok(Gate::Send);
978        }
979    }
980
981    fn proof_workspace(&self) -> String {
982        if let Some(workspace) = &self.workspace {
983            return workspace.clone();
984        }
985        std::env::var("PACKSET_WORKSPACE")
986            .ok()
987            .filter(|w| !w.is_empty())
988            .unwrap_or_else(|| "seat".to_string())
989    }
990
991    /// [`Self::roundtrip`] again while the writer answers busy, inside `timeout`.
992    fn tried(
993        &self,
994        method: &str,
995        path: &str,
996        query: &[(&str, &str)],
997        body: Option<&[u8]>,
998        timeout: Duration,
999    ) -> Result<Answer, Error> {
1000        // One try. A busy or silent writer is the caller's answer, not
1001        // a reason to wait and ask again.
1002        if clock_armed() {
1003            return self.roundtrip(method, path, query, body, true, timeout);
1004        }
1005        let started = Instant::now();
1006        let mut wait = Duration::from_millis(50);
1007        for n in 0..4 {
1008            let answer = self.roundtrip(method, path, query, body, true, timeout)?;
1009            if answer.status == 503 && n < 3 && started.elapsed() + wait < timeout {
1010                std::thread::sleep(wait);
1011                wait *= 3;
1012                continue;
1013            }
1014            return Ok(answer);
1015        }
1016        Err(Error::Bad(format!(
1017            "{}{path}: 503: packsetd is busy",
1018            self.base
1019        )))
1020    }
1021
1022    fn json_ok<T: DeserializeOwned>(&self, path: &str, answer: Answer) -> Result<T, Error> {
1023        if !(200..300).contains(&answer.status) {
1024            return Err(refused(
1025                &format!("{}{path}", self.base),
1026                answer.status,
1027                &answer.body,
1028            ));
1029        }
1030        serde_json::from_str(&answer.body).map_err(Error::from)
1031    }
1032
1033    pub fn health(&self) -> Result<String, Error> {
1034        let answer = self.tried("GET", "/health", &[], None, budget())?;
1035        if !(200..300).contains(&answer.status) {
1036            return Err(refused(
1037                &format!("{}/health", self.base),
1038                answer.status,
1039                &answer.body,
1040            ));
1041        }
1042        Ok(answer.body)
1043    }
1044
1045    pub fn status(&self, workspace: Option<&str>) -> Result<Value, Error> {
1046        let query: Vec<(&str, &str)> = workspace
1047            .map(|workspace| vec![("workspace", workspace)])
1048            .unwrap_or_default();
1049        let answer = self.tried("GET", "/v1/status", &query, None, budget())?;
1050        self.json_ok("/v1/status", answer)
1051    }
1052
1053    pub fn post_atom(&self, atom: &Value) -> Result<Value, Error> {
1054        let bytes = serde_json::to_vec(atom)?;
1055        let answer = self.tried("POST", "/v1/atoms", &[], Some(&bytes), budget())?;
1056        self.json_ok("/v1/atoms", answer)
1057    }
1058
1059    pub fn delete_atom(
1060        &self,
1061        workspace: &str,
1062        id: &str,
1063        why: Option<&str>,
1064    ) -> Result<Value, Error> {
1065        let mut body = serde_json::json!({"workspace": workspace, "id": id});
1066        if let Some(accession) = why {
1067            body["why"] = Value::String(accession.to_string());
1068        }
1069        let bytes = serde_json::to_vec(&body)?;
1070        let answer = self.tried("POST", "/v1/atoms/delete", &[], Some(&bytes), budget())?;
1071        if answer.status == 404 {
1072            return Err(Error::Bad(format!("no atom {id}")));
1073        }
1074        self.json_ok("/v1/atoms/delete", answer)
1075    }
1076
1077    pub fn atoms_of_kind(&self, workspace: &str, kind: &str) -> Result<Vec<Value>, Error> {
1078        let answer = self.tried(
1079            "GET",
1080            "/v1/atoms",
1081            &[("workspace", workspace), ("kind", kind)],
1082            None,
1083            budget(),
1084        )?;
1085        let body: Value = self.json_ok("/v1/atoms", answer)?;
1086        Ok(body
1087            .get("atoms")
1088            .and_then(Value::as_array)
1089            .cloned()
1090            .unwrap_or_default())
1091    }
1092
1093    pub fn atoms_in_set(&self, workspace: &str, set: &str) -> Result<Vec<Value>, Error> {
1094        let answer = self.tried(
1095            "GET",
1096            "/v1/pack",
1097            &[("workspace", workspace), ("set", set)],
1098            None,
1099            budget(),
1100        )?;
1101        let body: Value = self.json_ok("/v1/pack", answer)?;
1102        Ok(body
1103            .get("atoms")
1104            .and_then(Value::as_array)
1105            .cloned()
1106            .unwrap_or_default())
1107    }
1108
1109    pub fn sweep(&self, workspace: &str) -> Result<Value, Error> {
1110        let bytes = serde_json::to_vec(&serde_json::json!({"workspace": workspace}))?;
1111        let answer = self.tried("POST", "/v1/sweep", &[], Some(&bytes), budget())?;
1112        self.json_ok("/v1/sweep", answer)
1113    }
1114
1115    pub fn grade(&self, workspace: &str, id: &str, recalled: bool) -> Result<Value, Error> {
1116        let bytes = serde_json::to_vec(&serde_json::json!({
1117            "workspace": workspace,
1118            "id": id,
1119            "recalled": recalled,
1120        }))?;
1121        let answer = self.tried("POST", "/v1/grade", &[], Some(&bytes), budget())?;
1122        self.json_ok("/v1/grade", answer)
1123    }
1124
1125    /// Merge `fields` into one current atom, under its id.
1126    pub fn update_atom(&self, workspace: &str, id: &str, fields: &Value) -> Result<Value, Error> {
1127        let bytes = serde_json::to_vec(&serde_json::json!({
1128            "workspace": workspace,
1129            "id": id,
1130            "fields": fields,
1131        }))?;
1132        let answer = self.tried("POST", "/v1/atoms/update", &[], Some(&bytes), budget())?;
1133        self.json_ok("/v1/atoms/update", answer)
1134    }
1135
1136    pub fn activate_as(
1137        &self,
1138        workspace: &str,
1139        q: &str,
1140        limit: u32,
1141        fire: bool,
1142        lens: Option<&str>,
1143    ) -> Result<Value, Error> {
1144        let limit = limit.to_string();
1145        let fire = if fire { "1" } else { "0" };
1146        let mut query = vec![
1147            ("workspace", workspace),
1148            ("q", q),
1149            ("limit", limit.as_str()),
1150            ("fire", fire),
1151        ];
1152        if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
1153            query.push(("as", name));
1154        }
1155        let answer = self.tried("GET", "/v1/activate", &query, None, budget())?;
1156        self.json_ok("/v1/activate", answer)
1157    }
1158
1159    pub fn hubs(&self, workspace: &str, limit: usize) -> Result<Value, Error> {
1160        let limit = limit.to_string();
1161        let answer = self.tried(
1162            "GET",
1163            "/v1/hubs",
1164            &[("workspace", workspace), ("limit", limit.as_str())],
1165            None,
1166            budget(),
1167        )?;
1168        self.json_ok("/v1/hubs", answer)
1169    }
1170
1171    pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<Value, Error> {
1172        let bytes =
1173            serde_json::to_vec(&serde_json::json!({"workspace": workspace, "apply": apply}))?;
1174        let answer = self.tried("POST", "/v1/consolidate", &[], Some(&bytes), budget())?;
1175        self.json_ok("/v1/consolidate", answer)
1176    }
1177
1178    pub fn search_opts(
1179        &self,
1180        workspace: &str,
1181        q: &str,
1182        limit: u32,
1183        as_of: Option<&str>,
1184        rerank: bool,
1185    ) -> Result<Vec<Hit>, Error> {
1186        Ok(self.search_fetched(workspace, q, limit, as_of, rerank)?.0)
1187    }
1188
1189    pub(crate) fn search_fetched(
1190        &self,
1191        workspace: &str,
1192        q: &str,
1193        limit: u32,
1194        as_of: Option<&str>,
1195        rerank: bool,
1196    ) -> Result<(Vec<Hit>, bool), Error> {
1197        let limit_s = limit.to_string();
1198        let mut query = vec![
1199            ("workspace", workspace),
1200            ("q", q),
1201            ("limit", limit_s.as_str()),
1202        ];
1203        if let Some(at) = as_of {
1204            query.push(("as_of", at));
1205        }
1206        if rerank {
1207            query.push(("rerank", "1"));
1208        }
1209        // A rerank used to wait at least a minute, past any shorter
1210        // budget. A clocked call, and a hook, keep the short budget.
1211        let timeout = if rerank && !clock_armed() && std::env::var_os("LJOS_IN_HOOK").is_none() {
1212            Duration::from_secs(60).max(budget())
1213        } else {
1214            budget()
1215        };
1216        let answer = self.tried("GET", "/v1/search", &query, None, timeout)?;
1217        let trusted = answer.trusted;
1218        let body: Value = self.json_ok("/v1/search", answer)?;
1219        let hits = body.get("hits").cloned().unwrap_or(Value::Array(vec![]));
1220        Ok((serde_json::from_value(hits)?, trusted))
1221    }
1222
1223    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<Value, Error> {
1224        let bytes = serde_json::to_vec(&serde_json::json!({
1225            "workspace": workspace,
1226            "ids": ids,
1227            "as": "",
1228        }))?;
1229        let answer = self.tried("POST", "/v1/fire", &[], Some(&bytes), budget())?;
1230        self.json_ok("/v1/fire", answer)
1231    }
1232}
1233
1234fn refused(url: &str, code: u16, body: &str) -> Error {
1235    let reason = serde_json::from_str::<Value>(body)
1236        .ok()
1237        .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
1238        .unwrap_or_else(|| body.to_string());
1239    let reason = reason.trim();
1240    if code == 401 {
1241        return Error::Bad(format!(
1242            "{url}: 401: {reason}; this seat's token does not match the writer's, \
1243             so it is another user's writer or PACKSET_HOME names another pack"
1244        ));
1245    }
1246    if reason.is_empty() {
1247        Error::Bad(format!("{url}: status code {code}"))
1248    } else {
1249        Error::Bad(format!("{url}: {code}: {reason}"))
1250    }
1251}
1252
1253fn send(live: &mut Slot, origin: &Url, call: &Wire<'_>, timeout: Duration) -> std::io::Result<Raw> {
1254    if pack_quiet() {
1255        return Err(quiet_io());
1256    }
1257    if side_budget_spent() {
1258        return Err(budget_spent());
1259    }
1260    let started = Instant::now();
1261    let result = send_once(live, origin, call, timeout);
1262    // An answer is not charged. A read that ends without one is a quiet
1263    // socket and spends the budget, so the next read does not open another.
1264    // Outside a hook the side budget is not armed. The quiet flag is that
1265    // same stop for a command, which asks the pack more than once.
1266    if let Err(err) = &result {
1267        if io_is_quiet(err) {
1268            mark_pack_quiet();
1269        }
1270        super::charge_side_wait(started.elapsed());
1271    }
1272    result
1273}
1274
1275fn send_once(
1276    live: &mut Slot,
1277    origin: &Url,
1278    call: &Wire<'_>,
1279    timeout: Duration,
1280) -> std::io::Result<Raw> {
1281    let stream = live
1282        .stream
1283        .as_mut()
1284        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotConnected, "no pack socket"))?;
1285    let wait = bounded_wait(timeout, false)?;
1286    let deadline = Instant::now() + wait;
1287    stream.set_read_timeout(Some(wait))?;
1288    stream.set_write_timeout(Some(wait))?;
1289    let bytes = render(
1290        call.method,
1291        &host_header(origin),
1292        &target(call.path, call.query),
1293        call.token,
1294        call.body,
1295    );
1296    if let Err(err) = write_all(stream, &bytes) {
1297        live.stream = None;
1298        live.proven = false;
1299        return Err(err);
1300    }
1301    match read_response(stream, deadline) {
1302        Ok(raw) => {
1303            if !raw.keep {
1304                live.stream = None;
1305                live.proven = false;
1306            }
1307            Ok(raw)
1308        }
1309        Err(err) => {
1310            live.stream = None;
1311            live.proven = false;
1312            Err(err)
1313        }
1314    }
1315}