1use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29pub const DISCOVERY_PATH: &str = "/.well-known/recall";
31
32pub const PROTOCOL: u32 = 1;
34
35pub const PROTOCOL_HEADER: &str = "recall-protocol";
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct Discovery {
43 pub protocol: Protocol,
45 pub server: ServerInfo,
47 pub min_client: String,
49 pub auth: Auth,
51 #[serde(default)]
53 pub capabilities: BTreeMap<String, serde_json::Value>,
54}
55
56impl Discovery {
57 pub fn speaks(&self, version: u32) -> bool {
59 self.protocol.supported.contains(&version)
60 }
61
62 pub fn can(&self, name: &str) -> bool {
64 self.capabilities.contains_key(name)
65 }
66
67 pub fn accepts(&self, name: &str) -> bool {
70 self.auth.methods.iter().any(|m| m == name)
71 }
72
73 pub fn devices(&self) -> Option<crate::DevicesCapability> {
77 serde_json::from_value(self.capabilities.get(CAPABILITY_DEVICES)?.clone()).ok()
78 }
79
80 pub fn audit(&self) -> Option<crate::AuditCapability> {
84 serde_json::from_value(self.capabilities.get(CAPABILITY_AUDIT)?.clone()).ok()
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct Protocol {
91 pub current: u32,
93 pub supported: Vec<u32>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ServerInfo {
100 pub version: String,
103 pub build: Build,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Build {
110 pub channel: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub revision: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub created: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct Auth {
123 pub methods: Vec<String>,
125}
126
127pub const AUTH_BEARER: &str = "bearer";
129
130pub const AUTH_DEVICE_SIG: &str = "device-sig-v1";
133
134pub const CAPABILITY_DEVICES: &str = "devices";
137
138pub const CAPABILITY_MERGE_QUEUE: &str = "merge_queue";
142
143pub const CAPABILITY_EVALUATION: &str = "evaluation";
146
147pub const CAPABILITY_AUDIT: &str = "audit";
151
152pub const CHANNEL_RELEASE: &str = "release";
154
155pub const CHANNEL_DEV: &str = "dev";
158
159pub fn channel() -> &'static str {
164 match env!("RECALL_RESOLVED_CHANNEL") {
165 CHANNEL_RELEASE => CHANNEL_RELEASE,
166 _ => CHANNEL_DEV,
167 }
168}
169
170pub fn revision() -> Option<&'static str> {
172 option_env!("RECALL_GIT_COMMIT").filter(|r| !r.is_empty())
173}
174
175pub fn created() -> Option<&'static str> {
177 option_env!("RECALL_BUILD_CREATED").filter(|c| !c.is_empty())
178}
179
180pub fn version() -> String {
182 version_for(channel(), revision())
183}
184
185pub fn version_for(channel: &str, revision: Option<&str>) -> String {
193 let base = env!("CARGO_PKG_VERSION");
194 if channel == CHANNEL_RELEASE {
195 return base.to_string();
196 }
197 let next = match Version::parse(base) {
198 Some(v) => format!("{}.{}.{}", v.major, v.minor, v.patch + 1),
199 None => base.to_string(),
200 };
201 match revision {
202 Some(rev) => {
203 let short: String = rev.chars().take(7).collect();
204 format!("{next}-dev+g{short}")
205 }
206 None => format!("{next}-dev"),
207 }
208}
209
210pub fn user_agent() -> String {
213 format!(
214 "recall/{} ({}-{})",
215 version(),
216 std::env::consts::OS,
217 std::env::consts::ARCH
218 )
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct Version {
227 pub major: u64,
229 pub minor: u64,
231 pub patch: u64,
233 pub pre: Vec<String>,
235}
236
237impl Version {
238 pub fn parse(text: &str) -> Option<Self> {
241 let text = text.trim().trim_start_matches('v');
242 let text = text.split('+').next()?;
243 let (core, pre) = match text.split_once('-') {
244 Some((core, pre)) => (core, pre.split('.').map(str::to_string).collect()),
245 None => (text, Vec::new()),
246 };
247 let mut parts = core.split('.');
248 let major = parts.next()?.parse().ok()?;
249 let minor = parts.next()?.parse().ok()?;
250 let patch = parts.next()?.parse().ok()?;
251 if parts.next().is_some() {
252 return None;
253 }
254 Some(Self {
255 major,
256 minor,
257 patch,
258 pre,
259 })
260 }
261}
262
263impl PartialOrd for Version {
264 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
265 Some(self.cmp(other))
266 }
267}
268
269impl Ord for Version {
270 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
275 use std::cmp::Ordering;
276 let core =
277 (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
278 if core != Ordering::Equal {
279 return core;
280 }
281 match (self.pre.is_empty(), other.pre.is_empty()) {
282 (true, true) => return Ordering::Equal,
283 (true, false) => return Ordering::Greater,
284 (false, true) => return Ordering::Less,
285 (false, false) => {}
286 }
287 for (a, b) in self.pre.iter().zip(&other.pre) {
288 let order = match (a.parse::<u64>(), b.parse::<u64>()) {
289 (Ok(x), Ok(y)) => x.cmp(&y),
290 (Ok(_), Err(_)) => Ordering::Less,
291 (Err(_), Ok(_)) => Ordering::Greater,
292 (Err(_), Err(_)) => a.cmp(b),
293 };
294 if order != Ordering::Equal {
295 return order;
296 }
297 }
298 self.pre.len().cmp(&other.pre.len())
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn v(text: &str) -> Version {
307 Version::parse(text).unwrap()
308 }
309
310 #[test]
312 fn versions_order_the_way_semver_says() {
313 let ordered = [
314 "1.0.0-alpha",
315 "1.0.0-alpha.1",
316 "1.0.0-alpha.beta",
317 "1.0.0-beta",
318 "1.0.0-beta.2",
319 "1.0.0-beta.11",
320 "1.0.0-rc.1",
321 "1.0.0",
322 "2.0.0",
323 "2.1.0",
324 "2.1.1",
325 ];
326 for pair in ordered.windows(2) {
327 assert!(v(pair[0]) < v(pair[1]), "{} < {}", pair[0], pair[1]);
328 }
329 }
330
331 #[test]
332 fn build_metadata_does_not_count() {
333 assert_eq!(v("0.3.3-dev+g1a2b3c4"), v("0.3.3-dev+gffffff0"));
334 assert!(v("0.3.2") < v("0.3.3-dev+g1a2b3c4"));
335 assert!(v("0.3.3-dev+g1a2b3c4") < v("0.3.3"));
336 }
337
338 #[test]
339 fn what_is_not_a_version_is_refused() {
340 for bad in ["", "1", "1.2", "1.2.3.4", "a.b.c", "1.2.x"] {
341 assert_eq!(Version::parse(bad), None, "{bad:?}");
342 }
343 assert_eq!(v("v1.2.3"), v("1.2.3"));
344 }
345
346 #[test]
347 fn a_release_build_is_its_release_and_anything_else_the_next_dev() {
348 let base = env!("CARGO_PKG_VERSION");
349 assert_eq!(version_for(CHANNEL_RELEASE, Some("abc")), base);
350 let dev = version_for(CHANNEL_DEV, Some("e100cfdd88e8a0e6659b"));
351 assert!(dev.ends_with("-dev+ge100cfd"), "{dev}");
352 assert!(v(base) < v(&dev), "{base} < {dev}");
353 assert!(version_for(CHANNEL_DEV, None).ends_with("-dev"));
354 }
355
356 #[test]
359 fn unknown_keys_are_ignored_and_absent_capabilities_are_unsupported() {
360 let doc: Discovery = serde_json::from_str(
361 r#"{
362 "protocol": {"current": 2, "supported": [1, 2]},
363 "server": {"version": "0.9.0", "build": {"channel": "release", "signed": true}},
364 "min_client": "0.3.0",
365 "auth": {"methods": ["device-sig-v1", "bearer"]},
366 "capabilities": {"merge_base": {}, "telepathy": {"level": 3}},
367 "operator": {"contact": "someone"}
368 }"#,
369 )
370 .unwrap();
371 assert!(doc.speaks(1) && doc.speaks(2) && !doc.speaks(3));
372 assert!(doc.can("merge_base") && doc.can("telepathy"));
373 assert!(!doc.can("scopes"));
374 assert!(doc.accepts(AUTH_DEVICE_SIG) && doc.accepts(AUTH_BEARER));
375 assert!(!doc.accepts("passkey"));
376 assert_eq!(doc.devices(), None, "not listed, so not supported");
377 }
378
379 #[test]
382 fn the_devices_capability_reads_into_its_type() {
383 let mut doc: Discovery = serde_json::from_str(
384 r#"{
385 "protocol": {"current": 1, "supported": [1]},
386 "server": {"version": "0.4.1", "build": {"channel": "release"}},
387 "min_client": "0.1.0",
388 "auth": {"methods": ["bearer", "device-sig-v1"]},
389 "capabilities": {"devices": {
390 "enroll_path": "/v1/devices/enroll", "code_ttl_seconds": 900,
391 "poll_interval_seconds": 5, "signature_window_seconds": 60,
392 "passkeys": {}
393 }}
394 }"#,
395 )
396 .unwrap();
397 let devices = doc.devices().unwrap();
398 assert_eq!(devices.enroll_path, "/v1/devices/enroll");
399 assert_eq!(devices.signature_window_seconds, 60);
400
401 doc.capabilities.insert(
402 CAPABILITY_DEVICES.into(),
403 serde_json::json!({"enroll_path": "/x"}),
404 );
405 assert_eq!(doc.devices(), None);
406 }
407
408 #[test]
409 fn the_user_agent_names_the_version_and_platform() {
410 let ua = user_agent();
411 assert!(ua.starts_with("recall/"), "{ua}");
412 assert!(ua.contains(std::env::consts::OS), "{ua}");
413 }
414}