diff options
Diffstat (limited to 'adenosine')
-rw-r--r-- | adenosine/Cargo.toml | 37 | ||||
-rw-r--r-- | adenosine/src/app_bsky/mod.rs | 165 | ||||
-rw-r--r-- | adenosine/src/auth.rs | 37 | ||||
-rw-r--r-- | adenosine/src/com_atproto/mod.rs | 29 | ||||
-rw-r--r-- | adenosine/src/com_atproto/repo/mod.rs | 53 | ||||
-rw-r--r-- | adenosine/src/ipld.rs | 47 | ||||
-rw-r--r-- | adenosine/src/lib.rs | 231 | ||||
-rw-r--r-- | adenosine/src/plc.rs (renamed from adenosine/src/did.rs) | 0 | ||||
-rw-r--r-- | adenosine/src/xrpc.rs | 186 | ||||
-rw-r--r-- | adenosine/tests/bigger.car | bin | 0 -> 60050 bytes | |||
-rw-r--r-- | adenosine/tests/example_repo.car | bin | 0 -> 7390 bytes | |||
-rw-r--r-- | adenosine/tests/test_mst_interop.rs | 152 | ||||
-rw-r--r-- | adenosine/tests/test_repro_mst.rs | 26 |
13 files changed, 721 insertions, 242 deletions
diff --git a/adenosine/Cargo.toml b/adenosine/Cargo.toml index 456b0fe..f4705ae 100644 --- a/adenosine/Cargo.toml +++ b/adenosine/Cargo.toml @@ -15,33 +15,32 @@ repository = "https://gitlab.com/bnewbold/adenosine" [dependencies] # NOTE: could try 'rustls-tls' feature instead of default native TLS? -reqwest = { version = "0.11", features = ["blocking", "json"] } -serde = { version = "1", features = ["serde_derive"] } -serde_json = "1" +anyhow = "1" +async-trait = "0.1" base64 = "0.13" -regex = "1" -lazy_static = "1" +bs58 = "0.4" data-encoding = "2" +futures = "0.3" +ipfs-sqlite-block-store = "0.13" +lazy_static = "1" +libipld = { version = "0.14", features = ["dag-cbor", "derive"] } +log = "0.4" +multibase = "0.9" rand = "0.8" +regex = "1" +reqwest = { version = "0.11", features = ["blocking", "json"] } +serde = { version = "1", features = ["serde_derive"] } +serde_json = "1" +sha256 = "1" time = { version = "0.3", features = ["formatting"] } +tokio = { version = "1", features = ["full"] } + +# crypto/auth stuff k256 = { version = "0.11", features = ["ecdsa"] } p256 = { version = "0.11", features = ["ecdsa"] } -bs58 = "0.4" -libipld = "0.14" -ipfs-sqlite-block-store = "0.13" -tokio = { version = "1", features = ["full"] } -futures = "0.3" -sha256 = "1" -multibase = "0.9" ucan = "0.7.0-alpha.1" -async-trait = "0.1" # for vendored iroh-car -thiserror = "1.0" integer-encoding = { version = "3", features = ["tokio_async"] } multihash = "0.16" - -# uncertain about these... -anyhow = "1" -log = "0.4" -env_logger = "0.7" +thiserror = "1.0" diff --git a/adenosine/src/app_bsky/mod.rs b/adenosine/src/app_bsky/mod.rs new file mode 100644 index 0000000..18a0449 --- /dev/null +++ b/adenosine/src/app_bsky/mod.rs @@ -0,0 +1,165 @@ +/// app.bsky types (manually entered) +use serde_json::Value; + +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Subject { + pub uri: String, + // TODO: CID is required + pub cid: Option<String>, +} + +/// Generic over Re-post and Like +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct RefRecord { + pub subject: Subject, + pub createdAt: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct FollowSubject { + pub did: String, + // pub declarationCid: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct FollowRecord { + pub subject: FollowSubject, + pub createdAt: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct ProfileRecord { + pub displayName: String, + pub description: Option<String>, +} + +// app.bsky.system.actorUser or app.bsky.system.actorScene +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Declaration { + pub actorType: String, +} + +// actorType: app.bsky.system.actorUser +// cid: bafyreid27zk7lbis4zw5fz4podbvbs4fc5ivwji3dmrwa6zggnj4bnd57u +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct DeclRef { + pub actorType: String, + pub cid: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Profile { + pub did: String, + pub declaration: DeclRef, + pub handle: String, + // for simple accounts, 'creator' is just the did + pub creator: String, + pub displayName: Option<String>, + pub description: Option<String>, + pub followersCount: u64, + pub followsCount: u64, + pub membersCount: u64, + pub postsCount: u64, + pub myState: serde_json::Value, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct GenericFeed { + pub feed: Vec<FeedItem>, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct User { + pub did: String, + pub handle: String, + pub displayName: Option<String>, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct FeedItem { + pub uri: String, + pub cid: String, + pub author: User, + pub repostedBy: Option<User>, + pub record: Value, + //pub embed?: RecordEmbed | ExternalEmbed | UnknownEmbed, + pub embed: Option<Value>, + pub replyCount: u64, + pub repostCount: u64, + pub upvoteCount: u64, + pub downvoteCount: u64, + pub indexedAt: String, + pub myState: Option<Value>, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Post { + pub text: String, + pub reply: Option<PostReply>, + pub createdAt: Option<String>, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct PostReply { + pub parent: Subject, + pub root: Subject, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct PostThread { + pub thread: ThreadItem, +} + +// TODO: 'parent' and 'replies' should allow "NotFoundPost" for references that point to an unknown +// URI +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct ThreadItem { + pub uri: String, + pub cid: String, + pub author: User, + pub record: Value, + //pub embed?: RecordEmbed | ExternalEmbed | UnknownEmbed, + pub embed: Option<Value>, + pub parent: Option<Box<ThreadItem>>, + pub replyCount: u64, + pub replies: Option<Vec<ThreadItem>>, + pub repostCount: u64, + pub upvoteCount: u64, + pub downvoteCount: u64, + pub indexedAt: String, + pub myState: Option<Value>, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct FollowTarget { + // TODO: nested follow list? + pub subject: Subject, + pub did: String, + pub handle: String, + pub displayName: Option<String>, + pub createdAt: Option<String>, + pub indexedAt: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Follow { + // TODO: nested follow list? + pub subject: Subject, + pub follows: FollowTarget, +} diff --git a/adenosine/src/auth.rs b/adenosine/src/auth.rs new file mode 100644 index 0000000..ff931ef --- /dev/null +++ b/adenosine/src/auth.rs @@ -0,0 +1,37 @@ +use anyhow::anyhow; +pub use anyhow::Result; +use serde_json::Value; + +/// Tries to parse a DID internal identifier from a JWT (as base64-encoded token) +pub fn parse_did_from_jwt(jwt: &str) -> Result<String> { + let second_b64 = jwt.split('.').nth(1).ok_or(anyhow!("couldn't parse JWT"))?; + let second_json: Vec<u8> = base64::decode_config(second_b64, base64::URL_SAFE)?; + let obj: Value = serde_json::from_slice(&second_json)?; + // trying to also support pulling "aud" as DID; not sure this is actually correct use of + // UCAN/JWT semantics? + let did = obj["sub"] + .as_str() + .or(obj["aud"].as_str()) + .ok_or(anyhow!("couldn't find DID subject in JWT"))? + .to_string(); + if !did.starts_with("did:") { + return Err(anyhow!("couldn't find DID subject in JWT")); + } + Ok(did) +} + +#[test] +fn test_parse_jwt() { + assert!(parse_did_from_jwt(".").is_err()); + // JWT from atproto ("sub") + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(), + "did:plc:ex3si527cd2aogbvidkooozc", + ); + // UCAN from adenosine-pds ("aud") + assert_eq!( + parse_did_from_jwt("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4wLWNhbmFyeSJ9.eyJhdHQiOltdLCJhdWQiOiJkaWQ6cGxjOnM3b25ieWphN2MzeXJzZ3Zob2xrbHM1YiIsImV4cCI6MTY3NTM4Mzg2NywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6RG5hZWRHVGJkb0Frb1NlOG96a3k1WHAzMjZTVFpUSm50aDlHY2dxaTZQYjNzYjczIiwibm5jIjoiTnZURDhENWZjNXFpalIyMWJ1V2Z1ZE02dzlBM2drSy1ac3RtUW03b21pdyIsInByZiI6W119.QwZkb9R17tNhXnY_roqFYgdiIgUnSC18FYWQb3PcH6BU1R5l4W_T4XdACyczPGfM-jAnF2r2loBXDntYVS6N5A").unwrap(), + "did:plc:s7onbyja7c3yrsgvholkls5b", + ); + assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err()); +} diff --git a/adenosine/src/com_atproto/mod.rs b/adenosine/src/com_atproto/mod.rs new file mode 100644 index 0000000..8e2317a --- /dev/null +++ b/adenosine/src/com_atproto/mod.rs @@ -0,0 +1,29 @@ +// com.atproto types (manually entered) + +pub mod repo; + +#[allow(non_snake_case)] +#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +pub struct AccountRequest { + pub email: String, + pub handle: String, + pub password: String, + pub inviteCode: Option<String>, + pub recoveryKey: Option<String>, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +pub struct SessionRequest { + pub handle: String, + pub password: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Session { + pub did: String, + pub name: String, + pub accessJwt: String, + pub refreshJwt: String, +} diff --git a/adenosine/src/com_atproto/repo/mod.rs b/adenosine/src/com_atproto/repo/mod.rs new file mode 100644 index 0000000..aa66e98 --- /dev/null +++ b/adenosine/src/com_atproto/repo/mod.rs @@ -0,0 +1,53 @@ +/// com.atproto.repo types (manually entered) + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct Describe { + pub name: String, + pub did: String, + pub didDoc: serde_json::Value, + pub collections: Vec<String>, + pub nameIsCorrect: bool, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct CreateRecord { + pub did: String, + pub collection: String, + pub record: serde_json::Value, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct PutRecord { + pub did: String, + pub collection: String, + pub rkey: String, + pub record: serde_json::Value, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct DeleteRecord { + pub did: String, + pub collection: String, + pub rkey: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct BatchWriteBody { + pub did: String, + pub writes: Vec<BatchWrite>, +} + +#[allow(non_snake_case)] +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)] +pub struct BatchWrite { + #[serde(rename = "type")] + pub op_type: String, + pub collection: String, + pub rkey: Option<String>, + pub value: serde_json::Value, +} diff --git a/adenosine/src/ipld.rs b/adenosine/src/ipld.rs new file mode 100644 index 0000000..65a56e4 --- /dev/null +++ b/adenosine/src/ipld.rs @@ -0,0 +1,47 @@ +use libipld::{Cid, Ipld}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::str::FromStr; + +/// Intentionally serializing with this instead of DAG-JSON, because ATP schemas don't encode CID +/// links in any special way, they just pass the CID as a string. +pub fn ipld_into_json_value(val: Ipld) -> Value { + match val { + Ipld::Null => Value::Null, + Ipld::Bool(b) => Value::Bool(b), + Ipld::Integer(v) => json!(v), + Ipld::Float(v) => json!(v), + Ipld::String(s) => Value::String(s), + Ipld::Bytes(b) => Value::String(data_encoding::BASE64_NOPAD.encode(&b)), + Ipld::List(l) => Value::Array(l.into_iter().map(ipld_into_json_value).collect()), + Ipld::Map(m) => Value::Object(serde_json::Map::from_iter( + m.into_iter().map(|(k, v)| (k, ipld_into_json_value(v))), + )), + Ipld::Link(c) => Value::String(c.to_string()), + } +} + +/// Crude reverse generation +/// +/// Does not handle base64 to bytes, and the link generation is pretty simple (object elements with +/// key "car"). Numbers always come through as f64 (float). +pub fn json_value_into_ipld(val: Value) -> Ipld { + match val { + Value::Null => Ipld::Null, + Value::Bool(b) => Ipld::Bool(b), + Value::String(s) => Ipld::String(s), + // TODO: handle numbers better? + Value::Number(v) => Ipld::Float(v.as_f64().unwrap()), + Value::Array(l) => Ipld::List(l.into_iter().map(json_value_into_ipld).collect()), + Value::Object(m) => { + let map: BTreeMap<String, Ipld> = BTreeMap::from_iter(m.into_iter().map(|(k, v)| { + if k == "car" && v.is_string() { + (k, Ipld::Link(Cid::from_str(v.as_str().unwrap()).unwrap())) + } else { + (k, json_value_into_ipld(v)) + } + })); + Ipld::Map(map) + } + } +} diff --git a/adenosine/src/lib.rs b/adenosine/src/lib.rs index dc4a1b9..49701a1 100644 --- a/adenosine/src/lib.rs +++ b/adenosine/src/lib.rs @@ -1,232 +1,17 @@ -use anyhow::anyhow; -pub use anyhow::Result; -use reqwest::header; -use serde_json::Value; -use std::collections::HashMap; -use std::str::FromStr; -use std::time::Duration; - +pub mod app_bsky; +pub mod auth; pub mod car; +pub mod com_atproto; pub mod crypto; -pub mod did; pub mod identifiers; +pub mod ipld; pub mod mst; +pub mod plc; pub mod repo; -pub mod ucan_p256; -pub mod vendored; -use identifiers::Nsid; - -static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); - -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum XrpcMethod { - Get, - Post, -} - -impl FromStr for XrpcMethod { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - match s { - "get" => Ok(XrpcMethod::Get), - "post" => Ok(XrpcMethod::Post), - _ => Err(anyhow!("unknown method: {}", s)), - } - } -} - -pub struct XrpcClient { - http_client: reqwest::blocking::Client, - host: String, -} - -impl XrpcClient { - pub fn new(host: String, auth_token: Option<String>) -> Result<Self> { - let mut headers = header::HeaderMap::new(); - if let Some(token) = &auth_token { - let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?; - auth_value.set_sensitive(true); - headers.insert(header::AUTHORIZATION, auth_value); - }; - - let http_client = reqwest::blocking::Client::builder() - .default_headers(headers) - .user_agent(APP_USER_AGENT) - .timeout(Duration::from_secs(30)) - //.danger_accept_invalid_certs(true) - .build() - .expect("ERROR :: Could not build reqwest client"); +pub mod xrpc; - Ok(XrpcClient { http_client, host }) - } - - pub fn get( - &self, - nsid: &Nsid, - params: Option<HashMap<String, String>>, - ) -> Result<Option<Value>> { - log::debug!("XRPC GET endpoint={} params={:?}", nsid, params); - let params: HashMap<String, String> = params.unwrap_or_default(); - let res = self - .http_client - .get(format!("{}/xrpc/{}", self.host, nsid)) - .query(¶ms) - .send()?; - // TODO: refactor this error handling stuff into single method - if res.status() == 400 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Bad Request (400): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } else if res.status() == 500 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Internal Error (500): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } - let res = res.error_for_status()?; - Ok(res.json()?) - } - - pub fn get_to_writer<W: std::io::Write>( - &self, - nsid: &Nsid, - params: Option<HashMap<String, String>>, - output: &mut W, - ) -> Result<u64> { - let params: HashMap<String, String> = params.unwrap_or_default(); - let res = self - .http_client - .get(format!("{}/xrpc/{}", self.host, nsid)) - .query(¶ms) - .send()?; - if res.status() == 400 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Bad Request (400): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } else if res.status() == 500 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Internal Error (500): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } - let mut res = res.error_for_status()?; - Ok(res.copy_to(output)?) - } - - pub fn post( - &self, - nsid: &Nsid, - params: Option<HashMap<String, String>>, - body: Option<Value>, - ) -> Result<Option<Value>> { - let params: HashMap<String, String> = params.unwrap_or_default(); - log::debug!( - "XRPC POST endpoint={} params={:?} body={:?}", - nsid, - params, - body - ); - let mut req = self - .http_client - .post(format!("{}/xrpc/{}", self.host, nsid)) - .query(¶ms); - req = if let Some(b) = body { - req.json(&b) - } else { - req - }; - let res = req.send()?; - if res.status() == 400 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Bad Request (400): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } else if res.status() == 500 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Internal Error (500): {}", - val["message"].as_str().unwrap_or("unknown") - )); - } - let res = res.error_for_status()?; - if res.content_length() == Some(0) { - Ok(None) - } else { - Ok(res.json()?) - } - } - - pub fn post_cbor_from_reader<R: std::io::Read>( - &self, - nsid: &Nsid, - params: Option<HashMap<String, String>>, - input: &mut R, - ) -> Result<Option<Value>> { - let params: HashMap<String, String> = params.unwrap_or_default(); - let mut buf: Vec<u8> = Vec::new(); - input.read_to_end(&mut buf)?; - let res = self - .http_client - .post(format!("{}/xrpc/{}", self.host, nsid)) - .query(¶ms) - .header(reqwest::header::CONTENT_TYPE, "application/cbor") - .body(buf) - .send()?; - if res.status() == 400 { - let val: Value = res.json()?; - return Err(anyhow!( - "XRPC Bad Request: {}", - val["message"].as_str().unwrap_or("unknown") - )); - } - let res = res.error_for_status()?; - Ok(res.json()?) - } - - // reqwest::blocking::Body -} - -/// Tries to parse a DID internal identifier from a JWT (as base64-encoded token) -pub fn parse_did_from_jwt(jwt: &str) -> Result<String> { - let second_b64 = jwt.split('.').nth(1).ok_or(anyhow!("couldn't parse JWT"))?; - let second_json: Vec<u8> = base64::decode_config(second_b64, base64::URL_SAFE)?; - let obj: Value = serde_json::from_slice(&second_json)?; - // trying to also support pulling "aud" as DID; not sure this is actually correct use of - // UCAN/JWT semantics? - let did = obj["sub"] - .as_str() - .or(obj["aud"].as_str()) - .ok_or(anyhow!("couldn't find DID subject in JWT"))? - .to_string(); - if !did.starts_with("did:") { - return Err(anyhow!("couldn't find DID subject in JWT")); - } - Ok(did) -} - -#[test] -fn test_parse_jwt() { - assert!(parse_did_from_jwt(".").is_err()); - // JWT from atproto ("sub") - assert_eq!( - parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(), - "did:plc:ex3si527cd2aogbvidkooozc", - ); - // UCAN from adenosine-pds ("aud") - assert_eq!( - parse_did_from_jwt("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4wLWNhbmFyeSJ9.eyJhdHQiOltdLCJhdWQiOiJkaWQ6cGxjOnM3b25ieWphN2MzeXJzZ3Zob2xrbHM1YiIsImV4cCI6MTY3NTM4Mzg2NywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6RG5hZWRHVGJkb0Frb1NlOG96a3k1WHAzMjZTVFpUSm50aDlHY2dxaTZQYjNzYjczIiwibm5jIjoiTnZURDhENWZjNXFpalIyMWJ1V2Z1ZE02dzlBM2drSy1ac3RtUW03b21pdyIsInByZiI6W119.QwZkb9R17tNhXnY_roqFYgdiIgUnSC18FYWQb3PcH6BU1R5l4W_T4XdACyczPGfM-jAnF2r2loBXDntYVS6N5A").unwrap(), - "did:plc:s7onbyja7c3yrsgvholkls5b", - ); - assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err()); -} +mod ucan_p256; +mod vendored; /// Helper to generate the current timestamp as right now, UTC, formatted as an RFC 3339 string. /// diff --git a/adenosine/src/did.rs b/adenosine/src/plc.rs index c7d7d10..c7d7d10 100644 --- a/adenosine/src/did.rs +++ b/adenosine/src/plc.rs diff --git a/adenosine/src/xrpc.rs b/adenosine/src/xrpc.rs new file mode 100644 index 0000000..382c7fb --- /dev/null +++ b/adenosine/src/xrpc.rs @@ -0,0 +1,186 @@ +use crate::identifiers::Nsid; +use anyhow::anyhow; +pub use anyhow::Result; +use reqwest::header; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; +use std::time::Duration; + +static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum XrpcMethod { + Get, + Post, +} + +impl FromStr for XrpcMethod { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + "get" => Ok(XrpcMethod::Get), + "post" => Ok(XrpcMethod::Post), + _ => Err(anyhow!("unknown method: {}", s)), + } + } +} + +pub struct XrpcClient { + http_client: reqwest::blocking::Client, + host: String, +} + +impl XrpcClient { + pub fn new(host: String, auth_token: Option<String>) -> Result<Self> { + let mut headers = header::HeaderMap::new(); + if let Some(token) = &auth_token { + let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?; + auth_value.set_sensitive(true); + headers.insert(header::AUTHORIZATION, auth_value); + }; + + let http_client = reqwest::blocking::Client::builder() + .default_headers(headers) + .user_agent(APP_USER_AGENT) + .timeout(Duration::from_secs(30)) + //.danger_accept_invalid_certs(true) + .build() + .expect("ERROR :: Could not build reqwest client"); + + Ok(XrpcClient { http_client, host }) + } + + pub fn get( + &self, + nsid: &Nsid, + params: Option<HashMap<String, String>>, + ) -> Result<Option<Value>> { + log::debug!("XRPC GET endpoint={} params={:?}", nsid, params); + let params: HashMap<String, String> = params.unwrap_or_default(); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()?; + // TODO: refactor this error handling stuff into single method + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + Ok(res.json()?) + } + + pub fn get_to_writer<W: std::io::Write>( + &self, + nsid: &Nsid, + params: Option<HashMap<String, String>>, + output: &mut W, + ) -> Result<u64> { + let params: HashMap<String, String> = params.unwrap_or_default(); + let res = self + .http_client + .get(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let mut res = res.error_for_status()?; + Ok(res.copy_to(output)?) + } + + pub fn post( + &self, + nsid: &Nsid, + params: Option<HashMap<String, String>>, + body: Option<Value>, + ) -> Result<Option<Value>> { + let params: HashMap<String, String> = params.unwrap_or_default(); + log::debug!( + "XRPC POST endpoint={} params={:?} body={:?}", + nsid, + params, + body + ); + let mut req = self + .http_client + .post(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms); + req = if let Some(b) = body { + req.json(&b) + } else { + req + }; + let res = req.send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request (400): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } else if res.status() == 500 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Internal Error (500): {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + if res.content_length() == Some(0) { + Ok(None) + } else { + Ok(res.json()?) + } + } + + pub fn post_cbor_from_reader<R: std::io::Read>( + &self, + nsid: &Nsid, + params: Option<HashMap<String, String>>, + input: &mut R, + ) -> Result<Option<Value>> { + let params: HashMap<String, String> = params.unwrap_or_default(); + let mut buf: Vec<u8> = Vec::new(); + input.read_to_end(&mut buf)?; + let res = self + .http_client + .post(format!("{}/xrpc/{}", self.host, nsid)) + .query(¶ms) + .header(reqwest::header::CONTENT_TYPE, "application/cbor") + .body(buf) + .send()?; + if res.status() == 400 { + let val: Value = res.json()?; + return Err(anyhow!( + "XRPC Bad Request: {}", + val["message"].as_str().unwrap_or("unknown") + )); + } + let res = res.error_for_status()?; + Ok(res.json()?) + } + + // reqwest::blocking::Body +} diff --git a/adenosine/tests/bigger.car b/adenosine/tests/bigger.car Binary files differnew file mode 100644 index 0000000..7169013 --- /dev/null +++ b/adenosine/tests/bigger.car diff --git a/adenosine/tests/example_repo.car b/adenosine/tests/example_repo.car Binary files differnew file mode 100644 index 0000000..b2ae723 --- /dev/null +++ b/adenosine/tests/example_repo.car diff --git a/adenosine/tests/test_mst_interop.rs b/adenosine/tests/test_mst_interop.rs new file mode 100644 index 0000000..ee45019 --- /dev/null +++ b/adenosine/tests/test_mst_interop.rs @@ -0,0 +1,152 @@ +use adenosine::repo::RepoStore; +use libipld::Cid; +use std::collections::BTreeMap; +use std::str::FromStr; + +#[test] +fn test_known_maps() { + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + + let empty_map: BTreeMap<String, Cid> = Default::default(); + assert_eq!( + repo.mst_from_map(&empty_map).unwrap().to_string(), + "bafyreie5737gdxlw5i64vzichcalba3z2v5n6icifvx5xytvske7mr3hpm" + ); + + let mut trivial_map: BTreeMap<String, Cid> = Default::default(); + trivial_map.insert("asdf".to_string(), cid1.clone()); + assert_eq!( + repo.mst_from_map(&trivial_map).unwrap().to_string(), + "bafyreidaftbr35xhh4lzmv5jcoeufqjh75ohzmz6u56v7n2ippbtxdgqqe" + ); + + let mut singlelayer2_map: BTreeMap<String, Cid> = Default::default(); + singlelayer2_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); + assert_eq!( + repo.mst_from_map(&singlelayer2_map).unwrap().to_string(), + "bafyreid4g5smj6ukhrjasebt6myj7wmtm2eijouteoyueoqgoh6vm5jkae" + ); + + let mut simple_map: BTreeMap<String, Cid> = Default::default(); + simple_map.insert("asdf".to_string(), cid1.clone()); + simple_map.insert("88bfafc7".to_string(), cid1.clone()); + simple_map.insert("2a92d355".to_string(), cid1.clone()); + simple_map.insert("app.bsky.feed.post/454397e440ec".to_string(), cid1.clone()); + simple_map.insert("app.bsky.feed.post/9adeb165882c".to_string(), cid1.clone()); + assert_eq!( + repo.mst_from_map(&simple_map).unwrap().to_string(), + "bafyreiecb33zh7r2sc3k2wthm6exwzfktof63kmajeildktqc25xj6qzx4" + ); +} + +// TODO: behavior of these wide-char keys is undefined behavior in string MST +#[ignore] +#[test] +fn test_tricky_map() { + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + + let mut tricky_map: BTreeMap<String, Cid> = Default::default(); + tricky_map.insert("".to_string(), cid1.clone()); + tricky_map.insert("jalapeño".to_string(), cid1.clone()); + tricky_map.insert("coöperative".to_string(), cid1.clone()); + tricky_map.insert("coüperative".to_string(), cid1.clone()); + tricky_map.insert("abc\x00".to_string(), cid1.clone()); + assert_eq!( + repo.mst_from_map(&tricky_map).unwrap().to_string(), + "bafyreiecb33zh7r2sc3k2wthm6exwzfktof63kmajeildktqc25xj6qzx4" + ); +} + +#[test] +fn test_trims_top() { + // "trims top of tree on delete" + + use adenosine::mst::print_mst_keys; + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + let l1root = "bafyreihuyj2vzb2vjw3yhxg6dy25achg5fmre6gg5m6fjtxn64bqju4dee"; + let l0root = "bafyreibmijjc63mekkjzl3v2pegngwke5u6cu66g75z6uw27v64bc6ahqi"; + + // NOTE: this test doesn't do much in this case of rust implementation + let mut trim_map: BTreeMap<String, Cid> = Default::default(); + trim_map.insert("com.example.record/40c73105b48f".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/e99bf3ced34b".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/893e6c08b450".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/9cd8b6c0cc02".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // level 0 + trim_map.insert("com.example.record/a15e33ba0f6c".to_string(), cid1.clone()); // level 1 + let trim_before_cid = repo.mst_from_map(&trim_map).unwrap(); + print_mst_keys(&mut repo.db, &trim_before_cid).unwrap(); + assert_eq!(trim_before_cid.to_string(), l1root); + + // NOTE: if we did mutations in-place, this is where we would mutate + + trim_map.remove("com.example.record/a15e33ba0f6c"); + let trim_after_cid = repo.mst_from_map(&trim_map).unwrap(); + assert_eq!(trim_after_cid.to_string(), l0root); +} + +#[test] +fn test_insertion() { + // "handles insertion that splits two layers down" + + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + let l1root = "bafyreiagt55jzvkenoa4yik77dhomagq2uj26ix4cijj7kd2py2u3s43ve"; + let l2root = "bafyreiddrz7qbvfattp5dzzh4ldohsaobatsg7f5l6awxnmuydewq66qoa"; + + // TODO: actual mutation instead of rebuild from scratch + let mut insertion_map: BTreeMap<String, Cid> = Default::default(); + insertion_map.insert("com.example.record/403e2aeebfdb".to_string(), cid1.clone()); // A; level 0 + insertion_map.insert("com.example.record/40c73105b48f".to_string(), cid1.clone()); // B; level 0 + insertion_map.insert("com.example.record/645787eb4316".to_string(), cid1.clone()); // C; level 0 + insertion_map.insert("com.example.record/7ca4e61d6fbc".to_string(), cid1.clone()); // D; level 1 + insertion_map.insert("com.example.record/893e6c08b450".to_string(), cid1.clone()); // E; level 0 + insertion_map.insert("com.example.record/9cd8b6c0cc02".to_string(), cid1.clone()); // G; level 0 + insertion_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // H; level 0 + insertion_map.insert("com.example.record/dbea731be795".to_string(), cid1.clone()); // I; level 1 + insertion_map.insert("com.example.record/e2ef555433f2".to_string(), cid1.clone()); // J; level 0 + insertion_map.insert("com.example.record/e99bf3ced34b".to_string(), cid1.clone()); // K; level 0 + insertion_map.insert("com.example.record/f728ba61e4b6".to_string(), cid1.clone()); // L; level 0 + let insertion_before_cid = repo.mst_from_map(&insertion_map).unwrap(); + assert_eq!(insertion_before_cid.to_string(), l1root); + + insertion_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); + let insertion_after_cid = repo.mst_from_map(&insertion_map).unwrap(); + assert_eq!(insertion_after_cid.to_string(), l2root); +} + +#[test] +fn test_higher_layers() { + // "handles new layers that are two higher than existing" + + use adenosine::mst::print_mst_keys; + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid1 = + Cid::from_str("bafyreie5cvv4h45feadgeuwhbcutmh6t2ceseocckahdoe6uat64zmz454").unwrap(); + let l0root = "bafyreicivoa3p3ttcebdn2zfkdzenkd2uk3gxxlaz43qvueeip6yysvq2m"; + let l2root = "bafyreidwoqm6xlewxzhrx6ytbyhsazctlv72txtmnd4au6t53z2vpzn7wa"; + let l2root2 = "bafyreiapru27ce4wdlylk5revtr3hewmxhmt3ek5f2ypioiivmdbv5igrm"; + + // TODO: actual mutation instead of rebuild from scratch + let mut higher_map: BTreeMap<String, Cid> = Default::default(); + higher_map.insert("com.example.record/403e2aeebfdb".to_string(), cid1.clone()); // A; level 0 + higher_map.insert("com.example.record/cbe72d33d12a".to_string(), cid1.clone()); // C; level 0 + let higher_before_cid = repo.mst_from_map(&higher_map).unwrap(); + assert_eq!(higher_before_cid.to_string(), l0root); + + higher_map.insert("com.example.record/9ba1c7247ede".to_string(), cid1.clone()); // B; level 2 + let higher_after_cid = repo.mst_from_map(&higher_map).unwrap(); + print_mst_keys(&mut repo.db, &higher_after_cid).unwrap(); + assert_eq!(higher_after_cid.to_string(), l2root); + + higher_map.insert("com.example.record/fae7a851fbeb".to_string(), cid1.clone()); // D; level 1 + let higher_after_cid = repo.mst_from_map(&higher_map).unwrap(); + assert_eq!(higher_after_cid.to_string(), l2root2); +} diff --git a/adenosine/tests/test_repro_mst.rs b/adenosine/tests/test_repro_mst.rs new file mode 100644 index 0000000..692c41b --- /dev/null +++ b/adenosine/tests/test_repro_mst.rs @@ -0,0 +1,26 @@ +use adenosine::repo::RepoStore; +use std::path::PathBuf; +use std::str::FromStr; + +#[test] +fn test_repro_mst() { + let mut repo = RepoStore::open_ephemeral().unwrap(); + let cid = repo + .import_car_path( + &PathBuf::from_str("./tests/example_repo.car").unwrap(), + None, + ) + .unwrap(); + repo.verify_repo_mst(&cid).unwrap(); + let cid = repo + .import_car_path(&PathBuf::from_str("./tests/bigger.car").unwrap(), None) + .unwrap(); + repo.verify_repo_mst(&cid).unwrap(); + + // test round-tripping from export + let car_bytes = repo.export_car(&cid, None).unwrap(); + let mut other_repo = RepoStore::open_ephemeral().unwrap(); + let other_cid = other_repo.import_car_bytes(&car_bytes, None).unwrap(); + other_repo.verify_repo_mst(&cid).unwrap(); + assert_eq!(cid, other_cid); +} |