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::{Arc, Mutex, OnceLock};
15use std::time::{Duration, Instant};
16
17use packset_client::{Error, Hit};
18use serde::de::DeserializeOwned;
19use serde_json::Value;
20use url::Url;
21
22/// The seat's pack writer. The token field is private to this module.
23pub struct PacksetClient {
24    base: String,
25    origin: Url,
26    addr: Option<SocketAddr>,
27    workspace: Option<String>,
28    token: Mutex<Option<String>>,
29}
30
31impl Clone for PacksetClient {
32    fn clone(&self) -> Self {
33        Self {
34            base: self.base.clone(),
35            origin: self.origin.clone(),
36            addr: self.addr,
37            workspace: self.workspace.clone(),
38            token: Mutex::new(self.token.lock().unwrap_or_else(|p| p.into_inner()).clone()),
39        }
40    }
41}
42
43/// One answer. `trusted` means the token was on this answer, after the
44/// proof held.
45#[derive(Debug)]
46pub(crate) struct Answer {
47    pub status: u16,
48    pub body: String,
49    pub trusted: bool,
50}
51
52struct Slot {
53    stream: Option<TcpStream>,
54    /// The proof held on `stream` and the stream is still the one it held on.
55    proven: bool,
56    token_tag: u64,
57    /// The last answer on this socket carried the token.
58    carried: bool,
59}
60
61fn slots() -> &'static Mutex<HashMap<SocketAddr, Arc<Mutex<Slot>>>> {
62    static SLOTS: OnceLock<Mutex<HashMap<SocketAddr, Arc<Mutex<Slot>>>>> = OnceLock::new();
63    SLOTS.get_or_init(|| Mutex::new(HashMap::new()))
64}
65
66fn slot_for(addr: SocketAddr) -> Arc<Mutex<Slot>> {
67    let mut map = slots().lock().unwrap_or_else(|p| p.into_inner());
68    map.entry(addr)
69        .or_insert_with(|| {
70            Arc::new(Mutex::new(Slot {
71                stream: None,
72                proven: false,
73                token_tag: 0,
74                carried: false,
75            }))
76        })
77        .clone()
78}
79
80fn load_token() -> Option<String> {
81    packset_client::token()
82}
83
84fn token_tag(token: Option<&str>) -> u64 {
85    let Some(token) = token else {
86        return 0;
87    };
88    let mut hash = 0xcbf29ce484222325u64;
89    for byte in token.as_bytes() {
90        hash ^= u64::from(*byte);
91        hash = hash.wrapping_mul(0x100000001b3);
92    }
93    hash
94}
95
96fn nonce() -> Option<String> {
97    use std::io::Read as _;
98    let mut raw = [0u8; 32];
99    std::fs::File::open("/dev/urandom")
100        .and_then(|mut file| file.read_exact(&mut raw))
101        .ok()
102        .map(|()| hex::encode(raw))
103}
104
105fn pin(url: &Url) -> Option<SocketAddr> {
106    let host = url.host_str()?;
107    let port = url.port_or_known_default()?;
108    (host, port).to_socket_addrs().ok()?.next()
109}
110
111fn host_header(url: &Url) -> String {
112    match (url.host_str(), url.port()) {
113        (Some(host), Some(port)) if host.contains(':') => format!("[{host}]:{port}"),
114        (Some(host), Some(port)) => format!("{host}:{port}"),
115        (Some(host), None) => host.to_string(),
116        _ => "localhost".to_string(),
117    }
118}
119
120fn budget() -> Duration {
121    std::env::var("PACKSET_TIMEOUT_MS")
122        .ok()
123        .and_then(|v| v.trim().parse::<u64>().ok())
124        .filter(|ms| *ms > 0)
125        .map_or(Duration::from_secs(30), Duration::from_millis)
126}
127
128/// Whether every socket listening on the loopback port of `url` belongs to
129/// another user. Unknown is not foreign.
130fn foreign(url: &str) -> bool {
131    #[cfg(target_os = "linux")]
132    {
133        use std::os::unix::fs::MetadataExt;
134        let Some(port) = loopback_port(url) else {
135            return false;
136        };
137        let Ok(me) = std::fs::metadata("/proc/self").map(|m| m.uid()) else {
138            return false;
139        };
140        let owners: Vec<u32> = ["/proc/net/tcp", "/proc/net/tcp6"]
141            .iter()
142            .filter_map(|table| std::fs::read_to_string(table).ok())
143            .flat_map(|text| listen_uids(&text, port))
144            .collect();
145        !owners.is_empty() && owners.iter().all(|uid| *uid != me)
146    }
147    #[cfg(not(target_os = "linux"))]
148    {
149        let _ = url;
150        false
151    }
152}
153
154#[cfg(target_os = "linux")]
155fn loopback_port(url: &str) -> Option<u16> {
156    let rest = url
157        .strip_prefix("http://")
158        .or_else(|| url.strip_prefix("https://"))?;
159    let authority = rest.split(['/', '?', '#']).next()?;
160    let (host, port) = match authority.rsplit_once(':') {
161        Some((host, port)) if !port.contains(']') => (host, port.parse().ok()?),
162        _ => (authority, 80),
163    };
164    matches!(host, "127.0.0.1" | "localhost" | "[::1]").then_some(port)
165}
166
167#[cfg(target_os = "linux")]
168fn listen_uids(table: &str, port: u16) -> Vec<u32> {
169    table
170        .lines()
171        .skip(1)
172        .filter_map(|line| {
173            let fields: Vec<&str> = line.split_whitespace().collect();
174            if fields.get(3) != Some(&"0A") {
175                return None;
176            }
177            let hex = fields.get(1)?.rsplit(':').next()?;
178            if u16::from_str_radix(hex, 16).ok()? != port {
179                return None;
180            }
181            fields.get(7)?.parse().ok()
182        })
183        .collect()
184}
185
186fn target(path: &str, query: &[(&str, &str)]) -> String {
187    if query.is_empty() {
188        return path.to_string();
189    }
190    let mut ser = url::form_urlencoded::Serializer::new(String::new());
191    for (key, value) in query {
192        ser.append_pair(key, value);
193    }
194    format!("{path}?{}", ser.finish())
195}
196
197struct Raw {
198    status: u16,
199    body: String,
200    keep: bool,
201}
202
203fn connect(addr: SocketAddr, timeout: Duration) -> std::io::Result<TcpStream> {
204    if side_budget_spent() {
205        return Err(budget_spent());
206    }
207    let started = Instant::now();
208    let result = connect_once(addr, timeout);
209    // A connect that returns a socket is the pack accepting. Time spent
210    // there is not a quiet wait. A connect that fails spends the budget.
211    if result.is_err() {
212        super::charge_side_wait(started.elapsed());
213    }
214    result
215}
216
217/// A side event's pack budget is gone. Opening a socket for a one
218/// millisecond timer still blocks, and a prompt makes several of those
219/// reads, so the call returns before the connect.
220fn side_budget_spent() -> bool {
221    super::side_remaining_ms() == Some(0)
222}
223
224fn budget_spent() -> std::io::Error {
225    std::io::Error::new(
226        std::io::ErrorKind::TimedOut,
227        "the side event's pack budget is spent",
228    )
229}
230
231fn connect_once(addr: SocketAddr, timeout: Duration) -> std::io::Result<TcpStream> {
232    let wait = capped_wait(timeout);
233    let stream = TcpStream::connect_timeout(&addr, wait)?;
234    stream.set_read_timeout(Some(wait))?;
235    stream.set_write_timeout(Some(wait))?;
236    let _ = stream.set_nodelay(true);
237    Ok(stream)
238}
239
240/// A side-event bound shrinks this wait. A zero bound is one millisecond,
241/// so a later read fails at once instead of taking the default.
242fn capped_wait(timeout: Duration) -> Duration {
243    let wait = super::cap_pack_wait(timeout);
244    if wait.is_zero() {
245        Duration::from_millis(1)
246    } else {
247        wait
248    }
249}
250
251fn write_all(stream: &mut TcpStream, bytes: &[u8]) -> std::io::Result<()> {
252    let mut sent = 0;
253    while sent < bytes.len() {
254        match stream.write(&bytes[sent..]) {
255            Ok(0) => {
256                return Err(std::io::Error::new(
257                    std::io::ErrorKind::WriteZero,
258                    "the pack closed the socket",
259                ))
260            }
261            Ok(n) => sent += n,
262            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
263            Err(err) => return Err(err),
264        }
265    }
266    stream.flush()
267}
268
269fn read_response(stream: &mut TcpStream, deadline: Instant) -> std::io::Result<Raw> {
270    let mut buf = Vec::new();
271    let mut tmp = [0u8; 4096];
272    let head_end = loop {
273        if buf.len() > 64 * 1024 {
274            return Err(std::io::Error::new(
275                std::io::ErrorKind::InvalidData,
276                "the pack's headers are too long",
277            ));
278        }
279        arm_read(stream, deadline)?;
280        let n = read_some(stream, &mut tmp)?;
281        buf.extend_from_slice(&tmp[..n]);
282        if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
283            break at;
284        }
285    };
286    let header = String::from_utf8_lossy(&buf[..head_end]).into_owned();
287    let status = header
288        .lines()
289        .next()
290        .and_then(|line| line.split_whitespace().nth(1))
291        .and_then(|code| code.parse().ok())
292        .ok_or_else(|| {
293            std::io::Error::new(
294                std::io::ErrorKind::InvalidData,
295                "the pack's status line is not HTTP",
296            )
297        })?;
298    let mut length = None;
299    let mut close = header.starts_with("HTTP/1.0");
300    for line in header.lines().skip(1) {
301        let Some((name, value)) = line.split_once(':') else {
302            continue;
303        };
304        if name.eq_ignore_ascii_case("content-length") {
305            length = value.trim().parse().ok();
306        } else if name.eq_ignore_ascii_case("connection")
307            && value.trim().eq_ignore_ascii_case("close")
308        {
309            close = true;
310        }
311    }
312    let mut body = buf.split_off(head_end + 4);
313    if let Some(length) = length {
314        while body.len() < length {
315            arm_read(stream, deadline)?;
316            let n = read_some(stream, &mut tmp)?;
317            body.extend_from_slice(&tmp[..n]);
318            if body.len() > length.saturating_add(64 * 1024) {
319                break;
320            }
321        }
322        body.truncate(length);
323    } else {
324        loop {
325            if deadline.saturating_duration_since(Instant::now()).is_zero() {
326                break;
327            }
328            let _ = arm_read(stream, deadline);
329            match stream.read(&mut tmp) {
330                Ok(0) => break,
331                Ok(n) => body.extend_from_slice(&tmp[..n]),
332                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
333                Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
334                Err(err) if err.kind() == std::io::ErrorKind::TimedOut => break,
335                Err(_) => break,
336            }
337        }
338        close = true;
339    }
340    // A redirect is the answer. The token must not follow it.
341    Ok(Raw {
342        status,
343        body: String::from_utf8_lossy(&body).into_owned(),
344        keep: !close && !(300..400).contains(&status),
345    })
346}
347
348fn arm_read(stream: &TcpStream, deadline: Instant) -> std::io::Result<()> {
349    let remain = deadline.saturating_duration_since(Instant::now());
350    if remain.is_zero() {
351        return Err(std::io::Error::new(
352            std::io::ErrorKind::TimedOut,
353            "the pack did not answer",
354        ));
355    }
356    stream.set_read_timeout(Some(remain))?;
357    Ok(())
358}
359
360fn read_some(stream: &mut TcpStream, tmp: &mut [u8]) -> std::io::Result<usize> {
361    loop {
362        match stream.read(tmp) {
363            Ok(0) => {
364                return Err(std::io::Error::new(
365                    std::io::ErrorKind::UnexpectedEof,
366                    "the pack closed the socket",
367                ))
368            }
369            Ok(n) => return Ok(n),
370            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
371            Err(err) => return Err(err),
372        }
373    }
374}
375
376fn render(
377    method: &str,
378    host: &str,
379    path: &str,
380    token: Option<&str>,
381    body: Option<&[u8]>,
382) -> Vec<u8> {
383    let mut head =
384        format!("{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: keep-alive\r\n");
385    if let Some(token) = token {
386        head.push_str("Authorization: Bearer ");
387        head.push_str(token);
388        head.push_str("\r\n");
389    }
390    let mut out = Vec::new();
391    if let Some(body) = body {
392        head.push_str("Content-Type: application/json\r\n");
393        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
394        out.extend(head.into_bytes());
395        out.extend_from_slice(body);
396    } else {
397        head.push_str("\r\n");
398        out.extend(head.into_bytes());
399    }
400    out
401}
402
403struct Wire<'a> {
404    method: &'a str,
405    path: &'a str,
406    query: &'a [(&'a str, &'a str)],
407    body: Option<&'a [u8]>,
408    token: Option<&'a str>,
409}
410
411enum Gate {
412    /// The proof held. The call may carry the token.
413    Send,
414    /// The listener did not ask for the token.
415    Bare,
416    /// The writer is busy. The call is not sent.
417    Busy(Raw),
418}
419
420impl PacksetClient {
421    pub fn new(base: impl Into<String>) -> Self {
422        let mut base = base.into();
423        while base.ends_with('/') {
424            base.pop();
425        }
426        let origin = Url::parse(&base).unwrap_or_else(|_| Url::parse("http://127.0.0.1/").unwrap());
427        let addr = Url::parse(&base).ok().as_ref().and_then(pin);
428        Self {
429            base,
430            origin,
431            addr,
432            workspace: None,
433            token: Mutex::new(load_token()),
434        }
435    }
436
437    /// The writer the seat talks to. `PACKSET_URL=off` is no pack.
438    pub fn from_env() -> Result<Self, Error> {
439        packset_client::load_seat_env();
440        let url = std::env::var("PACKSET_URL")
441            .or_else(|_| std::env::var("INSIDE_MEMORY_URL"))
442            .ok()
443            .filter(|url| !url.is_empty());
444        match url {
445            Some(url) if url == "off" => Err(Error::NoUrl),
446            Some(url) => Ok(Self::new(url)),
447            None => Ok(Self::new(format!(
448                "http://127.0.0.1:{}",
449                packset_client::default_port()
450            ))),
451        }
452    }
453
454    #[must_use]
455    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
456        let workspace = workspace.into();
457        self.workspace = (!workspace.is_empty()).then_some(workspace);
458        self
459    }
460
461    pub fn base(&self) -> &str {
462        &self.base
463    }
464
465    /// Whether this seat has a token to show. The token itself stays here.
466    #[must_use]
467    pub(crate) fn has_token(&self) -> bool {
468        load_token().is_some()
469    }
470
471    /// Whether a call may carry the token: the seat has one, and another
472    /// user does not hold the loopback port.
473    #[must_use]
474    pub(crate) fn may_carry(&self) -> bool {
475        load_token().is_some() && !foreign(&self.base)
476    }
477
478    /// Whether the last answer on this socket carried the token. A closed
479    /// socket does not keep this for the next call: that call proves again.
480    #[must_use]
481    pub(crate) fn carried(&self) -> bool {
482        let Some(addr) = self.addr else {
483            return false;
484        };
485        let map = slots().lock().unwrap_or_else(|p| p.into_inner());
486        map.get(&addr)
487            .is_some_and(|slot| slot.lock().unwrap_or_else(|p| p.into_inner()).carried)
488    }
489
490    pub fn workspace(&self) -> String {
491        if let Some(workspace) = &self.workspace {
492            return workspace.clone();
493        }
494        if let Ok(workspace) = std::env::var("PACKSET_WORKSPACE") {
495            if !workspace.is_empty() {
496                return workspace;
497            }
498        }
499        let cwd = std::env::var("GROKOS_WORKSPACE")
500            .ok()
501            .map(|s| s.trim().to_string())
502            .filter(|s| !s.is_empty())
503            .map(std::path::PathBuf::from)
504            .or_else(|| std::env::current_dir().ok())
505            .unwrap_or_else(|| std::path::PathBuf::from("."));
506        let abs = cwd.canonicalize().unwrap_or(cwd);
507        let shown = abs.to_string_lossy().into_owned();
508        if let Ok(answer) = self.roundtrip(
509            "GET",
510            "/v1/identity",
511            &[("cwd", shown.as_str())],
512            None,
513            true,
514            budget(),
515        ) {
516            if answer.status == 200 {
517                if let Ok(val) = serde_json::from_str::<Value>(&answer.body) {
518                    if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
519                        if !ws.is_empty() {
520                            return ws.to_string();
521                        }
522                    }
523                }
524            }
525        }
526        format!("dir:{}", abs.display())
527    }
528
529    /// One request. `bearer` asks to carry the token; it is left off when
530    /// the proof does not hold or another user owns the port. A 3xx is
531    /// returned as the answer and not followed. HTTP 503 is one answer:
532    /// the caller decides whether to ask again.
533    pub(crate) fn roundtrip(
534        &self,
535        method: &str,
536        path: &str,
537        query: &[(&str, &str)],
538        body: Option<&[u8]>,
539        bearer: bool,
540        timeout: Duration,
541    ) -> Result<Answer, Error> {
542        let Some(addr) = self.addr else {
543            return Err(Error::Bad(format!(
544                "{} is not a socket this seat can pin",
545                self.base
546            )));
547        };
548        let token = load_token();
549        *self.token.lock().unwrap_or_else(|p| p.into_inner()) = token.clone();
550        let allow = bearer && token.is_some() && !foreign(&self.base);
551        self.exchange(
552            addr,
553            &Wire {
554                method,
555                path,
556                query,
557                body,
558                token: token.as_deref(),
559            },
560            allow,
561            timeout,
562        )
563    }
564
565    fn exchange(
566        &self,
567        addr: SocketAddr,
568        call: &Wire<'_>,
569        allow: bool,
570        timeout: Duration,
571    ) -> Result<Answer, Error> {
572        let slot = slot_for(addr);
573        let mut live = slot.lock().unwrap_or_else(|p| p.into_inner());
574        let tag = token_tag(call.token);
575        if live.token_tag != tag {
576            live.stream = None;
577            live.proven = false;
578            live.token_tag = tag;
579        }
580        let carried = if allow {
581            match self.gate(&mut live, addr, timeout)? {
582                Gate::Bare => None,
583                Gate::Send => call.token,
584                Gate::Busy(raw) => {
585                    live.carried = false;
586                    live.proven = false;
587                    return Ok(Answer {
588                        status: raw.status,
589                        body: raw.body,
590                        trusted: false,
591                    });
592                }
593            }
594        } else {
595            None
596        };
597        if live.stream.is_none() {
598            match connect(addr, timeout) {
599                Ok(stream) => live.stream = Some(stream),
600                Err(err) => {
601                    live.proven = false;
602                    live.carried = false;
603                    return Err(Error::Io(err));
604                }
605            }
606            // The probes ran on sockets the writer closed. This socket is
607            // the one the token rides, the third step of the same proof,
608            // not a later call.
609            live.proven = false;
610        }
611        let raw = match send(
612            &mut live,
613            &self.origin,
614            &Wire {
615                token: carried,
616                ..*call
617            },
618            timeout,
619        ) {
620            Ok(raw) => raw,
621            Err(err) => {
622                live.stream = None;
623                live.proven = false;
624                live.carried = false;
625                return Err(Error::Io(err));
626            }
627        };
628        let trusted = carried.is_some();
629        live.carried = trusted;
630        if trusted && live.stream.is_some() {
631            live.proven = true;
632        } else if live.stream.is_none() {
633            live.proven = false;
634        }
635        Ok(Answer {
636            status: raw.status,
637            body: raw.body,
638            trusted,
639        })
640    }
641
642    /// Two refusals on this socket, then the call may carry the token.
643    /// A busy writer (503) is returned as that answer; the call is not sent.
644    fn gate(&self, live: &mut Slot, addr: SocketAddr, timeout: Duration) -> Result<Gate, Error> {
645        if live.proven && live.stream.is_some() {
646            return Ok(Gate::Send);
647        }
648        live.proven = false;
649        let (Some(id), Some(bogus)) = (nonce(), nonce()) else {
650            return Err(Error::Io(std::io::Error::other(
651                "no randomness for the pack proof",
652            )));
653        };
654        let workspace = self.proof_workspace();
655        let path = format!("/v1/atoms/{}", &id[..32]);
656        let query = [("workspace", workspace.as_str())];
657        let mut stage = 0u8;
658        let mut restarts = 0u8;
659        loop {
660            if live.stream.is_none() {
661                live.stream = Some(connect(addr, timeout).map_err(Error::Io)?);
662                live.proven = false;
663            }
664            let shown = if stage == 0 {
665                None
666            } else {
667                Some(bogus.as_str())
668            };
669            let raw = match send(
670                live,
671                &self.origin,
672                &Wire {
673                    method: "GET",
674                    path: &path,
675                    query: &query,
676                    body: None,
677                    token: shown,
678                },
679                timeout,
680            ) {
681                Ok(raw) => raw,
682                Err(err) => {
683                    live.stream = None;
684                    live.proven = false;
685                    restarts += 1;
686                    if restarts > 2 {
687                        return Err(Error::Io(err));
688                    }
689                    stage = 0;
690                    continue;
691                }
692            };
693            if raw.status == 503 {
694                return Ok(Gate::Busy(raw));
695            }
696            if raw.status != 401 {
697                return Ok(Gate::Bare);
698            }
699            if stage == 0 {
700                stage = 1;
701                continue;
702            }
703            return Ok(Gate::Send);
704        }
705    }
706
707    fn proof_workspace(&self) -> String {
708        if let Some(workspace) = &self.workspace {
709            return workspace.clone();
710        }
711        std::env::var("PACKSET_WORKSPACE")
712            .ok()
713            .filter(|w| !w.is_empty())
714            .unwrap_or_else(|| "seat".to_string())
715    }
716
717    /// [`Self::roundtrip`] again while the writer answers busy, inside `timeout`.
718    fn tried(
719        &self,
720        method: &str,
721        path: &str,
722        query: &[(&str, &str)],
723        body: Option<&[u8]>,
724        timeout: Duration,
725    ) -> Result<Answer, Error> {
726        let started = Instant::now();
727        let mut wait = Duration::from_millis(50);
728        for n in 0..4 {
729            let answer = self.roundtrip(method, path, query, body, true, timeout)?;
730            if answer.status == 503 && n < 3 && started.elapsed() + wait < timeout {
731                std::thread::sleep(wait);
732                wait *= 3;
733                continue;
734            }
735            return Ok(answer);
736        }
737        Err(Error::Bad(format!(
738            "{}{path}: 503: packsetd is busy",
739            self.base
740        )))
741    }
742
743    fn json_ok<T: DeserializeOwned>(&self, path: &str, answer: Answer) -> Result<T, Error> {
744        if !(200..300).contains(&answer.status) {
745            return Err(refused(
746                &format!("{}{path}", self.base),
747                answer.status,
748                &answer.body,
749            ));
750        }
751        serde_json::from_str(&answer.body).map_err(Error::from)
752    }
753
754    pub fn health(&self) -> Result<String, Error> {
755        let answer = self.tried("GET", "/health", &[], None, budget())?;
756        if !(200..300).contains(&answer.status) {
757            return Err(refused(
758                &format!("{}/health", self.base),
759                answer.status,
760                &answer.body,
761            ));
762        }
763        Ok(answer.body)
764    }
765
766    pub fn status(&self, workspace: Option<&str>) -> Result<Value, Error> {
767        let query: Vec<(&str, &str)> = workspace
768            .map(|workspace| vec![("workspace", workspace)])
769            .unwrap_or_default();
770        let answer = self.tried("GET", "/v1/status", &query, None, budget())?;
771        self.json_ok("/v1/status", answer)
772    }
773
774    pub fn post_atom(&self, atom: &Value) -> Result<Value, Error> {
775        let bytes = serde_json::to_vec(atom)?;
776        let answer = self.tried("POST", "/v1/atoms", &[], Some(&bytes), budget())?;
777        self.json_ok("/v1/atoms", answer)
778    }
779
780    pub fn delete_atom(
781        &self,
782        workspace: &str,
783        id: &str,
784        why: Option<&str>,
785    ) -> Result<Value, Error> {
786        let mut body = serde_json::json!({"workspace": workspace, "id": id});
787        if let Some(accession) = why {
788            body["why"] = Value::String(accession.to_string());
789        }
790        let bytes = serde_json::to_vec(&body)?;
791        let answer = self.tried("POST", "/v1/atoms/delete", &[], Some(&bytes), budget())?;
792        if answer.status == 404 {
793            return Err(Error::Bad(format!("no atom {id}")));
794        }
795        self.json_ok("/v1/atoms/delete", answer)
796    }
797
798    pub fn atoms_of_kind(&self, workspace: &str, kind: &str) -> Result<Vec<Value>, Error> {
799        let answer = self.tried(
800            "GET",
801            "/v1/atoms",
802            &[("workspace", workspace), ("kind", kind)],
803            None,
804            budget(),
805        )?;
806        let body: Value = self.json_ok("/v1/atoms", answer)?;
807        Ok(body
808            .get("atoms")
809            .and_then(Value::as_array)
810            .cloned()
811            .unwrap_or_default())
812    }
813
814    pub fn atoms_in_set(&self, workspace: &str, set: &str) -> Result<Vec<Value>, Error> {
815        let answer = self.tried(
816            "GET",
817            "/v1/pack",
818            &[("workspace", workspace), ("set", set)],
819            None,
820            budget(),
821        )?;
822        let body: Value = self.json_ok("/v1/pack", answer)?;
823        Ok(body
824            .get("atoms")
825            .and_then(Value::as_array)
826            .cloned()
827            .unwrap_or_default())
828    }
829
830    pub fn sweep(&self, workspace: &str) -> Result<Value, Error> {
831        let bytes = serde_json::to_vec(&serde_json::json!({"workspace": workspace}))?;
832        let answer = self.tried("POST", "/v1/sweep", &[], Some(&bytes), budget())?;
833        self.json_ok("/v1/sweep", answer)
834    }
835
836    pub fn grade(&self, workspace: &str, id: &str, recalled: bool) -> Result<Value, Error> {
837        let bytes = serde_json::to_vec(&serde_json::json!({
838            "workspace": workspace,
839            "id": id,
840            "recalled": recalled,
841        }))?;
842        let answer = self.tried("POST", "/v1/grade", &[], Some(&bytes), budget())?;
843        self.json_ok("/v1/grade", answer)
844    }
845
846    pub fn activate_as(
847        &self,
848        workspace: &str,
849        q: &str,
850        limit: u32,
851        fire: bool,
852        lens: Option<&str>,
853    ) -> Result<Value, Error> {
854        let limit = limit.to_string();
855        let fire = if fire { "1" } else { "0" };
856        let mut query = vec![
857            ("workspace", workspace),
858            ("q", q),
859            ("limit", limit.as_str()),
860            ("fire", fire),
861        ];
862        if let Some(name) = lens.filter(|n| !n.trim().is_empty()) {
863            query.push(("as", name));
864        }
865        let answer = self.tried("GET", "/v1/activate", &query, None, budget())?;
866        self.json_ok("/v1/activate", answer)
867    }
868
869    pub fn hubs(&self, workspace: &str, limit: usize) -> Result<Value, Error> {
870        let limit = limit.to_string();
871        let answer = self.tried(
872            "GET",
873            "/v1/hubs",
874            &[("workspace", workspace), ("limit", limit.as_str())],
875            None,
876            budget(),
877        )?;
878        self.json_ok("/v1/hubs", answer)
879    }
880
881    pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<Value, Error> {
882        let bytes =
883            serde_json::to_vec(&serde_json::json!({"workspace": workspace, "apply": apply}))?;
884        let answer = self.tried("POST", "/v1/consolidate", &[], Some(&bytes), budget())?;
885        self.json_ok("/v1/consolidate", answer)
886    }
887
888    pub fn search_opts(
889        &self,
890        workspace: &str,
891        q: &str,
892        limit: u32,
893        as_of: Option<&str>,
894        rerank: bool,
895    ) -> Result<Vec<Hit>, Error> {
896        Ok(self.search_fetched(workspace, q, limit, as_of, rerank)?.0)
897    }
898
899    pub(crate) fn search_fetched(
900        &self,
901        workspace: &str,
902        q: &str,
903        limit: u32,
904        as_of: Option<&str>,
905        rerank: bool,
906    ) -> Result<(Vec<Hit>, bool), Error> {
907        let limit_s = limit.to_string();
908        let mut query = vec![
909            ("workspace", workspace),
910            ("q", q),
911            ("limit", limit_s.as_str()),
912        ];
913        if let Some(at) = as_of {
914            query.push(("as_of", at));
915        }
916        if rerank {
917            query.push(("rerank", "1"));
918        }
919        let timeout = if rerank {
920            Duration::from_secs(60).max(budget())
921        } else {
922            budget()
923        };
924        let answer = self.tried("GET", "/v1/search", &query, None, timeout)?;
925        let trusted = answer.trusted;
926        let body: Value = self.json_ok("/v1/search", answer)?;
927        let hits = body.get("hits").cloned().unwrap_or(Value::Array(vec![]));
928        Ok((serde_json::from_value(hits)?, trusted))
929    }
930
931    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<Value, Error> {
932        let bytes = serde_json::to_vec(&serde_json::json!({
933            "workspace": workspace,
934            "ids": ids,
935            "as": "",
936        }))?;
937        let answer = self.tried("POST", "/v1/fire", &[], Some(&bytes), budget())?;
938        self.json_ok("/v1/fire", answer)
939    }
940}
941
942fn refused(url: &str, code: u16, body: &str) -> Error {
943    let reason = serde_json::from_str::<Value>(body)
944        .ok()
945        .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
946        .unwrap_or_else(|| body.to_string());
947    let reason = reason.trim();
948    if code == 401 {
949        return Error::Bad(format!(
950            "{url}: 401: {reason}; this seat's token does not match the writer's, \
951             so it is another user's writer or PACKSET_HOME names another pack"
952        ));
953    }
954    if reason.is_empty() {
955        Error::Bad(format!("{url}: status code {code}"))
956    } else {
957        Error::Bad(format!("{url}: {code}: {reason}"))
958    }
959}
960
961fn send(live: &mut Slot, origin: &Url, call: &Wire<'_>, timeout: Duration) -> std::io::Result<Raw> {
962    if side_budget_spent() {
963        return Err(budget_spent());
964    }
965    let started = Instant::now();
966    let result = send_once(live, origin, call, timeout);
967    // An answer is not charged. A read that ends without one is a quiet
968    // socket and spends the budget, so the next read does not open another.
969    if result.is_err() {
970        super::charge_side_wait(started.elapsed());
971    }
972    result
973}
974
975fn send_once(
976    live: &mut Slot,
977    origin: &Url,
978    call: &Wire<'_>,
979    timeout: Duration,
980) -> std::io::Result<Raw> {
981    let stream = live
982        .stream
983        .as_mut()
984        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotConnected, "no pack socket"))?;
985    let wait = capped_wait(timeout);
986    let deadline = Instant::now() + wait;
987    stream.set_read_timeout(Some(wait))?;
988    stream.set_write_timeout(Some(wait))?;
989    let bytes = render(
990        call.method,
991        &host_header(origin),
992        &target(call.path, call.query),
993        call.token,
994        call.body,
995    );
996    if let Err(err) = write_all(stream, &bytes) {
997        live.stream = None;
998        live.proven = false;
999        return Err(err);
1000    }
1001    match read_response(stream, deadline) {
1002        Ok(raw) => {
1003            if !raw.keep {
1004                live.stream = None;
1005                live.proven = false;
1006            }
1007            Ok(raw)
1008        }
1009        Err(err) => {
1010            live.stream = None;
1011            live.proven = false;
1012            Err(err)
1013        }
1014    }
1015}