This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / bobbin / crates / knot-ingest / src / client.rs
17 kB 509 lines
1use std::future::Future; 2use std::pin::Pin; 3use std::sync::Arc; 4use std::time::Duration; 5 6use bobbin_knot_proxy::{KnotHost, KnotHostError, PrivateAddressFilter, PrivateHostReason}; 7use bobbin_runtime::{HttpRequest, HttpResponseHead, HttpTransport, NetworkError, ReqwestHttp}; 8use bytes::{Bytes, BytesMut}; 9use chrono::{DateTime, Utc}; 10use futures::TryStreamExt; 11use http::{HeaderMap, StatusCode}; 12use jacquard_common::DefaultStr; 13use jacquard_common::types::did::Did; 14use jacquard_common::types::nsid::Nsid; 15use serde::Deserialize; 16use thiserror::Error; 17use url::Url; 18 19const USER_AGENT: &str = concat!("bobbin/", env!("CARGO_PKG_VERSION")); 20const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); 21const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); 22const MAX_BODY_BYTES: u64 = 4 * 1024 * 1024; 23const LIST_PAGE_LIMIT: i64 = 1000; 24const MAX_LIST_PAGES: usize = 256; 25 26const VERSION_NSID: &str = "sh.tangled.knot.version"; 27const LIST_MEMBERS_NSID: &str = "sh.tangled.knot.listMembers"; 28const LIST_COLLABORATORS_NSID: &str = "sh.tangled.repo.listCollaborators"; 29 30#[derive(Clone)] 31pub struct KnotClient { 32 http: Arc<dyn HttpTransport>, 33} 34 35#[derive(Clone, Debug, Eq, PartialEq, Deserialize)] 36#[serde(rename_all = "camelCase")] 37pub struct AclEntry { 38 pub subject: Did<DefaultStr>, 39 pub created_at: DateTime<Utc>, 40} 41 42#[derive(Clone, Copy, Debug, Eq, PartialEq)] 43pub enum Completeness { 44 Complete, 45 Truncated, 46} 47 48#[derive(Clone, Debug, Eq, PartialEq)] 49pub struct AclListing { 50 pub entries: Vec<AclEntry>, 51 pub completeness: Completeness, 52} 53 54#[derive(Debug, Error)] 55pub enum KnotClientError { 56 #[error("knot host: {0}")] 57 Host(#[from] KnotHostError), 58 #[error("blocked: knot {host} resolves to {reason} address space")] 59 PrivateHost { 60 host: String, 61 reason: PrivateHostReason, 62 }, 63 #[error("http client build: {0}")] 64 Build(String), 65 #[error("network: {0}")] 66 Network(#[from] NetworkError), 67 #[error("xrpc not found")] 68 NotFound, 69 #[error("upstream returned status {0}")] 70 Upstream(StatusCode), 71 #[error("response body exceeded {limit} bytes")] 72 BodyTooLarge { limit: u64 }, 73 #[error("decode response: {0}")] 74 Decode(#[from] serde_json::Error), 75 #[error("extra ca cert: {0}")] 76 ExtraCa(#[from] bobbin_runtime::ExtraCaError), 77} 78 79pub fn knot_endpoint( 80 host: &str, 81 dev: bool, 82 allow_private: bool, 83) -> Result<KnotHost, KnotClientError> { 84 let raw = if dev { 85 format!("http://{host}") 86 } else { 87 host.to_owned() 88 }; 89 let knot = KnotHost::parse(&raw)?; 90 if !allow_private && let Some(reason) = knot.private_literal_reason() { 91 return Err(KnotClientError::PrivateHost { 92 host: host.to_owned(), 93 reason, 94 }); 95 } 96 Ok(knot) 97} 98 99fn default_http_client( 100 allow_private: bool, 101 extra_ca_cert: Option<&std::path::Path>, 102) -> Result<reqwest::Client, KnotClientError> { 103 let mut builder = reqwest::Client::builder() 104 .user_agent(USER_AGENT) 105 .timeout(REQUEST_TIMEOUT) 106 .connect_timeout(CONNECT_TIMEOUT) 107 .redirect(reqwest::redirect::Policy::none()) 108 .dns_resolver(Arc::new(PrivateAddressFilter::new(allow_private))); 109 if let Some(path) = extra_ca_cert { 110 builder = builder.add_root_certificate(bobbin_runtime::load_extra_ca_cert(path)?); 111 } 112 builder 113 .build() 114 .map_err(|e| KnotClientError::Build(e.to_string())) 115} 116 117fn nsid(s: &'static str) -> Nsid<DefaultStr> { 118 Nsid::new_static(s).expect("static nsid literal must validate") 119} 120 121pub(crate) fn authority(host: &KnotHost) -> String { 122 let url = host.url(); 123 match (url.host_str(), url.port()) { 124 (Some(h), Some(p)) => format!("{h}:{p}"), 125 (Some(h), None) => h.to_owned(), 126 (None, _) => String::new(), 127 } 128} 129 130impl KnotClient { 131 pub fn new(http: Arc<dyn HttpTransport>) -> Self { 132 Self { http } 133 } 134 135 pub fn with_default_http( 136 allow_private: bool, 137 extra_ca_cert: Option<&std::path::Path>, 138 ) -> Result<Self, KnotClientError> { 139 let client = default_http_client(allow_private, extra_ca_cert)?; 140 Ok(Self::new(ReqwestHttp::shared(client))) 141 } 142 143 pub async fn capabilities(&self, host: &KnotHost) -> Result<Vec<String>, KnotClientError> { 144 let mut url = host.xrpc_url(&nsid(VERSION_NSID)); 145 url.set_query(None); 146 let bytes = self.get_json(url).await?; 147 let resp: VersionWire = serde_json::from_slice(&bytes)?; 148 Ok(resp.capabilities.unwrap_or_default()) 149 } 150 151 pub async fn list_members(&self, host: &KnotHost) -> Result<AclListing, KnotClientError> { 152 let subject = authority(host); 153 self.drain(host, LIST_MEMBERS_NSID, subject, None, 0, Vec::new()) 154 .await 155 } 156 157 pub async fn list_collaborators( 158 &self, 159 host: &KnotHost, 160 repo: &Did<DefaultStr>, 161 ) -> Result<AclListing, KnotClientError> { 162 self.drain( 163 host, 164 LIST_COLLABORATORS_NSID, 165 repo.as_ref().to_owned(), 166 None, 167 0, 168 Vec::new(), 169 ) 170 .await 171 } 172 173 fn drain<'a>( 174 &'a self, 175 host: &'a KnotHost, 176 endpoint: &'static str, 177 subject: String, 178 cursor: Option<String>, 179 page: usize, 180 mut acc: Vec<AclEntry>, 181 ) -> Pin<Box<dyn Future<Output = Result<AclListing, KnotClientError>> + Send + 'a>> { 182 Box::pin(async move { 183 if page >= MAX_LIST_PAGES { 184 tracing::warn!( 185 host = %authority(host), 186 endpoint, 187 pages = page, 188 "knot list truncated at page cap" 189 ); 190 return Ok(AclListing { 191 entries: acc, 192 completeness: Completeness::Truncated, 193 }); 194 } 195 let resp = self 196 .fetch_page(host, endpoint, &subject, cursor.as_deref()) 197 .await?; 198 acc.extend(resp.items); 199 match resp.cursor.filter(|c| !c.is_empty()) { 200 Some(next) => { 201 self.drain(host, endpoint, subject, Some(next), page + 1, acc) 202 .await 203 } 204 None => Ok(AclListing { 205 entries: acc, 206 completeness: Completeness::Complete, 207 }), 208 } 209 }) 210 } 211 212 async fn fetch_page( 213 &self, 214 host: &KnotHost, 215 endpoint: &'static str, 216 subject: &str, 217 cursor: Option<&str>, 218 ) -> Result<ListWire, KnotClientError> { 219 let mut url = host.xrpc_url(&nsid(endpoint)); 220 { 221 let mut q = url.query_pairs_mut(); 222 q.clear(); 223 q.append_pair("subject", subject); 224 q.append_pair("limit", &LIST_PAGE_LIMIT.to_string()); 225 if let Some(c) = cursor { 226 q.append_pair("cursor", c); 227 } 228 } 229 let bytes = self.get_json(url).await?; 230 Ok(serde_json::from_slice(&bytes)?) 231 } 232 233 async fn get_json(&self, url: Url) -> Result<Bytes, KnotClientError> { 234 let resp = self 235 .http 236 .execute(HttpRequest { 237 url, 238 headers: HeaderMap::new(), 239 }) 240 .await?; 241 match resp.status { 242 StatusCode::OK => read_bounded(resp).await, 243 StatusCode::NOT_FOUND => Err(KnotClientError::NotFound), 244 other => Err(KnotClientError::Upstream(other)), 245 } 246 } 247} 248 249#[derive(Deserialize)] 250struct VersionWire { 251 #[serde(default)] 252 capabilities: Option<Vec<String>>, 253} 254 255#[derive(Deserialize)] 256struct ListWire { 257 #[serde(default)] 258 items: Vec<AclEntry>, 259 #[serde(default)] 260 cursor: Option<String>, 261} 262 263async fn read_bounded(resp: HttpResponseHead) -> Result<Bytes, KnotClientError> { 264 if resp.content_length.is_some_and(|len| len > MAX_BODY_BYTES) { 265 return Err(KnotClientError::BodyTooLarge { 266 limit: MAX_BODY_BYTES, 267 }); 268 } 269 let buf = resp 270 .body 271 .map_err(KnotClientError::Network) 272 .try_fold(BytesMut::new(), |mut acc, chunk| async move { 273 if (acc.len() as u64).saturating_add(chunk.len() as u64) > MAX_BODY_BYTES { 274 return Err(KnotClientError::BodyTooLarge { 275 limit: MAX_BODY_BYTES, 276 }); 277 } 278 acc.extend_from_slice(&chunk); 279 Ok(acc) 280 }) 281 .await?; 282 Ok(buf.freeze()) 283} 284 285#[cfg(test)] 286mod tests { 287 use super::*; 288 use serde_json::json; 289 use wiremock::matchers::{method, path, query_param}; 290 use wiremock::{Mock, MockServer, ResponseTemplate}; 291 292 fn did(s: &str) -> Did<DefaultStr> { 293 Did::new_owned(s).unwrap() 294 } 295 296 fn client() -> KnotClient { 297 KnotClient::new(ReqwestHttp::shared(default_http_client(true, None).unwrap())) 298 } 299 300 fn endpoint(server: &MockServer) -> KnotHost { 301 KnotHost::parse(&server.uri()).unwrap() 302 } 303 304 #[tokio::test] 305 async fn capabilities_returns_declared_tokens() { 306 let server = MockServer::start().await; 307 Mock::given(method("GET")) 308 .and(path("/xrpc/sh.tangled.knot.version")) 309 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 310 "version": "1.0.0 (deadbeef)", 311 "capabilities": ["knot-acl"] 312 }))) 313 .mount(&server) 314 .await; 315 316 let caps = client().capabilities(&endpoint(&server)).await.unwrap(); 317 assert_eq!(caps, vec!["knot-acl".to_owned()]); 318 } 319 320 #[tokio::test] 321 async fn capabilities_empty_when_field_absent() { 322 let server = MockServer::start().await; 323 Mock::given(method("GET")) 324 .and(path("/xrpc/sh.tangled.knot.version")) 325 .respond_with( 326 ResponseTemplate::new(200).set_body_json(json!({ "version": "1.0.0 (cafe)" })), 327 ) 328 .mount(&server) 329 .await; 330 331 let caps = client().capabilities(&endpoint(&server)).await.unwrap(); 332 assert!(caps.is_empty()); 333 } 334 335 #[tokio::test] 336 async fn list_members_drains_single_page() { 337 let server = MockServer::start().await; 338 let host = endpoint(&server); 339 Mock::given(method("GET")) 340 .and(path("/xrpc/sh.tangled.knot.listMembers")) 341 .and(query_param("subject", authority(&host).as_str())) 342 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 343 "items": [ 344 {"subject": "did:plc:boltless", "addedBy": "did:plc:akshay", "createdAt": "2026-06-01T00:00:00Z"}, 345 {"subject": "did:plc:akshay", "addedBy": "did:plc:akshay", "createdAt": "2026-06-02T12:00:00Z"} 346 ] 347 }))) 348 .mount(&server) 349 .await; 350 351 let listing = client().list_members(&host).await.unwrap(); 352 assert_eq!(listing.completeness, Completeness::Complete); 353 assert_eq!( 354 listing.entries, 355 vec![ 356 AclEntry { 357 subject: did("did:plc:boltless"), 358 created_at: "2026-06-01T00:00:00Z".parse().unwrap(), 359 }, 360 AclEntry { 361 subject: did("did:plc:akshay"), 362 created_at: "2026-06-02T12:00:00Z".parse().unwrap(), 363 }, 364 ] 365 ); 366 } 367 368 #[tokio::test] 369 async fn list_members_drains_multiple_pages() { 370 let server = MockServer::start().await; 371 let host = endpoint(&server); 372 Mock::given(method("GET")) 373 .and(path("/xrpc/sh.tangled.knot.listMembers")) 374 .and(query_param("cursor", "p2")) 375 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 376 "items": [{"subject": "did:plc:akshay", "addedBy": "did:plc:akshay", "createdAt": "2026-06-02T00:00:00Z"}] 377 }))) 378 .mount(&server) 379 .await; 380 Mock::given(method("GET")) 381 .and(path("/xrpc/sh.tangled.knot.listMembers")) 382 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 383 "items": [{"subject": "did:plc:boltless", "addedBy": "did:plc:akshay", "createdAt": "2026-06-01T00:00:00Z"}], 384 "cursor": "p2" 385 }))) 386 .mount(&server) 387 .await; 388 389 let listing = client().list_members(&host).await.unwrap(); 390 assert_eq!(listing.completeness, Completeness::Complete); 391 let subjects: Vec<_> = listing.entries.into_iter().map(|m| m.subject).collect(); 392 assert_eq!( 393 subjects, 394 vec![did("did:plc:boltless"), did("did:plc:akshay")] 395 ); 396 } 397 398 #[tokio::test] 399 async fn list_collaborators_uses_repo_did_subject() { 400 let server = MockServer::start().await; 401 let host = endpoint(&server); 402 Mock::given(method("GET")) 403 .and(path("/xrpc/sh.tangled.repo.listCollaborators")) 404 .and(query_param("subject", "did:plc:scallop")) 405 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 406 "items": [{"subject": "did:plc:olaren", "addedBy": "did:plc:boltless", "createdAt": "2026-06-03T00:00:00Z"}] 407 }))) 408 .mount(&server) 409 .await; 410 411 let listing = client() 412 .list_collaborators(&host, &did("did:plc:scallop")) 413 .await 414 .unwrap(); 415 assert_eq!(listing.completeness, Completeness::Complete); 416 assert_eq!(listing.entries.len(), 1); 417 assert_eq!(listing.entries[0].subject, did("did:plc:olaren")); 418 } 419 420 #[tokio::test] 421 async fn drain_reports_truncation_at_page_cap() { 422 let server = MockServer::start().await; 423 let host = endpoint(&server); 424 Mock::given(method("GET")) 425 .and(path("/xrpc/sh.tangled.knot.listMembers")) 426 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 427 "items": [{"subject": "did:plc:boltless", "addedBy": "did:plc:akshay", "createdAt": "2026-06-01T00:00:00Z"}], 428 "cursor": "more" 429 }))) 430 .mount(&server) 431 .await; 432 433 let listing = client().list_members(&host).await.unwrap(); 434 assert_eq!( 435 listing.completeness, 436 Completeness::Truncated, 437 "a never-terminating cursor must surface as a truncated listing" 438 ); 439 assert_eq!(listing.entries.len(), MAX_LIST_PAGES); 440 } 441 442 #[tokio::test] 443 async fn maps_404_to_not_found() { 444 let server = MockServer::start().await; 445 Mock::given(method("GET")) 446 .respond_with(ResponseTemplate::new(404)) 447 .mount(&server) 448 .await; 449 450 let err = client() 451 .capabilities(&endpoint(&server)) 452 .await 453 .expect_err("404 must surface"); 454 assert!(matches!(err, KnotClientError::NotFound)); 455 } 456 457 #[tokio::test] 458 async fn maps_5xx_to_upstream() { 459 let server = MockServer::start().await; 460 Mock::given(method("GET")) 461 .respond_with(ResponseTemplate::new(503)) 462 .mount(&server) 463 .await; 464 465 let err = client() 466 .capabilities(&endpoint(&server)) 467 .await 468 .expect_err("5xx must surface"); 469 match err { 470 KnotClientError::Upstream(s) => assert_eq!(s.as_u16(), 503), 471 other => panic!("wrong variant: {other:?}"), 472 } 473 } 474 475 #[tokio::test] 476 async fn does_not_follow_redirects() { 477 let server = MockServer::start().await; 478 Mock::given(method("GET")) 479 .and(path("/xrpc/sh.tangled.knot.version")) 480 .respond_with(ResponseTemplate::new(301).insert_header( 481 "location", 482 "https://kt.tngl.oyster.cafe/xrpc/sh.tangled.knot.version", 483 )) 484 .mount(&server) 485 .await; 486 487 let err = client() 488 .capabilities(&endpoint(&server)) 489 .await 490 .expect_err("a redirect must surface as an error, not be followed to another knot"); 491 match err { 492 KnotClientError::Upstream(s) => assert_eq!(s.as_u16(), 301), 493 other => panic!("expected Upstream(301), got {other:?}"), 494 } 495 } 496 497 #[test] 498 fn knot_endpoint_rejects_private_host() { 499 let err = 500 knot_endpoint("127.0.0.1:9", true, false).expect_err("private host must be refused"); 501 assert!(matches!(err, KnotClientError::PrivateHost { .. })); 502 } 503 504 #[test] 505 fn knot_endpoint_allows_private_when_permitted() { 506 let knot = knot_endpoint("127.0.0.1:9", true, true).expect("private host allowed"); 507 assert_eq!(knot.url().scheme(), "http"); 508 } 509}