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