this repo has no description
1
fork

Configure Feed

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

Add download command with XRPC hardening and test infrastructure

Implement download-and-decrypt flow: parse AT-URI, fetch document record,
find wrapped key by DID, unwrap content key, fetch encrypted blob, decrypt
with AES-256-GCM, write plaintext to disk. Refuses to overwrite existing
files and bails on unsupported keyring encryption.

Harden XrpcClient with send_checked() combining transport.send() and HTTP
status checking into a single call — replaces 6 repeated check_response
invocations. Login keeps raw send for its custom error path.

Split AT Protocol primitives (AtUri, AtBytes, CidLink, BlobRef) into new
atproto module, reducing records.rs from 378 to 211 lines. Record types
re-export the atproto types used in their fields for backward compat.

Add MockTransport (FIFO response queue + request capture) behind a
test-utils feature flag for cross-crate testing. Add 8 download tests
exercising the full crypto roundtrip, PDS error handling, wrong-key
rejection, schema version guards, and keyring bail.

Test count: 87 → 99.

+998 -56
+6
CHANGELOG.md
··· 7 7 ## [Unreleased] 8 8 9 9 ### Added 10 + - Add MockTransport test infrastructure with FIFO response queue 11 + - Add download command tests with full crypto roundtrip verification 10 12 - Update login command to read password from stdin (#23) 11 13 12 14 ### Fixed 15 + - Fix missing HTTP status checks in XRPC client (#31) 13 16 14 17 ### Changed 18 + - Extract AT Protocol primitives into dedicated atproto module 19 + - Consolidate XRPC response checking into send_checked method 20 + - Add file download with client-side decryption (#6) 15 21 - Update outdated dependencies (reqwest 0.13, toml) (#29) 16 22 - Test upload command against real PDS (#28) 17 23 - Add file upload with client-side encryption (#5)
+1
Cargo.lock
··· 1050 1050 "serde_json", 1051 1051 "sha2", 1052 1052 "thiserror 2.0.18", 1053 + "tokio", 1053 1054 "x25519-dalek", 1054 1055 ] 1055 1056
+2
crates/opake-cli/Cargo.toml
··· 24 24 serde_json.workspace = true 25 25 26 26 [dev-dependencies] 27 + opake-core = { path = "../opake-core", features = ["test-utils"] } 27 28 tempfile = "3" 29 + tokio = { version = "1", features = ["full", "test-util"] }
+429 -3
crates/opake-cli/src/commands/download.rs
··· 1 + use std::fs; 1 2 use std::path::PathBuf; 2 3 3 - use anyhow::Result; 4 + use anyhow::{Context, Result}; 5 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 4 6 use clap::Args; 7 + use log::debug; 8 + use opake_core::atproto; 9 + use opake_core::crypto; 10 + use opake_core::records::{self, Encryption}; 5 11 6 12 use crate::commands::Execute; 13 + use crate::identity; 14 + use crate::session; 7 15 8 16 #[derive(Args)] 9 17 /// Download and decrypt a file ··· 16 24 output: Option<PathBuf>, 17 25 } 18 26 27 + /// Core download logic separated from filesystem/config concerns. 28 + /// Takes a pre-built client, identity details, the AT-URI, and output path. 29 + /// Returns the decrypted plaintext bytes (caller writes to disk). 30 + pub async fn download_and_decrypt( 31 + client: &opake_core::client::XrpcClient<impl opake_core::client::Transport>, 32 + did: &str, 33 + private_key: &[u8; 32], 34 + uri: &str, 35 + ) -> Result<(String, Vec<u8>)> { 36 + let at_uri = atproto::parse_at_uri(uri).map_err(|e| anyhow::anyhow!("{e}"))?; 37 + 38 + debug!("fetching record {}", uri); 39 + let entry = client 40 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 41 + .await 42 + .context("failed to fetch document record")?; 43 + 44 + let doc: records::Document = 45 + serde_json::from_value(entry.value).context("failed to parse document record")?; 46 + 47 + records::check_version(doc.version).map_err(|e| anyhow::anyhow!("{e}"))?; 48 + 49 + let envelope = match &doc.encryption { 50 + Encryption::Direct(direct) => &direct.envelope, 51 + Encryption::Keyring(_) => { 52 + anyhow::bail!("keyring-encrypted documents not yet supported (tracking: #21)") 53 + } 54 + }; 55 + 56 + let wrapped_key = envelope.keys.iter().find(|k| k.did == did).ok_or_else(|| { 57 + anyhow::anyhow!( 58 + "no wrapped key for your DID ({}) — you may not have access", 59 + did 60 + ) 61 + })?; 62 + 63 + debug!("unwrapping content key"); 64 + let content_key = 65 + crypto::unwrap_key(wrapped_key, private_key).context("failed to unwrap content key")?; 66 + 67 + let nonce_bytes = BASE64 68 + .decode(&envelope.nonce.encoded) 69 + .context("invalid base64 in encryption nonce")?; 70 + let nonce: [u8; 12] = nonce_bytes 71 + .try_into() 72 + .map_err(|v: Vec<u8>| anyhow::anyhow!("nonce is {} bytes, expected 12", v.len()))?; 73 + 74 + debug!( 75 + "fetching blob did={} cid={}", 76 + at_uri.authority, doc.blob.reference.cid 77 + ); 78 + let ciphertext = client 79 + .get_blob(&at_uri.authority, &doc.blob.reference.cid) 80 + .await 81 + .context("failed to fetch encrypted blob")?; 82 + 83 + debug!("decrypting {} bytes", ciphertext.len()); 84 + let plaintext = crypto::decrypt_blob( 85 + &content_key, 86 + &crypto::EncryptedPayload { ciphertext, nonce }, 87 + ) 88 + .context("decryption failed — wrong key or corrupted blob")?; 89 + 90 + Ok((doc.name, plaintext)) 91 + } 92 + 19 93 impl Execute for DownloadCommand { 20 94 async fn execute(self) -> Result<()> { 21 - let _client = crate::session::load_client()?; 22 - anyhow::bail!("download not yet implemented (tracking: chainlink #6)") 95 + let client = session::load_client()?; 96 + let id = identity::load_identity().context("run `opake login` first")?; 97 + let private_key = id.private_key_bytes()?; 98 + 99 + let (name, plaintext) = 100 + download_and_decrypt(&client, &id.did, &private_key, &self.uri).await?; 101 + 102 + let output_path = self.output.unwrap_or_else(|| PathBuf::from(&name)); 103 + 104 + if output_path.exists() { 105 + anyhow::bail!( 106 + "output file already exists: {} (use -o to specify a different path)", 107 + output_path.display() 108 + ); 109 + } 110 + 111 + fs::write(&output_path, &plaintext) 112 + .context(format!("failed to write {}", output_path.display()))?; 113 + 114 + println!( 115 + "{} → {} ({} bytes)", 116 + name, 117 + output_path.display(), 118 + plaintext.len() 119 + ); 120 + Ok(()) 121 + } 122 + } 123 + 124 + #[cfg(test)] 125 + mod tests { 126 + use super::*; 127 + use base64::engine::general_purpose::STANDARD as BASE64; 128 + use base64::Engine; 129 + use opake_core::client::{HttpResponse, Session, XrpcClient}; 130 + use opake_core::crypto::OsRng; 131 + use opake_core::records::{ 132 + AtBytes, BlobRef, CidLink, DirectEncryption, Document, EncryptionEnvelope, 133 + }; 134 + use opake_core::test_utils::MockTransport; 135 + 136 + const TEST_DID: &str = "did:plc:test"; 137 + const TEST_URI: &str = "at://did:plc:test/app.opake.cloud.document/abc123"; 138 + 139 + /// Build an authenticated XrpcClient backed by a MockTransport. 140 + fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 141 + let session = Session { 142 + did: TEST_DID.into(), 143 + handle: "test.handle".into(), 144 + access_jwt: "test-jwt".into(), 145 + refresh_jwt: "test-refresh".into(), 146 + }; 147 + XrpcClient::with_session(mock, "https://pds.test".into(), session) 148 + } 149 + 150 + /// Generate a keypair and return (public_key, private_key). 151 + fn test_keypair() -> ([u8; 32], [u8; 32]) { 152 + let secret = crypto::X25519DalekStaticSecret::random_from_rng(OsRng); 153 + let public = crypto::X25519DalekPublicKey::from(&secret); 154 + (public.to_bytes(), secret.to_bytes()) 155 + } 156 + 157 + /// Encrypt plaintext and wrap the content key, returning everything needed 158 + /// to build a mock PDS response pair. 159 + struct EncryptedFixture { 160 + ciphertext: Vec<u8>, 161 + nonce: [u8; 12], 162 + wrapped_key: records::WrappedKey, 163 + } 164 + 165 + fn encrypt_for_download(plaintext: &[u8], public_key: &[u8; 32]) -> EncryptedFixture { 166 + let rng = &mut OsRng; 167 + let content_key = crypto::generate_content_key(rng); 168 + let payload = crypto::encrypt_blob(&content_key, plaintext, rng).unwrap(); 169 + let wrapped_key = crypto::wrap_key(&content_key, public_key, TEST_DID, rng).unwrap(); 170 + EncryptedFixture { 171 + ciphertext: payload.ciphertext, 172 + nonce: payload.nonce, 173 + wrapped_key, 174 + } 175 + } 176 + 177 + /// Build a Document record from an encrypted fixture. 178 + fn document_from_fixture(fixture: &EncryptedFixture) -> Document { 179 + Document { 180 + mime_type: Some("text/plain".into()), 181 + size: Some(42), 182 + visibility: Some("private".into()), 183 + ..Document::new( 184 + "test-file.txt".into(), 185 + BlobRef { 186 + blob_type: "blob".into(), 187 + reference: CidLink { 188 + cid: "bafytest123".into(), 189 + }, 190 + mime_type: "application/octet-stream".into(), 191 + size: fixture.ciphertext.len() as u64, 192 + }, 193 + Encryption::Direct(DirectEncryption { 194 + envelope: EncryptionEnvelope { 195 + algo: "aes-256-gcm".into(), 196 + nonce: AtBytes { 197 + encoded: BASE64.encode(fixture.nonce), 198 + }, 199 + keys: vec![fixture.wrapped_key.clone()], 200 + }, 201 + }), 202 + "2026-03-01T00:00:00Z".into(), 203 + ) 204 + } 205 + } 206 + 207 + /// Build an HttpResponse for getRecord containing a serialized Document. 208 + fn record_response(doc: &Document) -> HttpResponse { 209 + let body = serde_json::to_vec(&serde_json::json!({ 210 + "uri": TEST_URI, 211 + "cid": "bafyrecord", 212 + "value": doc, 213 + })) 214 + .unwrap(); 215 + HttpResponse { status: 200, body } 216 + } 217 + 218 + /// Build an HttpResponse for getBlob returning raw bytes. 219 + fn blob_response(data: &[u8]) -> HttpResponse { 220 + HttpResponse { 221 + status: 200, 222 + body: data.to_vec(), 223 + } 224 + } 225 + 226 + // -- Happy path -- 227 + 228 + #[tokio::test] 229 + async fn roundtrip_encrypt_download_decrypt() { 230 + let (public_key, private_key) = test_keypair(); 231 + let plaintext = b"the quick brown fox jumps over the lazy dog"; 232 + let fixture = encrypt_for_download(plaintext, &public_key); 233 + let doc = document_from_fixture(&fixture); 234 + 235 + let mock = MockTransport::new(); 236 + mock.enqueue(record_response(&doc)); 237 + mock.enqueue(blob_response(&fixture.ciphertext)); 238 + 239 + let client = mock_client(mock.clone()); 240 + let (name, decrypted) = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 241 + .await 242 + .unwrap(); 243 + 244 + assert_eq!(name, "test-file.txt"); 245 + assert_eq!(decrypted, plaintext); 246 + 247 + let requests = mock.requests(); 248 + assert_eq!(requests.len(), 2); 249 + assert!(requests[0].url.contains("getRecord")); 250 + assert!(requests[1].url.contains("getBlob")); 251 + } 252 + 253 + #[tokio::test] 254 + async fn roundtrip_empty_file() { 255 + let (public_key, private_key) = test_keypair(); 256 + let fixture = encrypt_for_download(b"", &public_key); 257 + let doc = document_from_fixture(&fixture); 258 + 259 + let mock = MockTransport::new(); 260 + mock.enqueue(record_response(&doc)); 261 + mock.enqueue(blob_response(&fixture.ciphertext)); 262 + 263 + let client = mock_client(mock); 264 + let (_, decrypted) = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 265 + .await 266 + .unwrap(); 267 + 268 + assert!(decrypted.is_empty()); 269 + } 270 + 271 + // -- No wrapped key for DID -- 272 + 273 + #[tokio::test] 274 + async fn rejects_when_no_key_for_did() { 275 + let (public_key, private_key) = test_keypair(); 276 + let fixture = encrypt_for_download(b"data", &public_key); 277 + let doc = document_from_fixture(&fixture); 278 + 279 + let mock = MockTransport::new(); 280 + mock.enqueue(record_response(&doc)); 281 + 282 + let client = mock_client(mock); 283 + let err = download_and_decrypt(&client, "did:plc:wrong", &private_key, TEST_URI) 284 + .await 285 + .unwrap_err(); 286 + 287 + assert!( 288 + err.to_string().contains("no wrapped key"), 289 + "expected 'no wrapped key' error, got: {err}" 290 + ); 291 + } 292 + 293 + // -- Keyring encryption not supported -- 294 + 295 + #[tokio::test] 296 + async fn rejects_keyring_encryption() { 297 + let doc_value = serde_json::json!({ 298 + "uri": TEST_URI, 299 + "cid": "bafyrecord", 300 + "value": { 301 + "version": 1, 302 + "name": "keyring-doc.txt", 303 + "blob": { 304 + "$type": "blob", 305 + "ref": { "$link": "bafytest" }, 306 + "mimeType": "application/octet-stream", 307 + "size": 100, 308 + }, 309 + "encryption": { 310 + "$type": "app.opake.cloud.document#keyringEncryption", 311 + "keyringRef": { 312 + "keyring": "at://did:plc:test/app.opake.cloud.keyring/kr1", 313 + "wrappedContentKey": { "$bytes": "AAAA" }, 314 + "rotation": 1, 315 + }, 316 + "algo": "aes-256-gcm", 317 + "nonce": { "$bytes": "AAAAAAAAAAAAAAAA" }, 318 + }, 319 + "createdAt": "2026-03-01T00:00:00Z", 320 + }, 321 + }); 322 + 323 + let mock = MockTransport::new(); 324 + mock.enqueue(HttpResponse { 325 + status: 200, 326 + body: serde_json::to_vec(&doc_value).unwrap(), 327 + }); 328 + 329 + let (_, private_key) = test_keypair(); 330 + let client = mock_client(mock); 331 + let err = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 332 + .await 333 + .unwrap_err(); 334 + 335 + assert!( 336 + err.to_string().contains("keyring"), 337 + "expected keyring error, got: {err}" 338 + ); 339 + } 340 + 341 + // -- PDS errors -- 342 + 343 + #[tokio::test] 344 + async fn pds_404_on_get_record() { 345 + let mock = MockTransport::new(); 346 + mock.enqueue(HttpResponse { 347 + status: 404, 348 + body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 349 + }); 350 + 351 + let (_, private_key) = test_keypair(); 352 + let client = mock_client(mock); 353 + let err = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 354 + .await 355 + .unwrap_err(); 356 + 357 + assert!( 358 + err.to_string().contains("fetch document record"), 359 + "expected record fetch error, got: {err}" 360 + ); 361 + } 362 + 363 + #[tokio::test] 364 + async fn pds_500_on_get_blob() { 365 + let (public_key, private_key) = test_keypair(); 366 + let fixture = encrypt_for_download(b"data", &public_key); 367 + let doc = document_from_fixture(&fixture); 368 + 369 + let mock = MockTransport::new(); 370 + mock.enqueue(record_response(&doc)); 371 + mock.enqueue(HttpResponse { 372 + status: 500, 373 + body: br#"{"error":"InternalServerError","message":"blob storage error"}"#.to_vec(), 374 + }); 375 + 376 + let client = mock_client(mock); 377 + let err = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 378 + .await 379 + .unwrap_err(); 380 + 381 + assert!( 382 + err.to_string().contains("fetch encrypted blob"), 383 + "expected blob fetch error, got: {err}" 384 + ); 385 + } 386 + 387 + // -- Schema version -- 388 + 389 + #[tokio::test] 390 + async fn rejects_future_schema_version() { 391 + let (public_key, private_key) = test_keypair(); 392 + let fixture = encrypt_for_download(b"data", &public_key); 393 + let mut doc = document_from_fixture(&fixture); 394 + doc.version = records::SCHEMA_VERSION + 1; 395 + 396 + let mock = MockTransport::new(); 397 + mock.enqueue(record_response(&doc)); 398 + 399 + let client = mock_client(mock); 400 + let err = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 401 + .await 402 + .unwrap_err(); 403 + 404 + assert!( 405 + err.to_string().contains("schema version"), 406 + "expected schema version error, got: {err}" 407 + ); 408 + } 409 + 410 + // -- Bad AT-URI -- 411 + 412 + #[tokio::test] 413 + async fn rejects_invalid_at_uri() { 414 + let (_, private_key) = test_keypair(); 415 + let mock = MockTransport::new(); 416 + let client = mock_client(mock); 417 + 418 + let err = download_and_decrypt(&client, TEST_DID, &private_key, "not-a-uri") 419 + .await 420 + .unwrap_err(); 421 + 422 + assert!( 423 + err.to_string().contains("AT-URI"), 424 + "expected AT-URI error, got: {err}" 425 + ); 426 + } 427 + 428 + // -- Wrong private key -- 429 + 430 + #[tokio::test] 431 + async fn wrong_private_key_fails_unwrap() { 432 + let (public_key, _) = test_keypair(); 433 + let (_, wrong_private_key) = test_keypair(); 434 + let fixture = encrypt_for_download(b"secret data", &public_key); 435 + let doc = document_from_fixture(&fixture); 436 + 437 + let mock = MockTransport::new(); 438 + mock.enqueue(record_response(&doc)); 439 + 440 + let client = mock_client(mock); 441 + let err = download_and_decrypt(&client, TEST_DID, &wrong_private_key, TEST_URI) 442 + .await 443 + .unwrap_err(); 444 + 445 + assert!( 446 + err.to_string().contains("unwrap content key"), 447 + "expected unwrap error, got: {err}" 448 + ); 23 449 } 24 450 }
+4 -2
crates/opake-cli/src/commands/upload.rs
··· 126 126 let result = rt.block_on(cmd.execute()); 127 127 assert!(result.is_err()); 128 128 let err = result.unwrap_err().to_string(); 129 - // Fails at either session loading or file reading depending on env 129 + // Fails at config/session loading or file reading depending on env 130 130 assert!( 131 - err.contains("failed to read") || err.contains("run `opake login` first"), 131 + err.contains("failed to read") 132 + || err.contains("run `opake login` first") 133 + || err.contains("config.toml"), 132 134 "unexpected error: {err}" 133 135 ); 134 136 }
-1
crates/opake-cli/src/identity.rs
··· 30 30 Ok(key) 31 31 } 32 32 33 - #[allow(dead_code)] // used by download (#6) 34 33 pub fn private_key_bytes(&self) -> anyhow::Result<X25519PrivateKey> { 35 34 let bytes = BASE64 36 35 .decode(&self.private_key)
+6
crates/opake-core/Cargo.toml
··· 4 4 edition.workspace = true 5 5 version.workspace = true 6 6 7 + [features] 8 + test-utils = [] 9 + 7 10 [dependencies] 8 11 log.workspace = true 9 12 serde.workspace = true ··· 16 19 hkdf = "0.12" # HKDF key derivation (RFC 5869) 17 20 sha2 = "0.10" # SHA-256 hash — used by HKDF internally 18 21 base64 = "0.22" # atproto $bytes encoding 22 + 23 + [dev-dependencies] 24 + tokio = { version = "1", features = ["macros", "rt"] } 19 25 20 26 # aes-gcm pulls in getrandom transitively. On wasm32, getrandom needs the 21 27 # "js" feature to use crypto.getRandomValues() instead of OS-level randomness.
+188
crates/opake-core/src/atproto.rs
··· 1 + // AT Protocol primitives: URIs, binary data wrappers, blob references. 2 + // 3 + // These types mirror atproto's JSON serialization conventions and are used 4 + // across both the XRPC client and the app.opake.cloud.* lexicon records. 5 + 6 + use serde::{Deserialize, Serialize}; 7 + 8 + use crate::error::Error; 9 + 10 + // --------------------------------------------------------------------------- 11 + // AT-URI parsing 12 + // --------------------------------------------------------------------------- 13 + 14 + /// Parsed components of an `at://` URI. 15 + /// 16 + /// Format: `at://<authority>/<collection>/<rkey>` 17 + /// Example: `at://did:plc:abc123/app.opake.cloud.document/3abc` 18 + #[derive(Debug, Clone, PartialEq, Eq)] 19 + pub struct AtUri { 20 + pub authority: String, 21 + pub collection: String, 22 + pub rkey: String, 23 + } 24 + 25 + /// Parse an AT-URI into its components. 26 + /// 27 + /// Accepts `at://did/collection/rkey`. Rejects malformed URIs with a 28 + /// descriptive error. Does not validate the DID or collection format 29 + /// beyond requiring non-empty segments. 30 + pub fn parse_at_uri(uri: &str) -> Result<AtUri, Error> { 31 + let rest = uri.strip_prefix("at://").ok_or_else(|| { 32 + Error::InvalidRecord(format!("not an AT-URI (missing at:// prefix): {uri}")) 33 + })?; 34 + 35 + let parts: Vec<&str> = rest.splitn(3, '/').collect(); 36 + if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) { 37 + return Err(Error::InvalidRecord(format!( 38 + "AT-URI must have exactly 3 segments (authority/collection/rkey): {uri}" 39 + ))); 40 + } 41 + 42 + Ok(AtUri { 43 + authority: parts[0].to_string(), 44 + collection: parts[1].to_string(), 45 + rkey: parts[2].to_string(), 46 + }) 47 + } 48 + 49 + // --------------------------------------------------------------------------- 50 + // JSON serialization wrappers 51 + // --------------------------------------------------------------------------- 52 + 53 + /// Binary data in atproto JSON: `{ "$bytes": "<base64>" }`. 54 + #[derive(Debug, Clone, Serialize, Deserialize)] 55 + pub struct AtBytes { 56 + #[serde(rename = "$bytes")] 57 + pub encoded: String, 58 + } 59 + 60 + /// CID link reference: `{ "$link": "<cid>" }`. 61 + #[derive(Debug, Clone, Serialize, Deserialize)] 62 + pub struct CidLink { 63 + #[serde(rename = "$link")] 64 + pub cid: String, 65 + } 66 + 67 + /// Blob reference as returned by `com.atproto.repo.uploadBlob`. 68 + #[derive(Debug, Clone, Serialize, Deserialize)] 69 + #[serde(rename_all = "camelCase")] 70 + pub struct BlobRef { 71 + #[serde(rename = "$type")] 72 + pub blob_type: String, 73 + #[serde(rename = "ref")] 74 + pub reference: CidLink, 75 + pub mime_type: String, 76 + pub size: u64, 77 + } 78 + 79 + #[cfg(test)] 80 + mod tests { 81 + use super::*; 82 + 83 + // -- AT-URI parsing: valid inputs -- 84 + 85 + #[test] 86 + fn parse_valid_document_uri() { 87 + let uri = 88 + parse_at_uri("at://did:plc:abc123/app.opake.cloud.document/3jui2v6cv2a2w").unwrap(); 89 + assert_eq!(uri.authority, "did:plc:abc123"); 90 + assert_eq!(uri.collection, "app.opake.cloud.document"); 91 + assert_eq!(uri.rkey, "3jui2v6cv2a2w"); 92 + } 93 + 94 + #[test] 95 + fn parse_valid_grant_uri() { 96 + let uri = parse_at_uri("at://did:web:example.com/app.opake.cloud.grant/tid123").unwrap(); 97 + assert_eq!(uri.authority, "did:web:example.com"); 98 + assert_eq!(uri.collection, "app.opake.cloud.grant"); 99 + assert_eq!(uri.rkey, "tid123"); 100 + } 101 + 102 + #[test] 103 + fn rkey_with_slashes_captured_whole() { 104 + // splitn(3, '/') means everything after the second slash is rkey 105 + let uri = parse_at_uri("at://did:plc:x/col/rkey/with/slashes").unwrap(); 106 + assert_eq!(uri.rkey, "rkey/with/slashes"); 107 + } 108 + 109 + // -- AT-URI parsing: structural rejections -- 110 + 111 + #[test] 112 + fn rejects_missing_prefix() { 113 + let err = parse_at_uri("did:plc:abc/col/rkey").unwrap_err(); 114 + assert!(matches!(err, Error::InvalidRecord(_))); 115 + } 116 + 117 + #[test] 118 + fn rejects_https_uri() { 119 + assert!(parse_at_uri("https://bsky.app/profile/did:plc:abc").is_err()); 120 + } 121 + 122 + #[test] 123 + fn rejects_empty_string() { 124 + assert!(parse_at_uri("").is_err()); 125 + } 126 + 127 + #[test] 128 + fn rejects_just_prefix() { 129 + assert!(parse_at_uri("at://").is_err()); 130 + } 131 + 132 + #[test] 133 + fn rejects_authority_only() { 134 + assert!(parse_at_uri("at://did:plc:abc").is_err()); 135 + } 136 + 137 + #[test] 138 + fn rejects_two_segments() { 139 + assert!(parse_at_uri("at://did:plc:abc/collection").is_err()); 140 + } 141 + 142 + #[test] 143 + fn rejects_empty_authority() { 144 + assert!(parse_at_uri("at:///collection/rkey").is_err()); 145 + } 146 + 147 + #[test] 148 + fn rejects_empty_collection() { 149 + assert!(parse_at_uri("at://did:plc:abc//rkey").is_err()); 150 + } 151 + 152 + #[test] 153 + fn rejects_empty_rkey() { 154 + assert!(parse_at_uri("at://did:plc:abc/collection/").is_err()); 155 + } 156 + 157 + // -- AT-URI parsing: adversarial inputs -- 158 + 159 + #[test] 160 + fn rejects_uppercase_scheme() { 161 + assert!(parse_at_uri("AT://did:plc:abc/col/rkey").is_err()); 162 + } 163 + 164 + #[test] 165 + fn rejects_cyrillic_a_lookalike() { 166 + // Cyrillic "а" (U+0430) instead of Latin "a" 167 + assert!(parse_at_uri("\u{0430}t://did:plc:abc/col/rkey").is_err()); 168 + } 169 + 170 + #[test] 171 + fn rejects_leading_whitespace() { 172 + assert!(parse_at_uri(" at://did:plc:abc/col/rkey").is_err()); 173 + } 174 + 175 + #[test] 176 + fn whitespace_after_scheme_becomes_authority() { 177 + // space becomes part of authority — structurally valid, XRPC rejects 178 + let uri = parse_at_uri("at:// did:plc:abc/col/rkey").unwrap(); 179 + assert!(uri.authority.starts_with(' ')); 180 + } 181 + 182 + #[test] 183 + fn null_bytes_pass_structural_parse() { 184 + // We validate structure, not content. XRPC layer handles semantics. 185 + let uri = parse_at_uri("at://did:plc:\0abc/col/rkey").unwrap(); 186 + assert!(uri.authority.contains('\0')); 187 + } 188 + }
+203 -19
crates/opake-core/src/client.rs
··· 7 7 8 8 use log::{debug, info, warn}; 9 9 10 + use crate::atproto::BlobRef; 10 11 use crate::error::Error; 11 - use crate::records::BlobRef; 12 12 use serde::{Deserialize, Serialize}; 13 13 14 14 // --------------------------------------------------------------------------- ··· 163 163 .ok_or_else(|| Error::Auth("not logged in".into())) 164 164 } 165 165 166 + /// Send a request and check the response status. Every XRPC method except 167 + /// `login` (which has custom error handling) goes through here. 168 + async fn send_checked(&self, request: HttpRequest) -> Result<HttpResponse, Error> { 169 + let response = self.transport.send(request).await?; 170 + Self::check_response(&response)?; 171 + Ok(response) 172 + } 173 + 174 + /// Check an XRPC response for errors. Non-2xx responses are parsed as 175 + /// XRPC error bodies (`{"error":"...", "message":"..."}`) when possible, 176 + /// falling back to the raw status code. 177 + fn check_response(response: &HttpResponse) -> Result<(), Error> { 178 + if (200..300).contains(&response.status) { 179 + return Ok(()); 180 + } 181 + 182 + #[derive(Deserialize)] 183 + struct XrpcError { 184 + error: Option<String>, 185 + message: Option<String>, 186 + } 187 + 188 + let message = serde_json::from_slice::<XrpcError>(&response.body) 189 + .ok() 190 + .and_then(|e| match (e.error, e.message) { 191 + (Some(code), Some(msg)) => Some(format!("{code}: {msg}")), 192 + (Some(code), None) => Some(code), 193 + (None, Some(msg)) => Some(msg), 194 + (None, None) => None, 195 + }) 196 + .unwrap_or_else(|| format!("HTTP {}", response.status)); 197 + 198 + if response.status == 404 { 199 + Err(Error::NotFound(message)) 200 + } else { 201 + Err(Error::Xrpc { 202 + status: response.status, 203 + message, 204 + }) 205 + } 206 + } 207 + 166 208 /// Upload raw bytes as a blob via `com.atproto.repo.uploadBlob`. 167 209 pub async fn upload_blob(&self, data: Vec<u8>, mime_type: &str) -> Result<BlobRef, Error> { 168 210 debug!("uploading blob ({} bytes, {})", data.len(), mime_type); 169 211 let auth = self.auth_header()?; 170 212 171 213 let response = self 172 - .transport 173 - .send(HttpRequest { 214 + .send_checked(HttpRequest { 174 215 method: HttpMethod::Post, 175 216 url: format!("{}/xrpc/com.atproto.repo.uploadBlob", self.base_url), 176 217 headers: vec![auth, ("Content-Type".into(), mime_type.into())], ··· 185 226 struct UploadResponse { 186 227 blob: BlobRef, 187 228 } 229 + 188 230 let parsed: UploadResponse = serde_json::from_slice(&response.body)?; 189 231 Ok(parsed.blob) 190 232 } ··· 199 241 ); 200 242 201 243 let response = self 202 - .transport 203 - .send(HttpRequest { 244 + .send_checked(HttpRequest { 204 245 method: HttpMethod::Get, 205 246 url, 206 247 headers: vec![auth], ··· 228 269 }); 229 270 230 271 let response = self 231 - .transport 232 - .send(HttpRequest { 272 + .send_checked(HttpRequest { 233 273 method: HttpMethod::Post, 234 274 url: format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url), 235 275 headers: vec![auth, ("Content-Type".into(), "application/json".into())], ··· 255 295 ); 256 296 257 297 let response = self 258 - .transport 259 - .send(HttpRequest { 298 + .send_checked(HttpRequest { 260 299 method: HttpMethod::Get, 261 300 url, 262 301 headers: vec![auth], ··· 290 329 } 291 330 292 331 let response = self 293 - .transport 294 - .send(HttpRequest { 332 + .send_checked(HttpRequest { 295 333 method: HttpMethod::Get, 296 334 url, 297 335 headers: vec![auth], ··· 314 352 "rkey": rkey, 315 353 }); 316 354 317 - self.transport 318 - .send(HttpRequest { 319 - method: HttpMethod::Post, 320 - url: format!("{}/xrpc/com.atproto.repo.deleteRecord", self.base_url), 321 - headers: vec![auth, ("Content-Type".into(), "application/json".into())], 322 - body: Some(RequestBody::Json(body)), 323 - }) 324 - .await?; 355 + self.send_checked(HttpRequest { 356 + method: HttpMethod::Post, 357 + url: format!("{}/xrpc/com.atproto.repo.deleteRecord", self.base_url), 358 + headers: vec![auth, ("Content-Type".into(), "application/json".into())], 359 + body: Some(RequestBody::Json(body)), 360 + }) 361 + .await?; 325 362 326 363 Ok(()) 327 364 } 328 365 } 366 + 367 + #[cfg(test)] 368 + mod tests { 369 + use super::*; 370 + 371 + // Test check_response directly — it's pure logic over HttpResponse, 372 + // no transport needed. 373 + 374 + fn response(status: u16, body: &str) -> HttpResponse { 375 + HttpResponse { 376 + status, 377 + body: body.as_bytes().to_vec(), 378 + } 379 + } 380 + 381 + // -- 2xx success range -- 382 + 383 + #[test] 384 + fn ok_200_passes() { 385 + assert!(XrpcClient::<DummyTransport>::check_response(&response(200, "")).is_ok()); 386 + } 387 + 388 + #[test] 389 + fn created_201_passes() { 390 + assert!(XrpcClient::<DummyTransport>::check_response(&response(201, "")).is_ok()); 391 + } 392 + 393 + #[test] 394 + fn no_content_204_passes() { 395 + assert!(XrpcClient::<DummyTransport>::check_response(&response(204, "")).is_ok()); 396 + } 397 + 398 + // -- XRPC error bodies -- 399 + 400 + #[test] 401 + fn error_500_with_xrpc_body() { 402 + let r = response( 403 + 500, 404 + r#"{"error":"InternalServerError","message":"Internal Server Error"}"#, 405 + ); 406 + let err = XrpcClient::<DummyTransport>::check_response(&r).unwrap_err(); 407 + match err { 408 + Error::Xrpc { status, message } => { 409 + assert_eq!(status, 500); 410 + assert!(message.contains("InternalServerError")); 411 + assert!(message.contains("Internal Server Error")); 412 + } 413 + other => panic!("expected Xrpc error, got: {other}"), 414 + } 415 + } 416 + 417 + #[test] 418 + fn error_400_with_error_code_only() { 419 + let r = response(400, r#"{"error":"InvalidRequest"}"#); 420 + let err = XrpcClient::<DummyTransport>::check_response(&r).unwrap_err(); 421 + match err { 422 + Error::Xrpc { status, message } => { 423 + assert_eq!(status, 400); 424 + assert_eq!(message, "InvalidRequest"); 425 + } 426 + other => panic!("expected Xrpc error, got: {other}"), 427 + } 428 + } 429 + 430 + #[test] 431 + fn error_403_with_message_only() { 432 + let r = response(403, r#"{"message":"not authorized"}"#); 433 + let err = XrpcClient::<DummyTransport>::check_response(&r).unwrap_err(); 434 + match err { 435 + Error::Xrpc { status, message } => { 436 + assert_eq!(status, 403); 437 + assert_eq!(message, "not authorized"); 438 + } 439 + other => panic!("expected Xrpc error, got: {other}"), 440 + } 441 + } 442 + 443 + // -- 404 maps to NotFound -- 444 + 445 + #[test] 446 + fn error_404_returns_not_found() { 447 + let r = response( 448 + 404, 449 + r#"{"error":"RecordNotFound","message":"no such record"}"#, 450 + ); 451 + let err = XrpcClient::<DummyTransport>::check_response(&r).unwrap_err(); 452 + assert!(matches!(err, Error::NotFound(_))); 453 + } 454 + 455 + // -- Non-JSON error bodies -- 456 + 457 + #[test] 458 + fn error_502_with_html_body() { 459 + let r = response(502, "<html><body>Bad Gateway</body></html>"); 460 + let err = XrpcClient::<DummyTransport>::check_response(&r).unwrap_err(); 461 + match err { 462 + Error::Xrpc { status, message } => { 463 + assert_eq!(status, 502); 464 + assert_eq!(message, "HTTP 502"); 465 + } 466 + other => panic!("expected Xrpc error, got: {other}"), 467 + } 468 + } 469 + 470 + #[test] 471 + fn error_500_with_empty_body() { 472 + let r = response(500, ""); 473 + let err = XrpcClient::<DummyTransport>::check_response(&r).unwrap_err(); 474 + match err { 475 + Error::Xrpc { status, message } => { 476 + assert_eq!(status, 500); 477 + assert_eq!(message, "HTTP 500"); 478 + } 479 + other => panic!("expected Xrpc error, got: {other}"), 480 + } 481 + } 482 + 483 + #[test] 484 + fn error_500_with_empty_json_object() { 485 + let r = response(500, "{}"); 486 + let err = XrpcClient::<DummyTransport>::check_response(&r).unwrap_err(); 487 + match err { 488 + Error::Xrpc { status, message } => { 489 + assert_eq!(status, 500); 490 + assert_eq!(message, "HTTP 500"); 491 + } 492 + other => panic!("expected Xrpc error, got: {other}"), 493 + } 494 + } 495 + 496 + // -- Edge: 3xx is not success -- 497 + 498 + #[test] 499 + fn redirect_300_is_error() { 500 + assert!(XrpcClient::<DummyTransport>::check_response(&response(300, "")).is_err()); 501 + } 502 + 503 + // -- Dummy transport for type parameter (never called) -- 504 + 505 + struct DummyTransport; 506 + 507 + impl Transport for DummyTransport { 508 + async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Error> { 509 + unreachable!("check_response tests don't use transport") 510 + } 511 + } 512 + }
+2 -1
crates/opake-core/src/crypto.rs
··· 19 19 use sha2::Sha256; 20 20 use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; 21 21 22 + use crate::atproto::AtBytes; 22 23 use crate::error::Error; 23 - use crate::records::{AtBytes, WrappedKey, SCHEMA_VERSION}; 24 + use crate::records::{WrappedKey, SCHEMA_VERSION}; 24 25 25 26 /// Re-export so callers don't need direct rand_core / x25519_dalek dependencies. 26 27 pub use aes_gcm::aead::rand_core::{CryptoRng, OsRng, RngCore};
+4
crates/opake-core/src/lib.rs
··· 8 8 // a reqwest-based implementation, the SPA provides one using browser fetch. 9 9 // Crypto is synchronous and pure. Records are just types. 10 10 11 + pub mod atproto; 11 12 pub mod client; 12 13 pub mod crypto; 13 14 pub mod error; 14 15 pub mod records; 16 + 17 + #[cfg(any(test, feature = "test-utils"))] 18 + pub mod test_utils;
+33 -30
crates/opake-core/src/records.rs
··· 2 2 // 3 3 // These mirror the lexicon JSON schemas and handle atproto's serialization 4 4 // conventions ($type discriminators, $bytes for binary data, $link for CIDs). 5 + // 6 + // AT Protocol primitives (AtUri, AtBytes, CidLink, BlobRef) live in the 7 + // `atproto` module. The ones used as record fields are re-exported here. 5 8 6 9 use serde::{Deserialize, Serialize}; 7 10 8 11 use crate::error::Error; 12 + 13 + // Re-export atproto types that appear in record struct fields so that 14 + // downstream code using `records::AtBytes` etc. keeps working. 15 + pub use crate::atproto::{AtBytes, BlobRef, CidLink}; 9 16 10 17 /// The current app.opake.cloud.* schema version this client understands. 11 18 /// Records with version <= this are compatible; higher versions must be rejected. ··· 23 30 ))); 24 31 } 25 32 Ok(()) 26 - } 27 - 28 - // --------------------------------------------------------------------------- 29 - // AT Protocol primitives 30 - // --------------------------------------------------------------------------- 31 - 32 - /// Binary data in atproto JSON: `{ "$bytes": "<base64>" }`. 33 - #[derive(Debug, Clone, Serialize, Deserialize)] 34 - pub struct AtBytes { 35 - #[serde(rename = "$bytes")] 36 - pub encoded: String, 37 - } 38 - 39 - /// CID link reference: `{ "$link": "<cid>" }`. 40 - #[derive(Debug, Clone, Serialize, Deserialize)] 41 - pub struct CidLink { 42 - #[serde(rename = "$link")] 43 - pub cid: String, 44 - } 45 - 46 - /// Blob reference as returned by `com.atproto.repo.uploadBlob`. 47 - #[derive(Debug, Clone, Serialize, Deserialize)] 48 - #[serde(rename_all = "camelCase")] 49 - pub struct BlobRef { 50 - #[serde(rename = "$type")] 51 - pub blob_type: String, 52 - #[serde(rename = "ref")] 53 - pub reference: CidLink, 54 - pub mime_type: String, 55 - pub size: u64, 56 33 } 57 34 58 35 // --------------------------------------------------------------------------- ··· 206 183 #[serde(skip_serializing_if = "Option::is_none")] 207 184 pub modified_at: Option<String>, 208 185 } 186 + 187 + #[cfg(test)] 188 + mod tests { 189 + use super::*; 190 + 191 + #[test] 192 + fn check_version_accepts_current() { 193 + assert!(check_version(SCHEMA_VERSION).is_ok()); 194 + } 195 + 196 + #[test] 197 + fn check_version_accepts_v1() { 198 + assert!(check_version(1).is_ok()); 199 + } 200 + 201 + #[test] 202 + fn check_version_rejects_one_above() { 203 + let err = check_version(SCHEMA_VERSION + 1).unwrap_err(); 204 + assert!(matches!(err, Error::InvalidRecord(_))); 205 + } 206 + 207 + #[test] 208 + fn check_version_rejects_max() { 209 + assert!(check_version(u32::MAX).is_err()); 210 + } 211 + }
+120
crates/opake-core/src/test_utils.rs
··· 1 + // Shared test infrastructure for opake-core and downstream crates. 2 + 3 + use std::collections::VecDeque; 4 + use std::sync::{Arc, Mutex}; 5 + 6 + use crate::client::{HttpRequest, HttpResponse, Transport}; 7 + use crate::error::Error; 8 + 9 + /// A test double for Transport that serves canned responses in FIFO order 10 + /// and captures every request for post-hoc assertion. 11 + #[derive(Clone)] 12 + pub struct MockTransport { 13 + responses: Arc<Mutex<VecDeque<HttpResponse>>>, 14 + captured_requests: Arc<Mutex<Vec<HttpRequest>>>, 15 + } 16 + 17 + impl MockTransport { 18 + pub fn new() -> Self { 19 + Self { 20 + responses: Arc::new(Mutex::new(VecDeque::new())), 21 + captured_requests: Arc::new(Mutex::new(Vec::new())), 22 + } 23 + } 24 + 25 + /// Queue a response. Responses are served in FIFO order — first enqueued 26 + /// is the first returned by `send()`. 27 + pub fn enqueue(&self, response: HttpResponse) { 28 + self.responses.lock().unwrap().push_back(response); 29 + } 30 + 31 + /// All requests that were sent through this transport, in order. 32 + pub fn requests(&self) -> Vec<HttpRequest> { 33 + self.captured_requests.lock().unwrap().clone() 34 + } 35 + } 36 + 37 + impl Default for MockTransport { 38 + fn default() -> Self { 39 + Self::new() 40 + } 41 + } 42 + 43 + impl Transport for MockTransport { 44 + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> { 45 + self.captured_requests.lock().unwrap().push(request); 46 + 47 + self.responses 48 + .lock() 49 + .unwrap() 50 + .pop_front() 51 + .ok_or_else(|| Error::Xrpc { 52 + status: 500, 53 + message: "MockTransport: response queue exhausted".into(), 54 + }) 55 + } 56 + } 57 + 58 + #[cfg(test)] 59 + mod tests { 60 + use super::*; 61 + use crate::client::HttpMethod; 62 + 63 + fn get_request(url: &str) -> HttpRequest { 64 + HttpRequest { 65 + method: HttpMethod::Get, 66 + url: url.into(), 67 + headers: vec![], 68 + body: None, 69 + } 70 + } 71 + 72 + #[tokio::test] 73 + async fn serves_responses_in_fifo_order() { 74 + let mock = MockTransport::new(); 75 + mock.enqueue(HttpResponse { 76 + status: 200, 77 + body: b"first".to_vec(), 78 + }); 79 + mock.enqueue(HttpResponse { 80 + status: 201, 81 + body: b"second".to_vec(), 82 + }); 83 + 84 + let r1 = mock.send(get_request("http://a")).await.unwrap(); 85 + let r2 = mock.send(get_request("http://b")).await.unwrap(); 86 + 87 + assert_eq!(r1.status, 200); 88 + assert_eq!(r1.body, b"first"); 89 + assert_eq!(r2.status, 201); 90 + assert_eq!(r2.body, b"second"); 91 + } 92 + 93 + #[tokio::test] 94 + async fn captures_requests_in_order() { 95 + let mock = MockTransport::new(); 96 + mock.enqueue(HttpResponse { 97 + status: 200, 98 + body: vec![], 99 + }); 100 + mock.enqueue(HttpResponse { 101 + status: 200, 102 + body: vec![], 103 + }); 104 + 105 + mock.send(get_request("http://first")).await.ok(); 106 + mock.send(get_request("http://second")).await.ok(); 107 + 108 + let reqs = mock.requests(); 109 + assert_eq!(reqs.len(), 2); 110 + assert_eq!(reqs[0].url, "http://first"); 111 + assert_eq!(reqs[1].url, "http://second"); 112 + } 113 + 114 + #[tokio::test] 115 + async fn errors_when_queue_exhausted() { 116 + let mock = MockTransport::new(); 117 + let err = mock.send(get_request("http://x")).await.unwrap_err(); 118 + assert!(matches!(err, Error::Xrpc { status: 500, .. })); 119 + } 120 + }