Skip to main content

recall_wire/
discovery.rs

1//! What a server says about itself, and what a client says about itself.
2//!
3//! `GET /.well-known/recall` answers with a [`Discovery`] document: which
4//! protocol versions the server speaks, which release it is, the oldest
5//! client it accepts, how clients may authenticate, and what it can do. It
6//! is unauthenticated, like `/health`, and at the path RFC 8615 sets aside
7//! for exactly this kind of document.
8//!
9//! The rules that keep it readable by clients that do not exist yet, taken
10//! from git protocol v2, Matrix's `/versions` and MCP:
11//!
12//! 1. **Two layers.** [`Protocol`] changes only for a breaking change; the
13//!    capabilities only ever grow.
14//! 2. **Unknown keys are ignored**, at any depth. Nothing here denies
15//!    unknown fields, and a client must not either.
16//! 3. **Absent means unsupported.** A capability that is not listed is not
17//!    available.
18//! 4. **A map, not a list.** Each capability is an object, so it can carry
19//!    parameters later without a new key.
20//! 5. **Nothing is removed** within a protocol version.
21//!
22//! In the other direction, every request a client sends carries
23//! [`PROTOCOL_HEADER`] and a `User-Agent` of the form [`user_agent`] builds.
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29/// Where the discovery document is served.
30pub const DISCOVERY_PATH: &str = "/.well-known/recall";
31
32/// The protocol version this build speaks.
33pub const PROTOCOL: u32 = 1;
34
35/// The request header a client names its protocol version in. A request
36/// without it is treated as protocol 1, which is what every client before
37/// the header existed spoke.
38pub const PROTOCOL_HEADER: &str = "recall-protocol";
39
40/// The discovery document.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct Discovery {
43    /// The protocol versions the server speaks.
44    pub protocol: Protocol,
45    /// Which build of the server this is.
46    pub server: ServerInfo,
47    /// The oldest client version the server accepts, as SemVer.
48    pub min_client: String,
49    /// How clients may authenticate.
50    pub auth: Auth,
51    /// What the server can do, by name. An absent name is unsupported.
52    #[serde(default)]
53    pub capabilities: BTreeMap<String, serde_json::Value>,
54}
55
56impl Discovery {
57    /// Whether the server speaks protocol `version`.
58    pub fn speaks(&self, version: u32) -> bool {
59        self.protocol.supported.contains(&version)
60    }
61
62    /// Whether the server lists the capability `name`.
63    pub fn can(&self, name: &str) -> bool {
64        self.capabilities.contains_key(name)
65    }
66
67    /// Whether the server accepts the authentication method `name`, such
68    /// as [`AUTH_DEVICE_SIG`].
69    pub fn accepts(&self, name: &str) -> bool {
70        self.auth.methods.iter().any(|m| m == name)
71    }
72
73    /// The [`CAPABILITY_DEVICES`] capability, read into its type. [`None`]
74    /// when the server does not list it, and also when it lists one this
75    /// build cannot read, which is the same answer: a client cannot use it.
76    pub fn devices(&self) -> Option<crate::DevicesCapability> {
77        serde_json::from_value(self.capabilities.get(CAPABILITY_DEVICES)?.clone()).ok()
78    }
79
80    /// The [`CAPABILITY_AUDIT`] capability, read into its type: [`None`]
81    /// from a server that keeps no audit log (one older than 0.4.2), or
82    /// lists one this build cannot read.
83    pub fn audit(&self) -> Option<crate::AuditCapability> {
84        serde_json::from_value(self.capabilities.get(CAPABILITY_AUDIT)?.clone()).ok()
85    }
86}
87
88/// The protocol versions a server speaks.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct Protocol {
91    /// The newest one, which a server's own client speaks.
92    pub current: u32,
93    /// Every version the server accepts requests in.
94    pub supported: Vec<u32>,
95}
96
97/// Which build of the server answered.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ServerInfo {
100    /// The server's identity, as SemVer: the release version for a release
101    /// build, and a `-dev` pre-release of the next patch for anything else.
102    pub version: String,
103    /// Where the build came from.
104    pub build: Build,
105}
106
107/// Where a build came from. Provenance only: nothing is decided on it.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Build {
110    /// [`CHANNEL_RELEASE`] for a release build, [`CHANNEL_DEV`] otherwise.
111    pub channel: String,
112    /// The commit it was built from, when known.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub revision: Option<String>,
115    /// When it was built, in RFC 3339, when known.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub created: Option<String>,
118}
119
120/// How a client may authenticate.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct Auth {
123    /// The methods the server accepts, by name, e.g. [`AUTH_BEARER`].
124    pub methods: Vec<String>,
125}
126
127/// The one shared bearer token, `RECALL_TOKEN`.
128pub const AUTH_BEARER: &str = "bearer";
129
130/// Requests signed by an enrolled device's key: see
131/// [`crate::signature`].
132pub const AUTH_DEVICE_SIG: &str = "device-sig-v1";
133
134/// The capability a server lists when it enrols devices; its parameters
135/// are a [`crate::DevicesCapability`].
136pub const CAPABILITY_DEVICES: &str = "devices";
137
138/// The capability a server lists when it can queue a stale push for a
139/// merge worker and has the job routes (see [`crate::jobs`]). A worker
140/// asks for it before enrolling, and works for no server without it.
141pub const CAPABILITY_MERGE_QUEUE: &str = "merge_queue";
142
143/// The capability a server lists when it makes evaluation reports: the
144/// routes in [`crate::evaluations`], and `evaluate` jobs for a worker.
145pub const CAPABILITY_EVALUATION: &str = "evaluation";
146
147/// The capability a server lists when it keeps an audit log (see
148/// [`crate::audit`]); its parameters are a [`crate::AuditCapability`],
149/// which says how large a page of entries may be.
150pub const CAPABILITY_AUDIT: &str = "audit";
151
152/// A build made by the release workflow from a release tag.
153pub const CHANNEL_RELEASE: &str = "release";
154
155/// Any other build: from `main`, from a pull request, or on someone's
156/// machine.
157pub const CHANNEL_DEV: &str = "dev";
158
159/// This build's channel, decided at compile time by `build.rs`, which
160/// passes it on as `RECALL_RESOLVED_CHANNEL`:
161/// `RECALL_BUILD_CHANNEL` when set, otherwise `dev` for a git checkout and
162/// `release` for a crate built from crates.io, which is what
163/// `cargo install recall` compiles.
164pub fn channel() -> &'static str {
165    match env!("RECALL_RESOLVED_CHANNEL") {
166        CHANNEL_RELEASE => CHANNEL_RELEASE,
167        _ => CHANNEL_DEV,
168    }
169}
170
171/// The commit this build was compiled from, when the build recorded one.
172pub fn revision() -> Option<&'static str> {
173    option_env!("RECALL_GIT_COMMIT").filter(|r| !r.is_empty())
174}
175
176/// When this build was made, when the build recorded it: the release
177/// workflow sets `RECALL_BUILD_CREATED` at compile time.
178pub fn created() -> Option<&'static str> {
179    option_env!("RECALL_BUILD_CREATED").filter(|c| !c.is_empty())
180}
181
182/// This build's version, as SemVer: see [`version_for`].
183pub fn version() -> String {
184    version_for(channel(), revision())
185}
186
187/// The version a build of this source reports.
188///
189/// A release build reports its release. Anything else reports a
190/// pre-release of the next patch, with the commit as build metadata, so
191/// `0.3.2` built from a later `main` reads `0.3.3-dev+g1a2b3c4`. SemVer
192/// orders that after `0.3.2` and before `0.3.3`, which is where the code
193/// actually sits, and ignores the metadata when comparing.
194pub fn version_for(channel: &str, revision: Option<&str>) -> String {
195    let base = env!("CARGO_PKG_VERSION");
196    if channel == CHANNEL_RELEASE {
197        return base.to_string();
198    }
199    let next = match Version::parse(base) {
200        Some(v) => format!("{}.{}.{}", v.major, v.minor, v.patch + 1),
201        None => base.to_string(),
202    };
203    match revision {
204        Some(rev) => {
205            let short: String = rev.chars().take(7).collect();
206            format!("{next}-dev+g{short}")
207        }
208        None => format!("{next}-dev"),
209    }
210}
211
212/// The `User-Agent` a client sends: `recall/<version> (<os>-<arch>)`, the
213/// way git sends `agent=git/<version>`.
214pub fn user_agent() -> String {
215    format!(
216        "recall/{} ({}-{})",
217        version(),
218        std::env::consts::OS,
219        std::env::consts::ARCH
220    )
221}
222
223/// A SemVer version, enough of it to compare two.
224///
225/// Build metadata is dropped on parsing: SemVer says it *"MUST be ignored
226/// when determining version precedence"*.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct Version {
229    /// Major.
230    pub major: u64,
231    /// Minor.
232    pub minor: u64,
233    /// Patch.
234    pub patch: u64,
235    /// Pre-release identifiers, empty for a release.
236    pub pre: Vec<String>,
237}
238
239impl Version {
240    /// Parses `1.2.3`, `1.2.3-dev`, `1.2.3-rc.1+build`. [`None`] for
241    /// anything else.
242    pub fn parse(text: &str) -> Option<Self> {
243        let text = text.trim().trim_start_matches('v');
244        let text = text.split('+').next()?;
245        let (core, pre) = match text.split_once('-') {
246            Some((core, pre)) => (core, pre.split('.').map(str::to_string).collect()),
247            None => (text, Vec::new()),
248        };
249        let mut parts = core.split('.');
250        let major = parts.next()?.parse().ok()?;
251        let minor = parts.next()?.parse().ok()?;
252        let patch = parts.next()?.parse().ok()?;
253        if parts.next().is_some() {
254            return None;
255        }
256        Some(Self {
257            major,
258            minor,
259            patch,
260            pre,
261        })
262    }
263}
264
265impl PartialOrd for Version {
266    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
267        Some(self.cmp(other))
268    }
269}
270
271impl Ord for Version {
272    /// SemVer §11: major, minor and patch numerically; then a version with
273    /// a pre-release before the same version without one; then pre-release
274    /// identifiers left to right, numeric ones numerically and before
275    /// alphanumeric ones, and a shorter list before a longer one it prefixes.
276    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
277        use std::cmp::Ordering;
278        let core =
279            (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
280        if core != Ordering::Equal {
281            return core;
282        }
283        match (self.pre.is_empty(), other.pre.is_empty()) {
284            (true, true) => return Ordering::Equal,
285            (true, false) => return Ordering::Greater,
286            (false, true) => return Ordering::Less,
287            (false, false) => {}
288        }
289        for (a, b) in self.pre.iter().zip(&other.pre) {
290            let order = match (a.parse::<u64>(), b.parse::<u64>()) {
291                (Ok(x), Ok(y)) => x.cmp(&y),
292                (Ok(_), Err(_)) => Ordering::Less,
293                (Err(_), Ok(_)) => Ordering::Greater,
294                (Err(_), Err(_)) => a.cmp(b),
295            };
296            if order != Ordering::Equal {
297                return order;
298            }
299        }
300        self.pre.len().cmp(&other.pre.len())
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn v(text: &str) -> Version {
309        Version::parse(text).unwrap()
310    }
311
312    /// The ordering examples from SemVer §11, in order.
313    #[test]
314    fn versions_order_the_way_semver_says() {
315        let ordered = [
316            "1.0.0-alpha",
317            "1.0.0-alpha.1",
318            "1.0.0-alpha.beta",
319            "1.0.0-beta",
320            "1.0.0-beta.2",
321            "1.0.0-beta.11",
322            "1.0.0-rc.1",
323            "1.0.0",
324            "2.0.0",
325            "2.1.0",
326            "2.1.1",
327        ];
328        for pair in ordered.windows(2) {
329            assert!(v(pair[0]) < v(pair[1]), "{} < {}", pair[0], pair[1]);
330        }
331    }
332
333    #[test]
334    fn build_metadata_does_not_count() {
335        assert_eq!(v("0.3.3-dev+g1a2b3c4"), v("0.3.3-dev+gffffff0"));
336        assert!(v("0.3.2") < v("0.3.3-dev+g1a2b3c4"));
337        assert!(v("0.3.3-dev+g1a2b3c4") < v("0.3.3"));
338    }
339
340    #[test]
341    fn what_is_not_a_version_is_refused() {
342        for bad in ["", "1", "1.2", "1.2.3.4", "a.b.c", "1.2.x"] {
343            assert_eq!(Version::parse(bad), None, "{bad:?}");
344        }
345        assert_eq!(v("v1.2.3"), v("1.2.3"));
346    }
347
348    #[test]
349    fn a_release_build_is_its_release_and_anything_else_the_next_dev() {
350        let base = env!("CARGO_PKG_VERSION");
351        assert_eq!(version_for(CHANNEL_RELEASE, Some("abc")), base);
352        let dev = version_for(CHANNEL_DEV, Some("e100cfdd88e8a0e6659b"));
353        assert!(dev.ends_with("-dev+ge100cfd"), "{dev}");
354        assert!(v(base) < v(&dev), "{base} < {dev}");
355        assert!(version_for(CHANNEL_DEV, None).ends_with("-dev"));
356    }
357
358    /// A document from a newer server, with a key and a capability this
359    /// build has never heard of, still reads, and still answers.
360    #[test]
361    fn unknown_keys_are_ignored_and_absent_capabilities_are_unsupported() {
362        let doc: Discovery = serde_json::from_str(
363            r#"{
364                "protocol": {"current": 2, "supported": [1, 2]},
365                "server": {"version": "0.9.0", "build": {"channel": "release", "signed": true}},
366                "min_client": "0.3.0",
367                "auth": {"methods": ["device-sig-v1", "bearer"]},
368                "capabilities": {"merge_base": {}, "telepathy": {"level": 3}},
369                "operator": {"contact": "someone"}
370            }"#,
371        )
372        .unwrap();
373        assert!(doc.speaks(1) && doc.speaks(2) && !doc.speaks(3));
374        assert!(doc.can("merge_base") && doc.can("telepathy"));
375        assert!(!doc.can("scopes"));
376        assert!(doc.accepts(AUTH_DEVICE_SIG) && doc.accepts(AUTH_BEARER));
377        assert!(!doc.accepts("passkey"));
378        assert_eq!(doc.devices(), None, "not listed, so not supported");
379    }
380
381    /// A devices capability with keys this build has never heard of still
382    /// reads; one missing a key it needs does not, and so is unusable.
383    #[test]
384    fn the_devices_capability_reads_into_its_type() {
385        let mut doc: Discovery = serde_json::from_str(
386            r#"{
387                "protocol": {"current": 1, "supported": [1]},
388                "server": {"version": "0.4.1", "build": {"channel": "release"}},
389                "min_client": "0.1.0",
390                "auth": {"methods": ["bearer", "device-sig-v1"]},
391                "capabilities": {"devices": {
392                    "enroll_path": "/v1/devices/enroll", "code_ttl_seconds": 900,
393                    "poll_interval_seconds": 5, "signature_window_seconds": 60,
394                    "passkeys": {}
395                }}
396            }"#,
397        )
398        .unwrap();
399        let devices = doc.devices().unwrap();
400        assert_eq!(devices.enroll_path, "/v1/devices/enroll");
401        assert_eq!(devices.signature_window_seconds, 60);
402
403        doc.capabilities.insert(
404            CAPABILITY_DEVICES.into(),
405            serde_json::json!({"enroll_path": "/x"}),
406        );
407        assert_eq!(doc.devices(), None);
408    }
409
410    #[test]
411    fn the_user_agent_names_the_version_and_platform() {
412        let ua = user_agent();
413        assert!(ua.starts_with("recall/"), "{ua}");
414        assert!(ua.contains(std::env::consts::OS), "{ua}");
415    }
416}