haematite_cli/door/forward.rs
1//! Studio's verbs and head follows, carried to their store's socket.
2//!
3//! The session's Lys pass is presented first (ACCESS-002 R2); a branch's
4//! head is followed the same way, the store pushing each change
5//! ([`follow`]).
6//!
7//! Each verb is one connection: hello, then `session.pass` with the
8//! session's pass, then the verb. A pass the store refuses ends the request
9//! there and the verb is never sent. `hello` touches no resource, so it is
10//! answered with the store's own hello. `session.pass` belongs to the door,
11//! so a browser can never present a pass of its own.
12
13use std::path::Path;
14use std::time::Duration;
15
16use serde_json::value::RawValue;
17use serde_json::{Value, json};
18
19use crate::client::{Client, Ended, Refusal, Reply};
20use crate::serve::pass::now;
21
22/// What a carried verb came to.
23#[derive(Debug)]
24pub enum Carried {
25 /// The bytes the store sent for `result`.
26 Answered(Box<RawValue>),
27 /// The store's refusal, or the door's for a verb it keeps to itself.
28 Refused(Refusal),
29}
30
31/// Carry `method` with `params` to the store at `socket`, presenting
32/// `token` first, each step bounded by `timeout`.
33///
34/// # Errors
35///
36/// [`Ended`] as the client names it: nothing asked, or, from the verb's
37/// sending on, an outcome that is not known.
38pub fn carry(
39 socket: &Path,
40 timeout: Duration,
41 token: &str,
42 method: &str,
43 params: Option<&RawValue>,
44) -> Result<Carried, Ended> {
45 if method == "session.pass" {
46 return Ok(Carried::Refused(Refusal {
47 code: "invalid_operation".to_owned(),
48 message: "session.pass is the door's own and is never carried".to_owned(),
49 path: None,
50 // The door refuses this itself; no store sent a receipt.
51 verified: None,
52 }));
53 }
54 let mut client = Client::connect(socket, timeout)?;
55 if method == "hello" {
56 return Ok(Carried::Answered(client.hello().to_owned()));
57 }
58 let pass = serde_json::value::to_raw_value(&json!({ "pass": token }))
59 .map_err(|error| Ended::NotAsked(format!("the pass could not be written: {error}")))?;
60 if let Reply::Refused(refusal) = client.call("session.pass", Some(&pass))? {
61 return Ok(Carried::Refused(refusal));
62 }
63 Ok(match client.call(method, params)? {
64 Reply::Answered(answer) => Carried::Answered(answer),
65 Reply::Refused(refusal) => Carried::Refused(refusal),
66 })
67}
68
69/// What asking a store to follow a branch's head came to.
70pub enum Followed {
71 /// The store took the follow and sent the head it starts from.
72 Following(Following),
73 /// The store refused the session's pass or the follow.
74 Refused(Refusal),
75}
76
77/// A follow a store took: the first head it sent, and the connection every
78/// later frame arrives on.
79pub struct Following {
80 /// The bytes the store sent for the first head's `result`.
81 pub first: Box<RawValue>,
82 client: Client,
83 /// When the presented pass expires, in seconds since the epoch, as the
84 /// store answered `session.pass`.
85 expires: Option<u64>,
86 timeout: Duration,
87}
88
89impl Following {
90 /// The store's next frame of the follow: a head, or the refusal that
91 /// ends it. The store sends a frame only when the head moves, so the
92 /// read waits as long as that takes, bounded by the pass's expiry and
93 /// `timeout` past it: the store ends every follow at that expiry, so a
94 /// read still waiting then means a store that stopped answering. With no
95 /// expiry known the read is unbounded.
96 ///
97 /// # Errors
98 ///
99 /// [`Ended`] as the client names it when no frame could be read.
100 pub fn next_frame(&mut self) -> Result<Reply, Ended> {
101 self.client.next_reply()
102 }
103
104 pub(super) fn disconnect_handle(&self) -> std::io::Result<std::os::unix::net::UnixStream> {
105 self.client.follow_socket()
106 }
107
108 /// Bound every later read by the pass's expiry and `timeout` past it,
109 /// set once when the follow starts. A store that has already closed the
110 /// connection refuses the bound on some systems (macOS answers EINVAL);
111 /// what it sent before closing is still read, and the read itself then
112 /// names the closed connection, so that refusal is not the follow's end.
113 fn bound_reads(&self) {
114 let bound = self.expires.map(|expires| {
115 let left = Duration::from_secs(expires.saturating_sub(now()));
116 left.saturating_add(self.timeout)
117 });
118 if let Err(ended) = self.client.read_within(bound) {
119 eprintln!("haematite door: a follow's read bound was not set: {ended}");
120 }
121 }
122}
123
124/// Ask the store at `socket` to follow `branch`'s head, presenting `token`
125/// first, each step up to the first head bounded by `timeout`.
126///
127/// # Errors
128///
129/// [`Ended`] as the client names it. A follow changes nothing, so an
130/// unknown outcome only means no head was read.
131pub fn follow(
132 socket: &Path,
133 timeout: Duration,
134 token: &str,
135 branch: &str,
136) -> Result<Followed, Ended> {
137 let mut client = Client::connect(socket, timeout)?;
138 let pass = raw(&json!({ "pass": token }))?;
139 let expires = match client.call("session.pass", Some(&pass))? {
140 Reply::Refused(refusal) => return Ok(Followed::Refused(refusal)),
141 Reply::Answered(answer) => expiry(&answer),
142 };
143 let params = raw(&json!({ "branch": branch }))?;
144 Ok(match client.call("head.follow", Some(¶ms))? {
145 Reply::Answered(first) => {
146 let following = Following {
147 first,
148 client,
149 expires,
150 timeout,
151 };
152 following.bound_reads();
153 Followed::Following(following)
154 }
155 Reply::Refused(refusal) => Followed::Refused(refusal),
156 })
157}
158
159fn raw(value: &Value) -> Result<Box<RawValue>, Ended> {
160 serde_json::value::to_raw_value(value)
161 .map_err(|error| Ended::NotAsked(format!("the request could not be written: {error}")))
162}
163
164/// The expiry, in seconds since the epoch, a store answered `session.pass`
165/// with: `{holder, expires}`.
166fn expiry(answer: &RawValue) -> Option<u64> {
167 let answer: Value = serde_json::from_str(answer.get()).ok()?;
168 answer.get("expires")?.as_u64()
169}
170
171#[cfg(test)]
172#[path = "forward_tests.rs"]
173mod tests;