use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const DISCOVERY_PATH: &str = "/.well-known/recall";
pub const PROTOCOL: u32 = 1;
pub const PROTOCOL_HEADER: &str = "recall-protocol";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Discovery {
pub protocol: Protocol,
pub server: ServerInfo,
pub min_client: String,
pub auth: Auth,
#[serde(default)]
pub capabilities: BTreeMap<String, serde_json::Value>,
}
impl Discovery {
pub fn speaks(&self, version: u32) -> bool {
self.protocol.supported.contains(&version)
}
pub fn can(&self, name: &str) -> bool {
self.capabilities.contains_key(name)
}
pub fn accepts(&self, name: &str) -> bool {
self.auth.methods.iter().any(|m| m == name)
}
pub fn devices(&self) -> Option<crate::DevicesCapability> {
serde_json::from_value(self.capabilities.get(CAPABILITY_DEVICES)?.clone()).ok()
}
pub fn audit(&self) -> Option<crate::AuditCapability> {
serde_json::from_value(self.capabilities.get(CAPABILITY_AUDIT)?.clone()).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Protocol {
pub current: u32,
pub supported: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerInfo {
pub version: String,
pub build: Build,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Build {
pub channel: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Auth {
pub methods: Vec<String>,
}
pub const AUTH_BEARER: &str = "bearer";
pub const AUTH_DEVICE_SIG: &str = "device-sig-v1";
pub const CAPABILITY_DEVICES: &str = "devices";
pub const CAPABILITY_MERGE_QUEUE: &str = "merge_queue";
pub const CAPABILITY_EVALUATION: &str = "evaluation";
pub const CAPABILITY_AUDIT: &str = "audit";
pub const CHANNEL_RELEASE: &str = "release";
pub const CHANNEL_DEV: &str = "dev";
pub fn channel() -> &'static str {
match env!("RECALL_RESOLVED_CHANNEL") {
CHANNEL_RELEASE => CHANNEL_RELEASE,
_ => CHANNEL_DEV,
}
}
pub fn revision() -> Option<&'static str> {
option_env!("RECALL_GIT_COMMIT").filter(|r| !r.is_empty())
}
pub fn created() -> Option<&'static str> {
option_env!("RECALL_BUILD_CREATED").filter(|c| !c.is_empty())
}
pub fn version() -> String {
version_for(channel(), revision())
}
pub fn version_for(channel: &str, revision: Option<&str>) -> String {
let base = env!("CARGO_PKG_VERSION");
if channel == CHANNEL_RELEASE {
return base.to_string();
}
let next = match Version::parse(base) {
Some(v) => format!("{}.{}.{}", v.major, v.minor, v.patch + 1),
None => base.to_string(),
};
match revision {
Some(rev) => {
let short: String = rev.chars().take(7).collect();
format!("{next}-dev+g{short}")
}
None => format!("{next}-dev"),
}
}
pub fn user_agent() -> String {
format!(
"recall/{} ({}-{})",
version(),
std::env::consts::OS,
std::env::consts::ARCH
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Version {
pub major: u64,
pub minor: u64,
pub patch: u64,
pub pre: Vec<String>,
}
impl Version {
pub fn parse(text: &str) -> Option<Self> {
let text = text.trim().trim_start_matches('v');
let text = text.split('+').next()?;
let (core, pre) = match text.split_once('-') {
Some((core, pre)) => (core, pre.split('.').map(str::to_string).collect()),
None => (text, Vec::new()),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(Self {
major,
minor,
patch,
pre,
})
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
let core =
(self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
if core != Ordering::Equal {
return core;
}
match (self.pre.is_empty(), other.pre.is_empty()) {
(true, true) => return Ordering::Equal,
(true, false) => return Ordering::Greater,
(false, true) => return Ordering::Less,
(false, false) => {}
}
for (a, b) in self.pre.iter().zip(&other.pre) {
let order = match (a.parse::<u64>(), b.parse::<u64>()) {
(Ok(x), Ok(y)) => x.cmp(&y),
(Ok(_), Err(_)) => Ordering::Less,
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => a.cmp(b),
};
if order != Ordering::Equal {
return order;
}
}
self.pre.len().cmp(&other.pre.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn v(text: &str) -> Version {
Version::parse(text).unwrap()
}
#[test]
fn versions_order_the_way_semver_says() {
let ordered = [
"1.0.0-alpha",
"1.0.0-alpha.1",
"1.0.0-alpha.beta",
"1.0.0-beta",
"1.0.0-beta.2",
"1.0.0-beta.11",
"1.0.0-rc.1",
"1.0.0",
"2.0.0",
"2.1.0",
"2.1.1",
];
for pair in ordered.windows(2) {
assert!(v(pair[0]) < v(pair[1]), "{} < {}", pair[0], pair[1]);
}
}
#[test]
fn build_metadata_does_not_count() {
assert_eq!(v("0.3.3-dev+g1a2b3c4"), v("0.3.3-dev+gffffff0"));
assert!(v("0.3.2") < v("0.3.3-dev+g1a2b3c4"));
assert!(v("0.3.3-dev+g1a2b3c4") < v("0.3.3"));
}
#[test]
fn what_is_not_a_version_is_refused() {
for bad in ["", "1", "1.2", "1.2.3.4", "a.b.c", "1.2.x"] {
assert_eq!(Version::parse(bad), None, "{bad:?}");
}
assert_eq!(v("v1.2.3"), v("1.2.3"));
}
#[test]
fn a_release_build_is_its_release_and_anything_else_the_next_dev() {
let base = env!("CARGO_PKG_VERSION");
assert_eq!(version_for(CHANNEL_RELEASE, Some("abc")), base);
let dev = version_for(CHANNEL_DEV, Some("e100cfdd88e8a0e6659b"));
assert!(dev.ends_with("-dev+ge100cfd"), "{dev}");
assert!(v(base) < v(&dev), "{base} < {dev}");
assert!(version_for(CHANNEL_DEV, None).ends_with("-dev"));
}
#[test]
fn unknown_keys_are_ignored_and_absent_capabilities_are_unsupported() {
let doc: Discovery = serde_json::from_str(
r#"{
"protocol": {"current": 2, "supported": [1, 2]},
"server": {"version": "0.9.0", "build": {"channel": "release", "signed": true}},
"min_client": "0.3.0",
"auth": {"methods": ["device-sig-v1", "bearer"]},
"capabilities": {"merge_base": {}, "telepathy": {"level": 3}},
"operator": {"contact": "someone"}
}"#,
)
.unwrap();
assert!(doc.speaks(1) && doc.speaks(2) && !doc.speaks(3));
assert!(doc.can("merge_base") && doc.can("telepathy"));
assert!(!doc.can("scopes"));
assert!(doc.accepts(AUTH_DEVICE_SIG) && doc.accepts(AUTH_BEARER));
assert!(!doc.accepts("passkey"));
assert_eq!(doc.devices(), None, "not listed, so not supported");
}
#[test]
fn the_devices_capability_reads_into_its_type() {
let mut doc: Discovery = serde_json::from_str(
r#"{
"protocol": {"current": 1, "supported": [1]},
"server": {"version": "0.4.1", "build": {"channel": "release"}},
"min_client": "0.1.0",
"auth": {"methods": ["bearer", "device-sig-v1"]},
"capabilities": {"devices": {
"enroll_path": "/v1/devices/enroll", "code_ttl_seconds": 900,
"poll_interval_seconds": 5, "signature_window_seconds": 60,
"passkeys": {}
}}
}"#,
)
.unwrap();
let devices = doc.devices().unwrap();
assert_eq!(devices.enroll_path, "/v1/devices/enroll");
assert_eq!(devices.signature_window_seconds, 60);
doc.capabilities.insert(
CAPABILITY_DEVICES.into(),
serde_json::json!({"enroll_path": "/x"}),
);
assert_eq!(doc.devices(), None);
}
#[test]
fn the_user_agent_names_the_version_and_platform() {
let ua = user_agent();
assert!(ua.starts_with("recall/"), "{ua}");
assert!(ua.contains(std::env::consts::OS), "{ua}");
}
}