this repo has no description
1
fork

Configure Feed

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

Extract document operations into opake-core and add CLI tests

Move download, upload, list, and delete logic from the CLI into
opake-core::documents as a proper module directory. CLI commands
become thin wrappers over core functions.

Extracted testable helpers in the CLI (path resolution, file writing,
tag filtering, output formatting) and added unit tests for each.
Core module carries its own test suite with mock transport.

Closes #35, closes #36, closes #38, closes #39, closes #40

+1425 -449
+2
CHANGELOG.md
··· 15 15 - Fix missing HTTP status checks in XRPC client (#31) 16 16 17 17 ### Changed 18 + - Add document deletion via com.atproto.repo.deleteRecord (#8) 19 + - Add document listing via com.atproto.repo.listRecords (#7) 18 20 - Extract AT Protocol primitives into dedicated atproto module 19 21 - Consolidate XRPC response checking into send_checked method 20 22 - Add file download with client-side decryption (#6)
+54 -387
crates/opake-cli/src/commands/download.rs
··· 1 1 use std::fs; 2 - use std::path::PathBuf; 2 + use std::path::{Path, PathBuf}; 3 3 4 4 use anyhow::{Context, Result}; 5 - use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 6 5 use clap::Args; 7 - use log::debug; 8 - use opake_core::atproto; 9 - use opake_core::crypto; 10 - use opake_core::records::{self, Encryption}; 6 + use opake_core::documents; 11 7 12 8 use crate::commands::Execute; 13 9 use crate::identity; ··· 24 20 output: Option<PathBuf>, 25 21 } 26 22 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}"))?; 23 + /// Determine where to write the downloaded file. Uses the explicit output path 24 + /// if provided, otherwise falls back to the original filename. 25 + fn resolve_output_path(output_override: Option<PathBuf>, original_name: &str) -> PathBuf { 26 + output_override.unwrap_or_else(|| PathBuf::from(original_name)) 27 + } 48 28 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 - }; 29 + /// Write decrypted content to disk, refusing to overwrite existing files. 30 + fn write_output(path: &Path, content: &[u8]) -> Result<()> { 31 + if path.exists() { 32 + anyhow::bail!( 33 + "output file already exists: {} (use -o to specify a different path)", 34 + path.display() 35 + ); 36 + } 55 37 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)) 38 + fs::write(path, content).context(format!("failed to write {}", path.display()))?; 39 + Ok(()) 91 40 } 92 41 93 42 impl Execute for DownloadCommand { ··· 97 46 let private_key = id.private_key_bytes()?; 98 47 99 48 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)); 49 + documents::download_and_decrypt(&client, &id.did, &private_key, &self.uri).await?; 103 50 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()))?; 51 + let output_path = resolve_output_path(self.output, &name); 52 + write_output(&output_path, &plaintext)?; 113 53 114 54 println!( 115 55 "{} → {} ({} bytes)", ··· 117 57 output_path.display(), 118 58 plaintext.len() 119 59 ); 60 + 120 61 Ok(()) 121 62 } 122 63 } ··· 124 65 #[cfg(test)] 125 66 mod tests { 126 67 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; 68 + use std::fs; 69 + use tempfile::TempDir; 135 70 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) 71 + #[test] 72 + fn resolve_defaults_to_original_filename() { 73 + let path = resolve_output_path(None, "photo.jpg"); 74 + assert_eq!(path, PathBuf::from("photo.jpg")); 148 75 } 149 76 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()) 77 + #[test] 78 + fn resolve_uses_override_when_provided() { 79 + let path = resolve_output_path(Some(PathBuf::from("/tmp/custom.bin")), "photo.jpg"); 80 + assert_eq!(path, PathBuf::from("/tmp/custom.bin")); 155 81 } 156 82 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 - } 83 + #[test] 84 + fn write_output_creates_file() { 85 + let dir = TempDir::new().unwrap(); 86 + let path = dir.path().join("output.txt"); 164 87 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 - } 88 + write_output(&path, b"hello").unwrap(); 206 89 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 } 90 + assert_eq!(fs::read(&path).unwrap(), b"hello"); 216 91 } 217 92 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 - } 93 + #[test] 94 + fn write_output_refuses_to_overwrite() { 95 + let dir = TempDir::new().unwrap(); 96 + let path = dir.path().join("existing.txt"); 97 + fs::write(&path, b"original").unwrap(); 225 98 226 - // -- Happy path -- 99 + let err = write_output(&path, b"new content").unwrap_err(); 100 + let msg = err.to_string(); 101 + assert!(msg.contains("already exists"), "got: {msg}"); 102 + assert!(msg.contains("-o"), "should suggest -o flag, got: {msg}"); 227 103 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")); 104 + // Original content untouched 105 + assert_eq!(fs::read(&path).unwrap(), b"original"); 251 106 } 252 107 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); 108 + #[test] 109 + fn write_output_handles_empty_content() { 110 + let dir = TempDir::new().unwrap(); 111 + let path = dir.path().join("empty.bin"); 417 112 418 - let err = download_and_decrypt(&client, TEST_DID, &private_key, "not-a-uri") 419 - .await 420 - .unwrap_err(); 113 + write_output(&path, b"").unwrap(); 421 114 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 - ); 115 + assert_eq!(fs::read(&path).unwrap(), b""); 449 116 } 450 117 }
+235 -2
crates/opake-cli/src/commands/ls.rs
··· 1 1 use anyhow::Result; 2 2 use clap::Args; 3 + use opake_core::documents::{self, DocumentEntry}; 3 4 4 5 use crate::commands::Execute; 6 + use crate::session; 5 7 6 8 #[derive(Args)] 7 9 /// List your documents ··· 9 11 /// Filter by tag 10 12 #[arg(long)] 11 13 tag: Option<String>, 14 + 15 + /// Show long format with sizes and dates 16 + #[arg(short, long)] 17 + long: bool, 18 + } 19 + 20 + /// Format a byte count for humans. 21 + fn format_size(bytes: u64) -> String { 22 + const KB: u64 = 1024; 23 + const MB: u64 = 1024 * KB; 24 + const GB: u64 = 1024 * MB; 25 + 26 + if bytes >= GB { 27 + format!("{:.1} GB", bytes as f64 / GB as f64) 28 + } else if bytes >= MB { 29 + format!("{:.1} MB", bytes as f64 / MB as f64) 30 + } else if bytes >= KB { 31 + format!("{:.1} KB", bytes as f64 / KB as f64) 32 + } else { 33 + format!("{bytes} B") 34 + } 35 + } 36 + 37 + /// Keep only entries that have the given tag. 38 + fn filter_by_tag(entries: &mut Vec<DocumentEntry>, tag: &str) { 39 + entries.retain(|e| e.tags.iter().any(|t| t == tag)); 40 + } 41 + 42 + /// One line per document: name and URI separated by a tab. 43 + fn format_short(entries: &[DocumentEntry]) -> String { 44 + entries 45 + .iter() 46 + .map(|e| format!("{}\t{}", e.name, e.uri)) 47 + .collect::<Vec<_>>() 48 + .join("\n") 49 + } 50 + 51 + /// Multi-line per document: size, date, mime, name, tags, then URI on the next line. 52 + fn format_long(entries: &[DocumentEntry]) -> String { 53 + entries 54 + .iter() 55 + .map(|e| { 56 + let size = e.size.map(format_size).unwrap_or_else(|| "—".into()); 57 + let mime = e.mime_type.as_deref().unwrap_or("—"); 58 + let tags = if e.tags.is_empty() { 59 + String::new() 60 + } else { 61 + format!(" [{}]", e.tags.join(", ")) 62 + }; 63 + format!( 64 + "{:>10} {} {} {}{}\n {}", 65 + size, e.created_at, mime, e.name, tags, e.uri, 66 + ) 67 + }) 68 + .collect::<Vec<_>>() 69 + .join("\n") 12 70 } 13 71 14 72 impl Execute for LsCommand { 15 73 async fn execute(self) -> Result<()> { 16 - let _client = crate::session::load_client()?; 17 - anyhow::bail!("ls not yet implemented (tracking: chainlink #7)") 74 + let client = session::load_client()?; 75 + let mut entries = documents::list_documents(&client).await?; 76 + 77 + if let Some(ref tag) = self.tag { 78 + filter_by_tag(&mut entries, tag); 79 + } 80 + 81 + if entries.is_empty() { 82 + if let Some(ref tag) = self.tag { 83 + println!("no documents matching tag {:?}", tag); 84 + } else { 85 + println!("no documents"); 86 + } 87 + return Ok(()); 88 + } 89 + 90 + if self.long { 91 + println!("{}", format_long(&entries)); 92 + } else { 93 + println!("{}", format_short(&entries)); 94 + } 95 + 96 + println!("\n{} document(s)", entries.len()); 97 + 98 + Ok(()) 99 + } 100 + } 101 + 102 + #[cfg(test)] 103 + mod tests { 104 + use super::*; 105 + 106 + fn entry(name: &str, uri: &str, tags: Vec<String>) -> DocumentEntry { 107 + DocumentEntry { 108 + uri: uri.into(), 109 + name: name.into(), 110 + size: Some(1024), 111 + mime_type: Some("text/plain".into()), 112 + tags, 113 + created_at: "2026-03-01T00:00:00Z".into(), 114 + } 115 + } 116 + 117 + // -- format_size -- 118 + 119 + #[test] 120 + fn format_size_bytes() { 121 + assert_eq!(format_size(0), "0 B"); 122 + assert_eq!(format_size(512), "512 B"); 123 + assert_eq!(format_size(1023), "1023 B"); 124 + } 125 + 126 + #[test] 127 + fn format_size_kilobytes() { 128 + assert_eq!(format_size(1024), "1.0 KB"); 129 + assert_eq!(format_size(1536), "1.5 KB"); 130 + } 131 + 132 + #[test] 133 + fn format_size_megabytes() { 134 + assert_eq!(format_size(1_048_576), "1.0 MB"); 135 + assert_eq!(format_size(5_242_880), "5.0 MB"); 136 + } 137 + 138 + #[test] 139 + fn format_size_gigabytes() { 140 + assert_eq!(format_size(1_073_741_824), "1.0 GB"); 141 + } 142 + 143 + // -- filter_by_tag -- 144 + 145 + #[test] 146 + fn filter_keeps_matching_tag() { 147 + let mut entries = vec![ 148 + entry("a.txt", "at://did/col/a", vec!["photos".into()]), 149 + entry("b.txt", "at://did/col/b", vec!["docs".into()]), 150 + entry( 151 + "c.txt", 152 + "at://did/col/c", 153 + vec!["photos".into(), "family".into()], 154 + ), 155 + ]; 156 + filter_by_tag(&mut entries, "photos"); 157 + assert_eq!(entries.len(), 2); 158 + assert_eq!(entries[0].name, "a.txt"); 159 + assert_eq!(entries[1].name, "c.txt"); 160 + } 161 + 162 + #[test] 163 + fn filter_removes_all_when_no_match() { 164 + let mut entries = vec![entry("a.txt", "at://did/col/a", vec!["docs".into()])]; 165 + filter_by_tag(&mut entries, "nonexistent"); 166 + assert!(entries.is_empty()); 167 + } 168 + 169 + #[test] 170 + fn filter_on_empty_list() { 171 + let mut entries: Vec<DocumentEntry> = vec![]; 172 + filter_by_tag(&mut entries, "anything"); 173 + assert!(entries.is_empty()); 174 + } 175 + 176 + // -- format_short -- 177 + 178 + #[test] 179 + fn short_format_single_entry() { 180 + let entries = vec![entry("notes.txt", "at://did/col/abc", vec![])]; 181 + let output = format_short(&entries); 182 + assert_eq!(output, "notes.txt\tat://did/col/abc"); 183 + } 184 + 185 + #[test] 186 + fn short_format_multiple_entries() { 187 + let entries = vec![ 188 + entry("a.txt", "at://did/col/a", vec![]), 189 + entry("b.txt", "at://did/col/b", vec![]), 190 + ]; 191 + let output = format_short(&entries); 192 + assert_eq!(output, "a.txt\tat://did/col/a\nb.txt\tat://did/col/b"); 193 + } 194 + 195 + // -- format_long -- 196 + 197 + #[test] 198 + fn long_format_includes_size_and_mime() { 199 + let entries = vec![entry("notes.txt", "at://did/col/abc", vec![])]; 200 + let output = format_long(&entries); 201 + assert!( 202 + output.contains("1.0 KB"), 203 + "should contain formatted size, got: {output}" 204 + ); 205 + assert!(output.contains("text/plain"), "should contain mime type"); 206 + assert!(output.contains("notes.txt"), "should contain filename"); 207 + assert!(output.contains("at://did/col/abc"), "should contain URI"); 208 + } 209 + 210 + #[test] 211 + fn long_format_shows_tags() { 212 + let entries = vec![entry( 213 + "photo.jpg", 214 + "at://did/col/p", 215 + vec!["vacation".into(), "beach".into()], 216 + )]; 217 + let output = format_long(&entries); 218 + assert!(output.contains("[vacation, beach]"), "got: {output}"); 219 + } 220 + 221 + #[test] 222 + fn long_format_no_tags_no_brackets() { 223 + let entries = vec![entry("doc.pdf", "at://did/col/d", vec![])]; 224 + let output = format_long(&entries); 225 + assert!( 226 + !output.contains('['), 227 + "should not have brackets when no tags" 228 + ); 229 + } 230 + 231 + #[test] 232 + fn long_format_missing_size() { 233 + let mut entries = vec![entry("unknown.bin", "at://did/col/u", vec![])]; 234 + entries[0].size = None; 235 + let output = format_long(&entries); 236 + assert!( 237 + output.contains('—'), 238 + "should show dash for missing size, got: {output}" 239 + ); 240 + } 241 + 242 + #[test] 243 + fn long_format_missing_mime() { 244 + let mut entries = vec![entry("mystery", "at://did/col/m", vec![])]; 245 + entries[0].mime_type = None; 246 + let output = format_long(&entries); 247 + assert!( 248 + output.contains('—'), 249 + "should show dash for missing mime, got: {output}" 250 + ); 18 251 } 19 252 }
+25 -3
crates/opake-cli/src/commands/rm.rs
··· 1 - use anyhow::Result; 1 + use anyhow::{Context, Result}; 2 2 use clap::Args; 3 + use opake_core::documents; 3 4 4 5 use crate::commands::Execute; 6 + use crate::session; 5 7 6 8 #[derive(Args)] 7 9 /// Delete a document 8 10 pub struct RmCommand { 9 11 /// AT URI of the document record 10 12 uri: String, 13 + 14 + /// Skip confirmation prompt 15 + #[arg(short, long)] 16 + yes: bool, 11 17 } 12 18 13 19 impl Execute for RmCommand { 14 20 async fn execute(self) -> Result<()> { 15 - let _client = crate::session::load_client()?; 16 - anyhow::bail!("rm not yet implemented (tracking: chainlink #8)") 21 + let client = session::load_client()?; 22 + 23 + if !self.yes { 24 + eprint!("delete {}? [y/N] ", self.uri); 25 + let mut answer = String::new(); 26 + std::io::stdin() 27 + .read_line(&mut answer) 28 + .context("failed to read confirmation")?; 29 + if !answer.trim().eq_ignore_ascii_case("y") { 30 + println!("aborted"); 31 + return Ok(()); 32 + } 33 + } 34 + 35 + documents::delete_document(&client, &self.uri).await?; 36 + println!("deleted {}", self.uri); 37 + 38 + Ok(()) 17 39 } 18 40 }
+11 -57
crates/opake-cli/src/commands/upload.rs
··· 2 2 use std::path::PathBuf; 3 3 4 4 use anyhow::{Context, Result}; 5 - use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 6 5 use chrono::Utc; 7 6 use clap::Args; 8 - use log::debug; 9 - use opake_core::crypto::{self, OsRng}; 10 - use opake_core::records::{AtBytes, DirectEncryption, Document, Encryption, EncryptionEnvelope}; 7 + use opake_core::crypto::OsRng; 8 + use opake_core::documents::{self, UploadParams}; 11 9 12 10 use crate::commands::Execute; 13 11 use crate::identity; 14 12 use crate::session; 15 - 16 - // 50MB, Bluesky PDS default (I think). We might want to make this dynamic at some point. 17 - const MAX_BLOB_SIZE: u64 = 50 * 1024 * 1024; 18 - const DOCUMENT_COLLECTION: &str = "app.opake.cloud.document"; 19 13 20 14 #[derive(Args)] 21 15 /// Upload and encrypt a file ··· 45 39 let plaintext = 46 40 fs::read(&self.path).context(format!("failed to read {}", self.path.display()))?; 47 41 48 - let file_size = plaintext.len() as u64; 49 - anyhow::ensure!( 50 - file_size <= MAX_BLOB_SIZE, 51 - "file is {} bytes — PDS blob limit is {} bytes (50 MB)", 52 - file_size, 53 - MAX_BLOB_SIZE 54 - ); 55 - 56 42 let filename = self 57 43 .path 58 44 .file_name() ··· 63 49 .first_raw() 64 50 .unwrap_or("application/octet-stream"); 65 51 66 - debug!( 67 - "encrypting {} ({} bytes, {})", 68 - filename, file_size, mime_type 69 - ); 70 - 71 - let rng = &mut OsRng; 72 - let content_key = crypto::generate_content_key(rng); 73 - let payload = crypto::encrypt_blob(&content_key, &plaintext, rng)?; 74 - 75 - debug!( 76 - "uploading encrypted blob ({} bytes)", 77 - payload.ciphertext.len() 78 - ); 79 - 80 - let blob_ref = client 81 - .upload_blob(payload.ciphertext, "application/octet-stream") 82 - .await?; 83 - 84 - let wrapped_key = crypto::wrap_key(&content_key, &owner_pubkey, &id.did, rng)?; 85 - 86 - let document = Document { 87 - mime_type: Some(mime_type.into()), 88 - size: Some(file_size), 52 + let params = UploadParams { 53 + plaintext: &plaintext, 54 + filename: &filename, 55 + mime_type, 56 + owner_did: &id.did, 57 + owner_pubkey: &owner_pubkey, 89 58 tags: self.tags, 90 - visibility: Some("private".into()), 91 - ..Document::new( 92 - filename.clone(), 93 - blob_ref, 94 - Encryption::Direct(DirectEncryption { 95 - envelope: EncryptionEnvelope { 96 - algo: "aes-256-gcm".into(), 97 - nonce: AtBytes { 98 - encoded: BASE64.encode(payload.nonce), 99 - }, 100 - keys: vec![wrapped_key], 101 - }, 102 - }), 103 - Utc::now().to_rfc3339(), 104 - ) 59 + created_at: &Utc::now().to_rfc3339(), 105 60 }; 106 61 107 - let record_ref = client.create_record(DOCUMENT_COLLECTION, &document).await?; 62 + let uri = documents::encrypt_and_upload(&client, &params, &mut OsRng).await?; 108 63 109 - println!("{} → {}", filename, record_ref.uri); 64 + println!("{} → {}", filename, uri); 110 65 Ok(()) 111 66 } 112 67 } ··· 126 81 let result = rt.block_on(cmd.execute()); 127 82 assert!(result.is_err()); 128 83 let err = result.unwrap_err().to_string(); 129 - // Fails at config/session loading or file reading depending on env 130 84 assert!( 131 85 err.contains("failed to read") 132 86 || err.contains("run `opake login` first")
+124
crates/opake-core/src/documents/delete.rs
··· 1 + use log::debug; 2 + 3 + use crate::atproto; 4 + use crate::client::{Transport, XrpcClient}; 5 + use crate::error::Error; 6 + 7 + use super::DOCUMENT_COLLECTION; 8 + 9 + /// Delete a document record by AT-URI. Validates the collection is 10 + /// `app.opake.cloud.document` to prevent accidental deletion of other 11 + /// record types. The blob becomes orphaned and will eventually be 12 + /// garbage-collected by the PDS. 13 + pub async fn delete_document(client: &XrpcClient<impl Transport>, uri: &str) -> Result<(), Error> { 14 + let at_uri = atproto::parse_at_uri(uri)?; 15 + 16 + if at_uri.collection != DOCUMENT_COLLECTION { 17 + return Err(Error::InvalidRecord(format!( 18 + "expected a document URI ({}), got collection: {}", 19 + DOCUMENT_COLLECTION, at_uri.collection, 20 + ))); 21 + } 22 + 23 + debug!("deleting record {}", uri); 24 + client 25 + .delete_record(&at_uri.collection, &at_uri.rkey) 26 + .await?; 27 + 28 + Ok(()) 29 + } 30 + 31 + #[cfg(test)] 32 + mod tests { 33 + use super::*; 34 + use crate::client::{HttpResponse, RequestBody}; 35 + use crate::test_utils::MockTransport; 36 + 37 + use super::super::tests::{mock_client, TEST_DID}; 38 + 39 + #[tokio::test] 40 + async fn happy_path() { 41 + let mock = MockTransport::new(); 42 + mock.enqueue(HttpResponse { 43 + status: 200, 44 + body: b"{}".to_vec(), 45 + }); 46 + 47 + let client = mock_client(mock.clone()); 48 + let uri = format!("at://{}/app.opake.cloud.document/abc123", TEST_DID); 49 + delete_document(&client, &uri).await.unwrap(); 50 + 51 + let requests = mock.requests(); 52 + assert_eq!(requests.len(), 1); 53 + assert!(requests[0].url.contains("deleteRecord")); 54 + 55 + match &requests[0].body { 56 + Some(RequestBody::Json(v)) => { 57 + assert_eq!(v["collection"], "app.opake.cloud.document"); 58 + assert_eq!(v["rkey"], "abc123"); 59 + assert_eq!(v["repo"], TEST_DID); 60 + } 61 + _ => panic!("expected JSON body"), 62 + } 63 + } 64 + 65 + #[tokio::test] 66 + async fn rejects_grant_uri() { 67 + let mock = MockTransport::new(); 68 + let client = mock_client(mock); 69 + let uri = format!("at://{}/app.opake.cloud.grant/abc123", TEST_DID); 70 + let err = delete_document(&client, &uri).await.unwrap_err(); 71 + assert!( 72 + err.to_string().contains("expected a document URI"), 73 + "got: {err}" 74 + ); 75 + } 76 + 77 + #[tokio::test] 78 + async fn rejects_arbitrary_collection() { 79 + let mock = MockTransport::new(); 80 + let client = mock_client(mock); 81 + let uri = format!("at://{}/app.bsky.feed.post/abc123", TEST_DID); 82 + let err = delete_document(&client, &uri).await.unwrap_err(); 83 + assert!( 84 + err.to_string().contains("expected a document URI"), 85 + "got: {err}" 86 + ); 87 + } 88 + 89 + #[tokio::test] 90 + async fn rejects_invalid_uri() { 91 + let mock = MockTransport::new(); 92 + let client = mock_client(mock); 93 + let err = delete_document(&client, "not-a-uri").await.unwrap_err(); 94 + assert!(err.to_string().contains("AT-URI"), "got: {err}"); 95 + } 96 + 97 + #[tokio::test] 98 + async fn pds_404() { 99 + let mock = MockTransport::new(); 100 + mock.enqueue(HttpResponse { 101 + status: 404, 102 + body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 103 + }); 104 + 105 + let client = mock_client(mock); 106 + let uri = format!("at://{}/app.opake.cloud.document/gone", TEST_DID); 107 + let err = delete_document(&client, &uri).await.unwrap_err(); 108 + assert!(matches!(err, Error::NotFound(_))); 109 + } 110 + 111 + #[tokio::test] 112 + async fn pds_500() { 113 + let mock = MockTransport::new(); 114 + mock.enqueue(HttpResponse { 115 + status: 500, 116 + body: br#"{"error":"InternalServerError","message":"storage error"}"#.to_vec(), 117 + }); 118 + 119 + let client = mock_client(mock); 120 + let uri = format!("at://{}/app.opake.cloud.document/abc", TEST_DID); 121 + let err = delete_document(&client, &uri).await.unwrap_err(); 122 + assert!(matches!(err, Error::Xrpc { .. })); 123 + } 124 + }
+338
crates/opake-core/src/documents/download.rs
··· 1 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 2 + use log::debug; 3 + 4 + use crate::atproto; 5 + use crate::client::{Transport, XrpcClient}; 6 + use crate::crypto; 7 + use crate::error::Error; 8 + use crate::records::{self, Document, Encryption}; 9 + 10 + /// Fetch a document record and its encrypted blob, then decrypt. 11 + /// Returns `(filename, plaintext_bytes)`. 12 + pub async fn download_and_decrypt( 13 + client: &XrpcClient<impl Transport>, 14 + did: &str, 15 + private_key: &[u8; 32], 16 + uri: &str, 17 + ) -> Result<(String, Vec<u8>), Error> { 18 + let at_uri = atproto::parse_at_uri(uri)?; 19 + 20 + debug!("fetching record {}", uri); 21 + let entry = client 22 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 23 + .await?; 24 + 25 + let doc: Document = serde_json::from_value(entry.value)?; 26 + records::check_version(doc.version)?; 27 + 28 + let envelope = match &doc.encryption { 29 + Encryption::Direct(direct) => &direct.envelope, 30 + Encryption::Keyring(_) => { 31 + return Err(Error::InvalidRecord( 32 + "keyring-encrypted documents not yet supported".into(), 33 + )); 34 + } 35 + }; 36 + 37 + let wrapped_key = envelope.keys.iter().find(|k| k.did == did).ok_or_else(|| { 38 + Error::InvalidRecord(format!( 39 + "no wrapped key for DID ({did}) — you may not have access" 40 + )) 41 + })?; 42 + 43 + debug!("unwrapping content key"); 44 + let content_key = crypto::unwrap_key(wrapped_key, private_key)?; 45 + 46 + let nonce_bytes = BASE64 47 + .decode(&envelope.nonce.encoded) 48 + .map_err(|e| Error::InvalidRecord(format!("invalid base64 in encryption nonce: {e}")))?; 49 + let nonce: [u8; 12] = nonce_bytes.try_into().map_err(|v: Vec<u8>| { 50 + Error::InvalidRecord(format!("nonce is {} bytes, expected 12", v.len())) 51 + })?; 52 + 53 + debug!( 54 + "fetching blob did={} cid={}", 55 + at_uri.authority, doc.blob.reference.cid 56 + ); 57 + let ciphertext = client 58 + .get_blob(&at_uri.authority, &doc.blob.reference.cid) 59 + .await?; 60 + 61 + debug!("decrypting {} bytes", ciphertext.len()); 62 + let plaintext = crypto::decrypt_blob( 63 + &content_key, 64 + &crypto::EncryptedPayload { ciphertext, nonce }, 65 + )?; 66 + 67 + Ok((doc.name, plaintext)) 68 + } 69 + 70 + #[cfg(test)] 71 + mod tests { 72 + use super::*; 73 + use crate::client::HttpResponse; 74 + use crate::crypto::OsRng; 75 + use crate::records::{self, AtBytes, BlobRef, CidLink, DirectEncryption, EncryptionEnvelope}; 76 + use crate::test_utils::MockTransport; 77 + 78 + use super::super::tests::{mock_client, TEST_DID, TEST_URI}; 79 + 80 + fn test_keypair() -> ([u8; 32], [u8; 32]) { 81 + let secret = crypto::X25519DalekStaticSecret::random_from_rng(OsRng); 82 + let public = crypto::X25519DalekPublicKey::from(&secret); 83 + (public.to_bytes(), secret.to_bytes()) 84 + } 85 + 86 + struct EncryptedFixture { 87 + ciphertext: Vec<u8>, 88 + nonce: [u8; 12], 89 + wrapped_key: records::WrappedKey, 90 + } 91 + 92 + fn encrypt_for_download(plaintext: &[u8], public_key: &[u8; 32]) -> EncryptedFixture { 93 + let rng = &mut OsRng; 94 + let content_key = crypto::generate_content_key(rng); 95 + let payload = crypto::encrypt_blob(&content_key, plaintext, rng).unwrap(); 96 + let wrapped_key = crypto::wrap_key(&content_key, public_key, TEST_DID, rng).unwrap(); 97 + EncryptedFixture { 98 + ciphertext: payload.ciphertext, 99 + nonce: payload.nonce, 100 + wrapped_key, 101 + } 102 + } 103 + 104 + fn document_from_fixture(fixture: &EncryptedFixture) -> Document { 105 + Document { 106 + mime_type: Some("text/plain".into()), 107 + size: Some(42), 108 + visibility: Some("private".into()), 109 + ..Document::new( 110 + "test-file.txt".into(), 111 + BlobRef { 112 + blob_type: "blob".into(), 113 + reference: CidLink { 114 + cid: "bafytest123".into(), 115 + }, 116 + mime_type: "application/octet-stream".into(), 117 + size: fixture.ciphertext.len() as u64, 118 + }, 119 + Encryption::Direct(DirectEncryption { 120 + envelope: EncryptionEnvelope { 121 + algo: "aes-256-gcm".into(), 122 + nonce: AtBytes { 123 + encoded: BASE64.encode(fixture.nonce), 124 + }, 125 + keys: vec![fixture.wrapped_key.clone()], 126 + }, 127 + }), 128 + "2026-03-01T00:00:00Z".into(), 129 + ) 130 + } 131 + } 132 + 133 + fn record_response(doc: &Document) -> HttpResponse { 134 + let body = serde_json::to_vec(&serde_json::json!({ 135 + "uri": TEST_URI, 136 + "cid": "bafyrecord", 137 + "value": doc, 138 + })) 139 + .unwrap(); 140 + HttpResponse { status: 200, body } 141 + } 142 + 143 + fn blob_response(data: &[u8]) -> HttpResponse { 144 + HttpResponse { 145 + status: 200, 146 + body: data.to_vec(), 147 + } 148 + } 149 + 150 + #[tokio::test] 151 + async fn roundtrip() { 152 + let (public_key, private_key) = test_keypair(); 153 + let plaintext = b"the quick brown fox jumps over the lazy dog"; 154 + let fixture = encrypt_for_download(plaintext, &public_key); 155 + let doc = document_from_fixture(&fixture); 156 + 157 + let mock = MockTransport::new(); 158 + mock.enqueue(record_response(&doc)); 159 + mock.enqueue(blob_response(&fixture.ciphertext)); 160 + 161 + let client = mock_client(mock.clone()); 162 + let (name, decrypted) = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 163 + .await 164 + .unwrap(); 165 + 166 + assert_eq!(name, "test-file.txt"); 167 + assert_eq!(decrypted, plaintext); 168 + 169 + let requests = mock.requests(); 170 + assert_eq!(requests.len(), 2); 171 + assert!(requests[0].url.contains("getRecord")); 172 + assert!(requests[1].url.contains("getBlob")); 173 + } 174 + 175 + #[tokio::test] 176 + async fn empty_file() { 177 + let (public_key, private_key) = test_keypair(); 178 + let fixture = encrypt_for_download(b"", &public_key); 179 + let doc = document_from_fixture(&fixture); 180 + 181 + let mock = MockTransport::new(); 182 + mock.enqueue(record_response(&doc)); 183 + mock.enqueue(blob_response(&fixture.ciphertext)); 184 + 185 + let client = mock_client(mock); 186 + let (_, decrypted) = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 187 + .await 188 + .unwrap(); 189 + assert!(decrypted.is_empty()); 190 + } 191 + 192 + #[tokio::test] 193 + async fn rejects_no_key_for_did() { 194 + let (public_key, private_key) = test_keypair(); 195 + let fixture = encrypt_for_download(b"data", &public_key); 196 + let doc = document_from_fixture(&fixture); 197 + 198 + let mock = MockTransport::new(); 199 + mock.enqueue(record_response(&doc)); 200 + 201 + let client = mock_client(mock); 202 + let err = download_and_decrypt(&client, "did:plc:wrong", &private_key, TEST_URI) 203 + .await 204 + .unwrap_err(); 205 + assert!( 206 + err.to_string().contains("no wrapped key"), 207 + "expected 'no wrapped key' error, got: {err}" 208 + ); 209 + } 210 + 211 + #[tokio::test] 212 + async fn rejects_keyring_encryption() { 213 + let doc_value = serde_json::json!({ 214 + "uri": TEST_URI, 215 + "cid": "bafyrecord", 216 + "value": { 217 + "version": 1, 218 + "name": "keyring-doc.txt", 219 + "blob": { 220 + "$type": "blob", 221 + "ref": { "$link": "bafytest" }, 222 + "mimeType": "application/octet-stream", 223 + "size": 100, 224 + }, 225 + "encryption": { 226 + "$type": "app.opake.cloud.document#keyringEncryption", 227 + "keyringRef": { 228 + "keyring": "at://did:plc:test/app.opake.cloud.keyring/kr1", 229 + "wrappedContentKey": { "$bytes": "AAAA" }, 230 + "rotation": 1, 231 + }, 232 + "algo": "aes-256-gcm", 233 + "nonce": { "$bytes": "AAAAAAAAAAAAAAAA" }, 234 + }, 235 + "createdAt": "2026-03-01T00:00:00Z", 236 + }, 237 + }); 238 + 239 + let mock = MockTransport::new(); 240 + mock.enqueue(HttpResponse { 241 + status: 200, 242 + body: serde_json::to_vec(&doc_value).unwrap(), 243 + }); 244 + 245 + let (_, private_key) = test_keypair(); 246 + let client = mock_client(mock); 247 + let err = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 248 + .await 249 + .unwrap_err(); 250 + assert!(err.to_string().contains("keyring"), "got: {err}"); 251 + } 252 + 253 + #[tokio::test] 254 + async fn pds_404_on_record() { 255 + let mock = MockTransport::new(); 256 + mock.enqueue(HttpResponse { 257 + status: 404, 258 + body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 259 + }); 260 + 261 + let (_, private_key) = test_keypair(); 262 + let client = mock_client(mock); 263 + let err = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 264 + .await 265 + .unwrap_err(); 266 + assert!(matches!(err, Error::NotFound(_))); 267 + } 268 + 269 + #[tokio::test] 270 + async fn pds_500_on_blob() { 271 + let (public_key, private_key) = test_keypair(); 272 + let fixture = encrypt_for_download(b"data", &public_key); 273 + let doc = document_from_fixture(&fixture); 274 + 275 + let mock = MockTransport::new(); 276 + mock.enqueue(record_response(&doc)); 277 + mock.enqueue(HttpResponse { 278 + status: 500, 279 + body: br#"{"error":"InternalServerError","message":"blob storage error"}"#.to_vec(), 280 + }); 281 + 282 + let client = mock_client(mock); 283 + let err = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 284 + .await 285 + .unwrap_err(); 286 + assert!(matches!(err, Error::Xrpc { .. })); 287 + } 288 + 289 + #[tokio::test] 290 + async fn rejects_future_schema_version() { 291 + let (public_key, private_key) = test_keypair(); 292 + let fixture = encrypt_for_download(b"data", &public_key); 293 + let mut doc = document_from_fixture(&fixture); 294 + doc.version = records::SCHEMA_VERSION + 1; 295 + 296 + let mock = MockTransport::new(); 297 + mock.enqueue(record_response(&doc)); 298 + 299 + let client = mock_client(mock); 300 + let err = download_and_decrypt(&client, TEST_DID, &private_key, TEST_URI) 301 + .await 302 + .unwrap_err(); 303 + assert!(err.to_string().contains("schema version"), "got: {err}"); 304 + } 305 + 306 + #[tokio::test] 307 + async fn rejects_invalid_uri() { 308 + let (_, private_key) = test_keypair(); 309 + let mock = MockTransport::new(); 310 + let client = mock_client(mock); 311 + let err = download_and_decrypt(&client, TEST_DID, &private_key, "not-a-uri") 312 + .await 313 + .unwrap_err(); 314 + assert!(err.to_string().contains("AT-URI"), "got: {err}"); 315 + } 316 + 317 + #[tokio::test] 318 + async fn wrong_private_key() { 319 + let (public_key, _) = test_keypair(); 320 + let (_, wrong_private_key) = test_keypair(); 321 + let fixture = encrypt_for_download(b"secret data", &public_key); 322 + let doc = document_from_fixture(&fixture); 323 + 324 + let mock = MockTransport::new(); 325 + mock.enqueue(record_response(&doc)); 326 + 327 + let client = mock_client(mock); 328 + let err = download_and_decrypt(&client, TEST_DID, &wrong_private_key, TEST_URI) 329 + .await 330 + .unwrap_err(); 331 + // Wrong key produces either a KeyWrap or Decryption error depending 332 + // on where AES-KW detects the integrity failure. 333 + assert!( 334 + matches!(err, Error::KeyWrap(_) | Error::Decryption(_)), 335 + "expected key/decryption error, got: {err}" 336 + ); 337 + } 338 + }
+215
crates/opake-core/src/documents/list.rs
··· 1 + use log::debug; 2 + 3 + use crate::client::{RecordPage, Transport, XrpcClient}; 4 + use crate::error::Error; 5 + use crate::records::{self, Document}; 6 + 7 + use super::DOCUMENT_COLLECTION; 8 + 9 + /// A document listing entry with its AT-URI and parsed metadata. 10 + #[derive(Debug)] 11 + pub struct DocumentEntry { 12 + pub uri: String, 13 + pub name: String, 14 + pub size: Option<u64>, 15 + pub mime_type: Option<String>, 16 + pub tags: Vec<String>, 17 + pub created_at: String, 18 + } 19 + 20 + /// Fetch all document records, paginating through the full collection. 21 + /// Silently skips records that can't be parsed or have an unsupported 22 + /// schema version — these are expected when upgrading clients. 23 + pub async fn list_documents( 24 + client: &XrpcClient<impl Transport>, 25 + ) -> Result<Vec<DocumentEntry>, Error> { 26 + let mut entries = Vec::new(); 27 + let mut cursor: Option<String> = None; 28 + 29 + loop { 30 + debug!("listing records, cursor={:?}", cursor); 31 + let page: RecordPage = client 32 + .list_records(DOCUMENT_COLLECTION, Some(100), cursor.as_deref()) 33 + .await?; 34 + 35 + for record in &page.records { 36 + let doc: Document = match serde_json::from_value(record.value.clone()) { 37 + Ok(d) => d, 38 + Err(e) => { 39 + debug!("skipping unparseable record {}: {}", record.uri, e); 40 + continue; 41 + } 42 + }; 43 + 44 + if records::check_version(doc.version).is_err() { 45 + debug!( 46 + "skipping record {} with unsupported version {}", 47 + record.uri, doc.version 48 + ); 49 + continue; 50 + } 51 + 52 + entries.push(DocumentEntry { 53 + uri: record.uri.clone(), 54 + name: doc.name, 55 + size: doc.size, 56 + mime_type: doc.mime_type, 57 + tags: doc.tags, 58 + created_at: doc.created_at, 59 + }); 60 + } 61 + 62 + match page.cursor { 63 + Some(c) => cursor = Some(c), 64 + None => break, 65 + } 66 + } 67 + 68 + Ok(entries) 69 + } 70 + 71 + #[cfg(test)] 72 + mod tests { 73 + use super::*; 74 + use crate::client::HttpResponse; 75 + use crate::records; 76 + use crate::test_utils::MockTransport; 77 + 78 + use super::super::tests::{dummy_document, list_records_response, mock_client}; 79 + 80 + #[tokio::test] 81 + async fn single_document() { 82 + let doc = dummy_document("notes.txt", 1024, vec![]); 83 + let mock = MockTransport::new(); 84 + mock.enqueue(list_records_response(&[("abc", doc)], None)); 85 + 86 + let client = mock_client(mock.clone()); 87 + let entries = list_documents(&client).await.unwrap(); 88 + 89 + assert_eq!(entries.len(), 1); 90 + assert_eq!(entries[0].name, "notes.txt"); 91 + assert_eq!(entries[0].size, Some(1024)); 92 + assert!(entries[0].uri.contains("abc")); 93 + 94 + let requests = mock.requests(); 95 + assert_eq!(requests.len(), 1); 96 + assert!(requests[0].url.contains("listRecords")); 97 + assert!(requests[0].url.contains("app.opake.cloud.document")); 98 + } 99 + 100 + #[tokio::test] 101 + async fn multiple_documents() { 102 + let docs = vec![ 103 + ( 104 + "a1", 105 + dummy_document("photo.jpg", 2_000_000, vec!["photos".into()]), 106 + ), 107 + ("a2", dummy_document("resume.pdf", 50_000, vec![])), 108 + ( 109 + "a3", 110 + dummy_document("secret.key", 256, vec!["crypto".into(), "keys".into()]), 111 + ), 112 + ]; 113 + let mock = MockTransport::new(); 114 + mock.enqueue(list_records_response(&docs, None)); 115 + 116 + let client = mock_client(mock); 117 + let entries = list_documents(&client).await.unwrap(); 118 + 119 + assert_eq!(entries.len(), 3); 120 + assert_eq!(entries[0].name, "photo.jpg"); 121 + assert_eq!(entries[1].name, "resume.pdf"); 122 + assert_eq!(entries[2].name, "secret.key"); 123 + assert_eq!(entries[2].tags, vec!["crypto", "keys"]); 124 + } 125 + 126 + #[tokio::test] 127 + async fn paginates_through_multiple_pages() { 128 + let mock = MockTransport::new(); 129 + mock.enqueue(list_records_response( 130 + &[("a1", dummy_document("file1.txt", 100, vec![]))], 131 + Some("cursor-abc"), 132 + )); 133 + mock.enqueue(list_records_response( 134 + &[("a2", dummy_document("file2.txt", 200, vec![]))], 135 + None, 136 + )); 137 + 138 + let client = mock_client(mock.clone()); 139 + let entries = list_documents(&client).await.unwrap(); 140 + 141 + assert_eq!(entries.len(), 2); 142 + assert_eq!(entries[0].name, "file1.txt"); 143 + assert_eq!(entries[1].name, "file2.txt"); 144 + 145 + let requests = mock.requests(); 146 + assert_eq!(requests.len(), 2); 147 + assert!(requests[1].url.contains("cursor=cursor-abc")); 148 + } 149 + 150 + #[tokio::test] 151 + async fn empty_collection() { 152 + let mock = MockTransport::new(); 153 + mock.enqueue(list_records_response(&[], None)); 154 + 155 + let client = mock_client(mock); 156 + let entries = list_documents(&client).await.unwrap(); 157 + assert!(entries.is_empty()); 158 + } 159 + 160 + #[tokio::test] 161 + async fn skips_unparseable_records() { 162 + let body = serde_json::json!({ 163 + "records": [ 164 + { 165 + "uri": "at://did:plc:test/app.opake.cloud.document/bad1", 166 + "cid": "bafybad", 167 + "value": { "this": "is not a document" }, 168 + }, 169 + { 170 + "uri": "at://did:plc:test/app.opake.cloud.document/good1", 171 + "cid": "bafygood", 172 + "value": dummy_document("good.txt", 42, vec![]), 173 + }, 174 + ] 175 + }); 176 + 177 + let mock = MockTransport::new(); 178 + mock.enqueue(HttpResponse { 179 + status: 200, 180 + body: serde_json::to_vec(&body).unwrap(), 181 + }); 182 + 183 + let client = mock_client(mock); 184 + let entries = list_documents(&client).await.unwrap(); 185 + 186 + assert_eq!(entries.len(), 1); 187 + assert_eq!(entries[0].name, "good.txt"); 188 + } 189 + 190 + #[tokio::test] 191 + async fn skips_future_schema_version() { 192 + let mut doc = dummy_document("future.txt", 100, vec![]); 193 + doc.version = records::SCHEMA_VERSION + 1; 194 + 195 + let mock = MockTransport::new(); 196 + mock.enqueue(list_records_response(&[("f1", doc)], None)); 197 + 198 + let client = mock_client(mock); 199 + let entries = list_documents(&client).await.unwrap(); 200 + assert!(entries.is_empty()); 201 + } 202 + 203 + #[tokio::test] 204 + async fn pds_error_propagates() { 205 + let mock = MockTransport::new(); 206 + mock.enqueue(HttpResponse { 207 + status: 500, 208 + body: br#"{"error":"InternalServerError","message":"something broke"}"#.to_vec(), 209 + }); 210 + 211 + let client = mock_client(mock); 212 + let err = list_documents(&client).await.unwrap_err(); 213 + assert!(matches!(err, Error::Xrpc { .. })); 214 + } 215 + }
+102
crates/opake-core/src/documents/mod.rs
··· 1 + // Document operations: list, upload+encrypt, download+decrypt, delete. 2 + // 3 + // These are the high-level building blocks that both the CLI and the future 4 + // web AppView use. They talk to the PDS via XrpcClient and handle record 5 + // parsing, schema version checks, and crypto — but never touch the filesystem, 6 + // config, or user prompts. 7 + 8 + mod delete; 9 + mod download; 10 + mod list; 11 + mod upload; 12 + 13 + pub use delete::delete_document; 14 + pub use download::download_and_decrypt; 15 + pub use list::{list_documents, DocumentEntry}; 16 + pub use upload::{encrypt_and_upload, UploadParams}; 17 + 18 + const DOCUMENT_COLLECTION: &str = "app.opake.cloud.document"; 19 + 20 + #[cfg(test)] 21 + pub(crate) mod tests { 22 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 23 + 24 + use crate::client::{HttpResponse, Session, XrpcClient}; 25 + use crate::records::{ 26 + AtBytes, BlobRef, CidLink, DirectEncryption, Document, Encryption, EncryptionEnvelope, 27 + WrappedKey, 28 + }; 29 + use crate::test_utils::MockTransport; 30 + 31 + pub const TEST_DID: &str = "did:plc:test"; 32 + pub const TEST_URI: &str = "at://did:plc:test/app.opake.cloud.document/abc123"; 33 + 34 + pub fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 35 + let session = Session { 36 + did: TEST_DID.into(), 37 + handle: "test.handle".into(), 38 + access_jwt: "test-jwt".into(), 39 + refresh_jwt: "test-refresh".into(), 40 + }; 41 + XrpcClient::with_session(mock, "https://pds.test".into(), session) 42 + } 43 + 44 + pub fn dummy_document(name: &str, size: u64, tags: Vec<String>) -> Document { 45 + Document { 46 + mime_type: Some("text/plain".into()), 47 + size: Some(size), 48 + tags, 49 + visibility: Some("private".into()), 50 + ..Document::new( 51 + name.into(), 52 + BlobRef { 53 + blob_type: "blob".into(), 54 + reference: CidLink { 55 + cid: "bafytest".into(), 56 + }, 57 + mime_type: "application/octet-stream".into(), 58 + size, 59 + }, 60 + Encryption::Direct(DirectEncryption { 61 + envelope: EncryptionEnvelope { 62 + algo: "aes-256-gcm".into(), 63 + nonce: AtBytes { 64 + encoded: BASE64.encode([0u8; 12]), 65 + }, 66 + keys: vec![WrappedKey { 67 + did: TEST_DID.into(), 68 + ciphertext: AtBytes { 69 + encoded: BASE64.encode([0u8; 72]), 70 + }, 71 + algo: "x25519-hkdf-a256kw".into(), 72 + }], 73 + }, 74 + }), 75 + "2026-03-01T00:00:00Z".into(), 76 + ) 77 + } 78 + } 79 + 80 + pub fn list_records_response(docs: &[(&str, Document)], cursor: Option<&str>) -> HttpResponse { 81 + let records: Vec<serde_json::Value> = docs 82 + .iter() 83 + .map(|(rkey, doc)| { 84 + serde_json::json!({ 85 + "uri": format!("at://{}/app.opake.cloud.document/{}", TEST_DID, rkey), 86 + "cid": "bafyrecord", 87 + "value": doc, 88 + }) 89 + }) 90 + .collect(); 91 + 92 + let mut body = serde_json::json!({ "records": records }); 93 + if let Some(c) = cursor { 94 + body["cursor"] = serde_json::Value::String(c.into()); 95 + } 96 + 97 + HttpResponse { 98 + status: 200, 99 + body: serde_json::to_vec(&body).unwrap(), 100 + } 101 + } 102 + }
+318
crates/opake-core/src/documents/upload.rs
··· 1 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 2 + use log::debug; 3 + 4 + use crate::client::{Transport, XrpcClient}; 5 + use crate::crypto::{self, CryptoRng, RngCore}; 6 + use crate::error::Error; 7 + use crate::records::{AtBytes, DirectEncryption, Document, Encryption, EncryptionEnvelope}; 8 + 9 + use super::DOCUMENT_COLLECTION; 10 + 11 + /// Maximum blob size accepted by a standard PDS (50 MB). 12 + const MAX_BLOB_SIZE: usize = 50 * 1024 * 1024; 13 + 14 + /// Everything needed to encrypt and upload a document, minus the transport 15 + /// and RNG (which are passed separately). 16 + pub struct UploadParams<'a> { 17 + pub plaintext: &'a [u8], 18 + pub filename: &'a str, 19 + pub mime_type: &'a str, 20 + pub owner_did: &'a str, 21 + pub owner_pubkey: &'a [u8; 32], 22 + pub tags: Vec<String>, 23 + pub created_at: &'a str, 24 + } 25 + 26 + /// Encrypt plaintext, upload the ciphertext blob, wrap the content key to the 27 + /// owner's public key, and create the document record. Returns the AT-URI of 28 + /// the created record. 29 + /// 30 + /// The caller is responsible for reading the file from disk, detecting the MIME 31 + /// type, and extracting the filename — this function is platform-agnostic. 32 + pub async fn encrypt_and_upload( 33 + client: &XrpcClient<impl Transport>, 34 + params: &UploadParams<'_>, 35 + rng: &mut (impl CryptoRng + RngCore), 36 + ) -> Result<String, Error> { 37 + if params.plaintext.len() > MAX_BLOB_SIZE { 38 + return Err(Error::InvalidRecord(format!( 39 + "file is {} bytes — PDS blob limit is {} bytes (50 MB)", 40 + params.plaintext.len(), 41 + MAX_BLOB_SIZE, 42 + ))); 43 + } 44 + 45 + debug!( 46 + "encrypting {} ({} bytes, {})", 47 + params.filename, 48 + params.plaintext.len(), 49 + params.mime_type 50 + ); 51 + 52 + let content_key = crypto::generate_content_key(rng); 53 + let payload = crypto::encrypt_blob(&content_key, params.plaintext, rng)?; 54 + 55 + debug!( 56 + "uploading encrypted blob ({} bytes)", 57 + payload.ciphertext.len() 58 + ); 59 + 60 + let blob_ref = client 61 + .upload_blob(payload.ciphertext, "application/octet-stream") 62 + .await?; 63 + 64 + let wrapped_key = crypto::wrap_key(&content_key, params.owner_pubkey, params.owner_did, rng)?; 65 + 66 + let document = Document { 67 + mime_type: Some(params.mime_type.into()), 68 + size: Some(params.plaintext.len() as u64), 69 + tags: params.tags.clone(), 70 + visibility: Some("private".into()), 71 + ..Document::new( 72 + params.filename.into(), 73 + blob_ref, 74 + Encryption::Direct(DirectEncryption { 75 + envelope: EncryptionEnvelope { 76 + algo: "aes-256-gcm".into(), 77 + nonce: AtBytes { 78 + encoded: BASE64.encode(payload.nonce), 79 + }, 80 + keys: vec![wrapped_key], 81 + }, 82 + }), 83 + params.created_at.into(), 84 + ) 85 + }; 86 + 87 + let record_ref = client.create_record(DOCUMENT_COLLECTION, &document).await?; 88 + 89 + Ok(record_ref.uri) 90 + } 91 + 92 + #[cfg(test)] 93 + mod tests { 94 + use super::*; 95 + use crate::client::{HttpResponse, RequestBody}; 96 + use crate::crypto::OsRng; 97 + use crate::records::Document; 98 + use crate::test_utils::MockTransport; 99 + 100 + use super::super::tests::{mock_client, TEST_DID}; 101 + 102 + fn test_keypair() -> ([u8; 32], [u8; 32]) { 103 + let secret = crypto::X25519DalekStaticSecret::random_from_rng(OsRng); 104 + let public = crypto::X25519DalekPublicKey::from(&secret); 105 + (public.to_bytes(), secret.to_bytes()) 106 + } 107 + 108 + /// Fake uploadBlob response — the PDS returns a blob ref. 109 + fn upload_blob_response() -> HttpResponse { 110 + let body = serde_json::json!({ 111 + "blob": { 112 + "$type": "blob", 113 + "ref": { "$link": "bafyuploadedblob" }, 114 + "mimeType": "application/octet-stream", 115 + "size": 128, 116 + } 117 + }); 118 + HttpResponse { 119 + status: 200, 120 + body: serde_json::to_vec(&body).unwrap(), 121 + } 122 + } 123 + 124 + /// Fake createRecord response — the PDS returns a record ref. 125 + fn create_record_response() -> HttpResponse { 126 + let body = serde_json::json!({ 127 + "uri": format!("at://{}/app.opake.cloud.document/new123", TEST_DID), 128 + "cid": "bafynewrecord", 129 + }); 130 + HttpResponse { 131 + status: 200, 132 + body: serde_json::to_vec(&body).unwrap(), 133 + } 134 + } 135 + 136 + fn test_params<'a>( 137 + plaintext: &'a [u8], 138 + filename: &'a str, 139 + public_key: &'a [u8; 32], 140 + tags: Vec<String>, 141 + ) -> UploadParams<'a> { 142 + UploadParams { 143 + plaintext, 144 + filename, 145 + mime_type: "text/plain", 146 + owner_did: TEST_DID, 147 + owner_pubkey: public_key, 148 + tags, 149 + created_at: "2026-03-01T00:00:00Z", 150 + } 151 + } 152 + 153 + #[tokio::test] 154 + async fn happy_path() { 155 + let (public_key, _) = test_keypair(); 156 + let mock = MockTransport::new(); 157 + mock.enqueue(upload_blob_response()); 158 + mock.enqueue(create_record_response()); 159 + 160 + let client = mock_client(mock.clone()); 161 + let params = test_params( 162 + b"hello world", 163 + "hello.txt", 164 + &public_key, 165 + vec!["test".into()], 166 + ); 167 + let uri = encrypt_and_upload(&client, &params, &mut OsRng) 168 + .await 169 + .unwrap(); 170 + 171 + assert!(uri.contains("app.opake.cloud.document")); 172 + assert!(uri.contains(TEST_DID)); 173 + 174 + let requests = mock.requests(); 175 + assert_eq!(requests.len(), 2); 176 + assert!(requests[0].url.contains("uploadBlob")); 177 + assert!(requests[1].url.contains("createRecord")); 178 + 179 + // Verify the document record sent to createRecord 180 + match &requests[1].body { 181 + Some(RequestBody::Json(v)) => { 182 + assert_eq!(v["collection"], "app.opake.cloud.document"); 183 + let record = &v["record"]; 184 + assert_eq!(record["name"], "hello.txt"); 185 + assert_eq!(record["mimeType"], "text/plain"); 186 + assert_eq!(record["size"], 11); 187 + assert_eq!(record["tags"], serde_json::json!(["test"])); 188 + assert_eq!(record["visibility"], "private"); 189 + 190 + // Verify encryption envelope structure 191 + let enc = &record["encryption"]; 192 + assert_eq!(enc["envelope"]["algo"], "aes-256-gcm"); 193 + assert_eq!(enc["envelope"]["keys"].as_array().unwrap().len(), 1); 194 + assert_eq!(enc["envelope"]["keys"][0]["did"], TEST_DID); 195 + assert_eq!(enc["envelope"]["keys"][0]["algo"], "x25519-hkdf-a256kw"); 196 + } 197 + _ => panic!("expected JSON body on createRecord request"), 198 + } 199 + } 200 + 201 + #[tokio::test] 202 + async fn rejects_oversized_blob() { 203 + let (public_key, _) = test_keypair(); 204 + let mock = MockTransport::new(); 205 + let client = mock_client(mock); 206 + 207 + let oversized = vec![0u8; MAX_BLOB_SIZE + 1]; 208 + let params = test_params(&oversized, "big.bin", &public_key, vec![]); 209 + let err = encrypt_and_upload(&client, &params, &mut OsRng) 210 + .await 211 + .unwrap_err(); 212 + 213 + assert!(err.to_string().contains("50 MB"), "got: {err}"); 214 + } 215 + 216 + #[tokio::test] 217 + async fn empty_file_succeeds() { 218 + let (public_key, _) = test_keypair(); 219 + let mock = MockTransport::new(); 220 + mock.enqueue(upload_blob_response()); 221 + mock.enqueue(create_record_response()); 222 + 223 + let client = mock_client(mock); 224 + let params = test_params(b"", "empty.txt", &public_key, vec![]); 225 + let uri = encrypt_and_upload(&client, &params, &mut OsRng) 226 + .await 227 + .unwrap(); 228 + 229 + assert!(uri.contains("app.opake.cloud.document")); 230 + } 231 + 232 + #[tokio::test] 233 + async fn upload_blob_failure_propagates() { 234 + let (public_key, _) = test_keypair(); 235 + let mock = MockTransport::new(); 236 + mock.enqueue(HttpResponse { 237 + status: 500, 238 + body: br#"{"error":"InternalServerError","message":"blob storage down"}"#.to_vec(), 239 + }); 240 + 241 + let client = mock_client(mock); 242 + let params = test_params(b"data", "file.bin", &public_key, vec![]); 243 + let err = encrypt_and_upload(&client, &params, &mut OsRng) 244 + .await 245 + .unwrap_err(); 246 + 247 + assert!(matches!(err, Error::Xrpc { .. })); 248 + } 249 + 250 + #[tokio::test] 251 + async fn create_record_failure_propagates() { 252 + let (public_key, _) = test_keypair(); 253 + let mock = MockTransport::new(); 254 + mock.enqueue(upload_blob_response()); 255 + mock.enqueue(HttpResponse { 256 + status: 500, 257 + body: br#"{"error":"InternalServerError","message":"record write failed"}"#.to_vec(), 258 + }); 259 + 260 + let client = mock_client(mock); 261 + let params = test_params(b"data", "file.bin", &public_key, vec![]); 262 + let err = encrypt_and_upload(&client, &params, &mut OsRng) 263 + .await 264 + .unwrap_err(); 265 + 266 + assert!(matches!(err, Error::Xrpc { .. })); 267 + } 268 + 269 + #[tokio::test] 270 + async fn roundtrip_with_download() { 271 + let (public_key, private_key) = test_keypair(); 272 + let plaintext = b"roundtrip test data"; 273 + 274 + let mock = MockTransport::new(); 275 + mock.enqueue(upload_blob_response()); 276 + mock.enqueue(create_record_response()); 277 + 278 + let client = mock_client(mock.clone()); 279 + let params = test_params(plaintext, "roundtrip.txt", &public_key, vec![]); 280 + encrypt_and_upload(&client, &params, &mut OsRng) 281 + .await 282 + .unwrap(); 283 + 284 + // Extract the document record that was sent to createRecord 285 + let requests = mock.requests(); 286 + let create_body = match &requests[1].body { 287 + Some(RequestBody::Json(v)) => v.clone(), 288 + _ => panic!("expected JSON body"), 289 + }; 290 + let doc: Document = serde_json::from_value(create_body["record"].clone()).unwrap(); 291 + 292 + // Extract the ciphertext that was uploaded 293 + let ciphertext = match &requests[0].body { 294 + Some(RequestBody::Bytes { data, .. }) => data.clone(), 295 + _ => panic!("expected bytes body on uploadBlob"), 296 + }; 297 + 298 + // Decrypt using download's logic 299 + let envelope = match &doc.encryption { 300 + Encryption::Direct(d) => &d.envelope, 301 + _ => panic!("expected direct encryption"), 302 + }; 303 + 304 + let wrapped = &envelope.keys[0]; 305 + let content_key = crypto::unwrap_key(wrapped, &private_key).unwrap(); 306 + 307 + let nonce_bytes = BASE64.decode(&envelope.nonce.encoded).unwrap(); 308 + let nonce: [u8; 12] = nonce_bytes.try_into().unwrap(); 309 + 310 + let decrypted = crypto::decrypt_blob( 311 + &content_key, 312 + &crypto::EncryptedPayload { ciphertext, nonce }, 313 + ) 314 + .unwrap(); 315 + 316 + assert_eq!(decrypted, plaintext); 317 + } 318 + }
+1
crates/opake-core/src/lib.rs
··· 11 11 pub mod atproto; 12 12 pub mod client; 13 13 pub mod crypto; 14 + pub mod documents; 14 15 pub mod error; 15 16 pub mod records; 16 17