An encrypted personal cloud built on the AT Protocol.

Add lazy async document name resolution to directory tree

Replace bulk document metadata loading with a DocumentNameResolver
trait that resolves names on demand via individual getRecord calls.
All CLI commands now use the directory tree for path resolution —
bare names, paths, and AT-URIs all go through one code path. Removes
resolve_uri (bulk list+decrypt) and documents/resolve.rs (dead type
guard). Also includes encrypted metadata schema cleanup and updated
docs.

+596 -839
+11 -13
crates/opake-cli/src/commands/cat.rs
··· 8 8 use opake_core::documents; 9 9 10 10 use crate::commands::Execute; 11 + use crate::document_resolve; 11 12 use crate::identity; 12 13 use crate::keyring_store; 13 14 use crate::session::{self, CommandContext}; ··· 26 27 let private_key = id.private_key_bytes()?; 27 28 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 28 29 29 - // Resolve the reference to an AT-URI. 30 - let uri = if self.reference.starts_with("at://") { 31 - self.reference.clone() 32 - } else if self.reference.contains('/') { 33 - // Path — needs the directory tree. 34 - let mut tree = DirectoryTree::load(&mut client).await?; 35 - tree.decrypt_names(&ctx.did, &private_key); 36 - let resolved = tree.resolve(&mut client, &self.reference).await?; 37 - resolved.uri 38 - } else { 39 - // Bare name — lightweight document resolution. 40 - documents::resolve_uri(&mut client, &self.reference).await? 41 - }; 30 + let mut tree = DirectoryTree::load(&mut client).await?; 31 + tree.decrypt_names(&ctx.did, &private_key); 32 + let mut resolver = document_resolve::CliDocumentNameResolver::new( 33 + &mut client, 34 + &ctx.did, 35 + &private_key, 36 + &ctx.storage, 37 + ); 38 + let resolved = tree.resolve(&mut resolver, &self.reference).await?; 39 + let uri = resolved.uri; 42 40 43 41 // Peek at the document to check for keyring encryption. 44 42 let at_uri = atproto::parse_at_uri(&uri)?;
+8 -4
crates/opake-cli/src/commands/download.rs
··· 4 4 use anyhow::{Context, Result}; 5 5 use clap::Args; 6 6 use opake_core::atproto; 7 + use opake_core::directories::DirectoryTree; 7 8 use opake_core::documents; 8 9 9 10 use opake_core::client::Session; ··· 90 91 .as_deref() 91 92 .ok_or_else(|| anyhow::anyhow!("provide a document reference or --grant"))?; 92 93 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 93 - let uri = document_resolve::resolve_uri( 94 + 95 + let mut tree = DirectoryTree::load(&mut client).await?; 96 + tree.decrypt_names(&id.did, &private_key); 97 + let mut resolver = document_resolve::CliDocumentNameResolver::new( 94 98 &mut client, 95 - reference, 96 99 &id.did, 97 100 &private_key, 98 101 &ctx.storage, 99 - ) 100 - .await?; 102 + ); 103 + let resolved = tree.resolve(&mut resolver, reference).await?; 104 + let uri = resolved.uri; 101 105 102 106 // Peek at the document to check if it uses keyring encryption. 103 107 // If so, load the local group key before attempting decryption.
+85 -97
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 + use opake_core::documents::{self, DecryptedDocumentEntry}; 4 4 5 5 use opake_core::client::Session; 6 6 ··· 39 39 } 40 40 41 41 /// Keep only entries that have the given tag. 42 - fn filter_by_tag(entries: &mut Vec<DocumentEntry>, tag: &str) { 43 - entries.retain(|e| e.tags.iter().any(|t| t == tag)); 42 + fn filter_by_tag(docs: &mut Vec<DecryptedDocumentEntry>, tag: &str) { 43 + docs.retain(|d| d.metadata.tags.iter().any(|t| t == tag)); 44 44 } 45 45 46 46 /// One line per document: name and URI separated by a tab. 47 - fn format_short(entries: &[DocumentEntry]) -> String { 48 - entries 49 - .iter() 50 - .map(|e| format!("{}\t{}", e.name, e.uri)) 47 + fn format_short(docs: &[DecryptedDocumentEntry]) -> String { 48 + docs.iter() 49 + .map(|d| format!("{}\t{}", d.metadata.name, d.uri)) 51 50 .collect::<Vec<_>>() 52 51 .join("\n") 53 52 } 54 53 55 54 /// Multi-line per document: size, date, mime, name, tags, then URI on the next line. 56 - fn format_long(entries: &[DocumentEntry]) -> String { 57 - entries 58 - .iter() 59 - .map(|e| { 60 - let size = e.size.map(format_size).unwrap_or_else(|| "—".into()); 61 - let mime = e.mime_type.as_deref().unwrap_or("—"); 62 - let tags = if e.tags.is_empty() { 55 + fn format_long(docs: &[DecryptedDocumentEntry]) -> String { 56 + docs.iter() 57 + .map(|d| { 58 + let size = d 59 + .metadata 60 + .size 61 + .map(format_size) 62 + .unwrap_or_else(|| "—".into()); 63 + let mime = d.metadata.mime_type.as_deref().unwrap_or("—"); 64 + let tags = if d.metadata.tags.is_empty() { 63 65 String::new() 64 66 } else { 65 - format!(" [{}]", e.tags.join(", ")) 67 + format!(" [{}]", d.metadata.tags.join(", ")) 66 68 }; 67 69 format!( 68 70 "{:>10} {} {} {}{}\n {}", 69 - size, e.created_at, mime, e.name, tags, e.uri, 71 + size, d.created_at, mime, d.metadata.name, tags, d.uri, 70 72 ) 71 73 }) 72 74 .collect::<Vec<_>>() ··· 76 78 impl Execute for LsCommand { 77 79 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 78 80 let mut client = session::load_client(&ctx.storage, &ctx.did)?; 79 - let mut entries = documents::list_documents(&mut client).await?; 81 + let entries = documents::list_documents(&mut client).await?; 80 82 81 83 let id = identity::load_identity(&ctx.storage, &ctx.did)?; 82 84 let private_key = id.private_key_bytes()?; 83 - for entry in &mut entries { 84 - document_resolve::decrypt_entry_in_place(entry, &ctx.did, &private_key, &ctx.storage); 85 - } 85 + 86 + let mut docs = 87 + document_resolve::decrypt_entries(&entries, &ctx.did, &private_key, &ctx.storage); 86 88 87 89 if let Some(ref tag) = self.tag { 88 - filter_by_tag(&mut entries, tag); 90 + filter_by_tag(&mut docs, tag); 89 91 } 90 92 91 - if entries.is_empty() { 93 + if docs.is_empty() { 92 94 if let Some(ref tag) = self.tag { 93 95 println!("no documents matching tag {:?}", tag); 94 96 } else { ··· 98 100 } 99 101 100 102 if self.long { 101 - println!("{}", format_long(&entries)); 103 + println!("{}", format_long(&docs)); 102 104 } else { 103 - println!("{}", format_short(&entries)); 105 + println!("{}", format_short(&docs)); 104 106 } 105 107 106 - println!("\n{} document(s)", entries.len()); 108 + println!("\n{} document(s)", docs.len()); 107 109 108 110 Ok(session::refreshed_session(&client)) 109 111 } ··· 112 114 #[cfg(test)] 113 115 mod tests { 114 116 use super::*; 115 - use opake_core::records::{ 116 - AtBytes, DirectEncryption, EncryptedMetadata, Encryption, EncryptionEnvelope, WrappedKey, 117 - }; 117 + use opake_core::crypto::DocumentMetadata; 118 + use opake_core::records::Encryption; 118 119 119 - fn dummy_encrypted_metadata() -> EncryptedMetadata { 120 - EncryptedMetadata { 121 - ciphertext: AtBytes { 122 - encoded: "AAAA".into(), 123 - }, 124 - nonce: AtBytes { 125 - encoded: "AAAAAAAAAAAAAAAA".into(), 126 - }, 127 - } 128 - } 129 - 130 - fn dummy_encryption() -> Encryption { 131 - Encryption::Direct(DirectEncryption { 132 - envelope: EncryptionEnvelope { 133 - algo: "aes-256-gcm".into(), 134 - nonce: AtBytes { 135 - encoded: "AAAAAAAAAAAAAAAA".into(), 136 - }, 137 - keys: vec![WrappedKey { 138 - did: "did:plc:test".into(), 139 - ciphertext: AtBytes { 140 - encoded: "AAAA".into(), 141 - }, 142 - algo: "x25519-hkdf-a256kw".into(), 143 - }], 144 - }, 145 - }) 146 - } 147 - 148 - fn entry(name: &str, uri: &str, tags: Vec<String>) -> DocumentEntry { 149 - DocumentEntry { 120 + fn doc(name: &str, uri: &str, tags: Vec<String>) -> DecryptedDocumentEntry { 121 + DecryptedDocumentEntry { 150 122 uri: uri.into(), 151 - name: name.into(), 152 - size: Some(1024), 153 - mime_type: Some("text/plain".into()), 154 - tags, 155 123 created_at: "2026-03-01T00:00:00Z".into(), 156 - encrypted_metadata: dummy_encrypted_metadata(), 157 - encryption: dummy_encryption(), 124 + encryption: Encryption::Direct(opake_core::records::DirectEncryption { 125 + envelope: opake_core::records::EncryptionEnvelope { 126 + algo: "aes-256-gcm".into(), 127 + nonce: opake_core::records::AtBytes { 128 + encoded: "AAAAAAAAAAAAAAAA".into(), 129 + }, 130 + keys: vec![opake_core::records::WrappedKey { 131 + did: "did:plc:test".into(), 132 + ciphertext: opake_core::records::AtBytes { 133 + encoded: "AAAA".into(), 134 + }, 135 + algo: "x25519-hkdf-a256kw".into(), 136 + }], 137 + }, 138 + }), 139 + metadata: DocumentMetadata { 140 + name: name.into(), 141 + mime_type: Some("text/plain".into()), 142 + size: Some(1024), 143 + tags, 144 + description: None, 145 + }, 158 146 } 159 147 } 160 148 ··· 188 176 189 177 #[test] 190 178 fn filter_keeps_matching_tag() { 191 - let mut entries = vec![ 192 - entry("a.txt", "at://did/col/a", vec!["photos".into()]), 193 - entry("b.txt", "at://did/col/b", vec!["docs".into()]), 194 - entry( 179 + let mut docs = vec![ 180 + doc("a.txt", "at://did/col/a", vec!["photos".into()]), 181 + doc("b.txt", "at://did/col/b", vec!["docs".into()]), 182 + doc( 195 183 "c.txt", 196 184 "at://did/col/c", 197 185 vec!["photos".into(), "family".into()], 198 186 ), 199 187 ]; 200 - filter_by_tag(&mut entries, "photos"); 201 - assert_eq!(entries.len(), 2); 202 - assert_eq!(entries[0].name, "a.txt"); 203 - assert_eq!(entries[1].name, "c.txt"); 188 + filter_by_tag(&mut docs, "photos"); 189 + assert_eq!(docs.len(), 2); 190 + assert_eq!(docs[0].metadata.name, "a.txt"); 191 + assert_eq!(docs[1].metadata.name, "c.txt"); 204 192 } 205 193 206 194 #[test] 207 195 fn filter_removes_all_when_no_match() { 208 - let mut entries = vec![entry("a.txt", "at://did/col/a", vec!["docs".into()])]; 209 - filter_by_tag(&mut entries, "nonexistent"); 210 - assert!(entries.is_empty()); 196 + let mut docs = vec![doc("a.txt", "at://did/col/a", vec!["docs".into()])]; 197 + filter_by_tag(&mut docs, "nonexistent"); 198 + assert!(docs.is_empty()); 211 199 } 212 200 213 201 #[test] 214 202 fn filter_on_empty_list() { 215 - let mut entries: Vec<DocumentEntry> = vec![]; 216 - filter_by_tag(&mut entries, "anything"); 217 - assert!(entries.is_empty()); 203 + let mut docs: Vec<DecryptedDocumentEntry> = vec![]; 204 + filter_by_tag(&mut docs, "anything"); 205 + assert!(docs.is_empty()); 218 206 } 219 207 220 208 // -- format_short -- 221 209 222 210 #[test] 223 211 fn short_format_single_entry() { 224 - let entries = vec![entry("notes.txt", "at://did/col/abc", vec![])]; 225 - let output = format_short(&entries); 212 + let docs = vec![doc("notes.txt", "at://did/col/abc", vec![])]; 213 + let output = format_short(&docs); 226 214 assert_eq!(output, "notes.txt\tat://did/col/abc"); 227 215 } 228 216 229 217 #[test] 230 218 fn short_format_multiple_entries() { 231 - let entries = vec![ 232 - entry("a.txt", "at://did/col/a", vec![]), 233 - entry("b.txt", "at://did/col/b", vec![]), 219 + let docs = vec![ 220 + doc("a.txt", "at://did/col/a", vec![]), 221 + doc("b.txt", "at://did/col/b", vec![]), 234 222 ]; 235 - let output = format_short(&entries); 223 + let output = format_short(&docs); 236 224 assert_eq!(output, "a.txt\tat://did/col/a\nb.txt\tat://did/col/b"); 237 225 } 238 226 ··· 240 228 241 229 #[test] 242 230 fn long_format_includes_size_and_mime() { 243 - let entries = vec![entry("notes.txt", "at://did/col/abc", vec![])]; 244 - let output = format_long(&entries); 231 + let docs = vec![doc("notes.txt", "at://did/col/abc", vec![])]; 232 + let output = format_long(&docs); 245 233 assert!( 246 234 output.contains("1.0 KB"), 247 235 "should contain formatted size, got: {output}" ··· 253 241 254 242 #[test] 255 243 fn long_format_shows_tags() { 256 - let entries = vec![entry( 244 + let docs = vec![doc( 257 245 "photo.jpg", 258 246 "at://did/col/p", 259 247 vec!["vacation".into(), "beach".into()], 260 248 )]; 261 - let output = format_long(&entries); 249 + let output = format_long(&docs); 262 250 assert!(output.contains("[vacation, beach]"), "got: {output}"); 263 251 } 264 252 265 253 #[test] 266 254 fn long_format_no_tags_no_brackets() { 267 - let entries = vec![entry("doc.pdf", "at://did/col/d", vec![])]; 268 - let output = format_long(&entries); 255 + let docs = vec![doc("doc.pdf", "at://did/col/d", vec![])]; 256 + let output = format_long(&docs); 269 257 assert!( 270 258 !output.contains('['), 271 259 "should not have brackets when no tags" ··· 274 262 275 263 #[test] 276 264 fn long_format_missing_size() { 277 - let mut entries = vec![entry("unknown.bin", "at://did/col/u", vec![])]; 278 - entries[0].size = None; 279 - let output = format_long(&entries); 265 + let mut docs = vec![doc("unknown.bin", "at://did/col/u", vec![])]; 266 + docs[0].metadata.size = None; 267 + let output = format_long(&docs); 280 268 assert!( 281 269 output.contains('—'), 282 270 "should show dash for missing size, got: {output}" ··· 285 273 286 274 #[test] 287 275 fn long_format_missing_mime() { 288 - let mut entries = vec![entry("mystery", "at://did/col/m", vec![])]; 289 - entries[0].mime_type = None; 290 - let output = format_long(&entries); 276 + let mut docs = vec![doc("mystery", "at://did/col/m", vec![])]; 277 + docs[0].metadata.mime_type = None; 278 + let output = format_long(&docs); 291 279 assert!( 292 280 output.contains('—'), 293 281 "should show dash for missing mime, got: {output}"
+10 -6
crates/opake-cli/src/commands/metadata.rs
··· 2 2 use clap::{Args, Subcommand}; 3 3 use opake_core::atproto; 4 4 use opake_core::client::Session; 5 - use opake_core::crypto::{ContentKey, OsRng, X25519PrivateKey}; 5 + use opake_core::crypto::{ContentKey, OsRng}; 6 + use opake_core::directories::DirectoryTree; 6 7 use opake_core::metadata; 7 8 8 9 use crate::commands::Execute; ··· 211 212 } 212 213 } 213 214 214 - /// Resolve a document reference to an AT-URI. 215 + /// Resolve a document reference to an AT-URI via the directory tree. 215 216 async fn resolve( 216 217 client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 217 218 reference: &str, 218 219 did: &str, 219 - private_key: &X25519PrivateKey, 220 + private_key: &opake_core::crypto::X25519PrivateKey, 220 221 ctx: &CommandContext, 221 222 ) -> Result<String> { 222 - let uri = 223 - document_resolve::resolve_uri(client, reference, did, private_key, &ctx.storage).await?; 224 - Ok(uri) 223 + let mut tree = DirectoryTree::load(client).await?; 224 + tree.decrypt_names(did, private_key); 225 + let mut resolver = 226 + document_resolve::CliDocumentNameResolver::new(client, did, private_key, &ctx.storage); 227 + let resolved = tree.resolve(&mut resolver, reference).await?; 228 + Ok(resolved.uri) 225 229 } 226 230 227 231 /// Peek at a document's encryption type and load the group key if keyring-encrypted.
+10 -3
crates/opake-cli/src/commands/mkdir.rs
··· 7 7 use opake_core::error::Error; 8 8 9 9 use crate::commands::{encrypt_directory, Execute}; 10 + use crate::document_resolve; 10 11 use crate::identity; 11 12 use crate::session::{self, CommandContext}; 12 13 ··· 37 38 let mut tree = DirectoryTree::load(&mut client).await?; 38 39 tree.decrypt_names(&ctx.did, &private_key); 39 40 41 + let mut resolver = document_resolve::CliDocumentNameResolver::new( 42 + &mut client, 43 + &ctx.did, 44 + &private_key, 45 + &ctx.storage, 46 + ); 47 + 40 48 let (parent_uri, parent_label) = if let Some(dir_path) = &self.dir { 41 - let resolved = tree.resolve(&mut client, dir_path).await?; 49 + let resolved = tree.resolve(&mut resolver, dir_path).await?; 42 50 43 51 if resolved.kind != EntryKind::Directory { 44 52 anyhow::bail!("{dir_path:?} is not a directory"); ··· 49 57 }; 50 58 51 59 // Check for existing child directory with the same name. 52 - // Only NotFound means the name is free — AmbiguousName or Ok both mean it's taken. 53 60 let full_path = if parent_label == "/" { 54 61 self.name.clone() 55 62 } else { 56 63 format!("{}/{}", parent_label, self.name) 57 64 }; 58 - match tree.resolve(&mut client, &full_path).await { 65 + match tree.resolve(&mut resolver, &full_path).await { 59 66 Err(Error::NotFound(_)) => {} 60 67 Ok(_) | Err(Error::AmbiguousName { .. }) => { 61 68 anyhow::bail!(
+11 -2
crates/opake-cli/src/commands/move_cmd.rs
··· 5 5 use opake_core::directories::{check_cycle, move_entry, DirectoryTree, EntryKind}; 6 6 7 7 use crate::commands::Execute; 8 + use crate::document_resolve; 8 9 use crate::identity; 9 10 use crate::session::{self, CommandContext}; 10 11 ··· 27 28 28 29 let mut tree = DirectoryTree::load(&mut client).await?; 29 30 tree.decrypt_names(&ctx.did, &private_key); 30 - let source = tree.resolve(&mut client, &self.source).await?; 31 - let dest = tree.resolve(&mut client, &self.destination).await?; 31 + 32 + let mut resolver = document_resolve::CliDocumentNameResolver::new( 33 + &mut client, 34 + &ctx.did, 35 + &private_key, 36 + &ctx.storage, 37 + ); 38 + 39 + let source = tree.resolve(&mut resolver, &self.source).await?; 40 + let dest = tree.resolve(&mut resolver, &self.destination).await?; 32 41 33 42 if dest.kind != EntryKind::Directory { 34 43 anyhow::bail!("{:?} is not a directory", self.destination);
+25 -60
crates/opake-cli/src/commands/rm.rs
··· 3 3 use clap::Args; 4 4 use opake_core::client::Session; 5 5 use opake_core::directories::{self, DirectoryTree, EntryKind, ResolvedPath}; 6 - use opake_core::error::Error as CoreError; 7 6 use opake_core::{atproto, documents}; 8 7 9 8 use crate::commands::Execute; ··· 26 25 yes: bool, 27 26 } 28 27 29 - /// Determine whether a reference needs the full directory tree or can use 30 - /// the lightweight document-only resolver. 28 + /// Check if a reference is a document AT-URI that can skip tree loading. 31 29 /// 32 - /// Paths with `/` always need the tree. AT-URIs targeting directories need 33 - /// the tree. Bare names try document resolution first, falling back to the 34 - /// tree only when no document matches. 35 - enum Resolution { 36 - /// Resolved cheaply without building the full tree. 37 - Fast(ResolvedPath), 38 - /// Needs the full directory tree (path resolution, directory target, etc). 39 - NeedsTree, 40 - } 41 - 42 - async fn try_fast_resolve( 43 - client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 44 - reference: &str, 45 - did: &str, 46 - private_key: &opake_core::crypto::X25519PrivateKey, 47 - storage: &crate::config::FileStorage, 48 - ) -> Result<Resolution, CoreError> { 49 - // Paths always need the tree. 50 - if reference.contains('/') { 51 - return Ok(Resolution::NeedsTree); 30 + /// Document AT-URIs don't need the tree — no parent cleanup on fast-path 31 + /// deletion. Everything else (paths, bare names, directory URIs) goes 32 + /// through the tree with lazy document name resolution. 33 + fn try_fast_resolve(reference: &str) -> Option<ResolvedPath> { 34 + if !reference.starts_with("at://") { 35 + return None; 52 36 } 53 37 54 - // AT-URIs targeting directories need the tree for emptiness checks / recursion. 55 - if reference.starts_with("at://") { 56 - let at_uri = atproto::parse_at_uri(reference)?; 57 - if at_uri.collection == directories::DIRECTORY_COLLECTION { 58 - return Ok(Resolution::NeedsTree); 59 - } 60 - // Document AT-URI — no tree needed, no parent cleanup. 61 - return Ok(Resolution::Fast(ResolvedPath { 62 - uri: reference.to_owned(), 63 - kind: EntryKind::Document, 64 - name: at_uri.rkey.clone(), 65 - parent_uri: None, 66 - })); 38 + let at_uri = atproto::parse_at_uri(reference).ok()?; 39 + if at_uri.collection == directories::DIRECTORY_COLLECTION { 40 + return None; 67 41 } 68 42 69 - // Bare name — try document-only resolution with metadata decryption. 70 - match document_resolve::resolve_uri(client, reference, did, private_key, storage).await { 71 - Ok(uri) => Ok(Resolution::Fast(ResolvedPath { 72 - uri, 73 - kind: EntryKind::Document, 74 - name: reference.to_owned(), 75 - parent_uri: None, 76 - })), 77 - // No document match — might be a directory name. Need the tree. 78 - Err(CoreError::NotFound(_)) => Ok(Resolution::NeedsTree), 79 - Err(e) => Err(e), 80 - } 43 + Some(ResolvedPath { 44 + uri: reference.to_owned(), 45 + kind: EntryKind::Document, 46 + name: at_uri.rkey.clone(), 47 + parent_uri: None, 48 + }) 81 49 } 82 50 83 51 impl Execute for RmCommand { ··· 88 56 let id = identity::load_identity(&ctx.storage, &ctx.did)?; 89 57 let private_key = id.private_key_bytes()?; 90 58 91 - let resolution = try_fast_resolve( 92 - &mut client, 93 - &self.reference, 94 - &ctx.did, 95 - &private_key, 96 - &ctx.storage, 97 - ) 98 - .await?; 99 - 100 - // Fast path: document by bare name or AT-URI, no tree needed. 101 - if let Resolution::Fast(resolved) = resolution { 59 + // Fast path: document AT-URI — no tree needed, no parent cleanup. 60 + if let Some(resolved) = try_fast_resolve(&self.reference) { 102 61 if !self.yes { 103 62 eprint!("delete {}? [y/N] ", resolved.name); 104 63 let mut answer = String::new(); ··· 119 78 // Full tree path: paths, directories, recursive deletion. 120 79 let mut tree = DirectoryTree::load(&mut client).await?; 121 80 tree.decrypt_names(&ctx.did, &private_key); 122 - let resolved = tree.resolve(&mut client, &self.reference).await?; 81 + let mut resolver = document_resolve::CliDocumentNameResolver::new( 82 + &mut client, 83 + &ctx.did, 84 + &private_key, 85 + &ctx.storage, 86 + ); 87 + let resolved = tree.resolve(&mut resolver, &self.reference).await?; 123 88 124 89 if !self.yes { 125 90 let prompt = match resolved.kind {
+12 -1
crates/opake-cli/src/commands/share.rs
··· 3 3 use clap::Args; 4 4 use opake_core::client::Session; 5 5 use opake_core::crypto::OsRng; 6 + use opake_core::directories::DirectoryTree; 6 7 use opake_core::documents; 7 8 use opake_core::resolve; 8 9 use opake_core::sharing::{self, GrantParams}; 9 10 10 11 use crate::commands::Execute; 12 + use crate::document_resolve; 11 13 use crate::identity; 12 14 use crate::session::{self, CommandContext}; 13 15 use opake_core::client::ReqwestTransport; ··· 33 35 identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?; 34 36 let private_key = id.private_key_bytes()?; 35 37 36 - let uri = documents::resolve_uri(&mut client, &self.document).await?; 38 + let mut tree = DirectoryTree::load(&mut client).await?; 39 + tree.decrypt_names(&ctx.did, &private_key); 40 + let mut resolver = document_resolve::CliDocumentNameResolver::new( 41 + &mut client, 42 + &ctx.did, 43 + &private_key, 44 + &ctx.storage, 45 + ); 46 + let resolved = tree.resolve(&mut resolver, &self.document).await?; 47 + let uri = resolved.uri; 37 48 38 49 let content_key = 39 50 documents::fetch_content_key(&mut client, &id.did, &private_key, &uri).await?;
+5 -7
crates/opake-cli/src/commands/tree.rs
··· 26 26 27 27 tree.decrypt_names(&ctx.did, &private_key); 28 28 29 - let documents: HashMap<String, String> = entries 30 - .iter() 31 - .map(|e| { 32 - let name = 33 - document_resolve::decrypt_entry_name(e, &ctx.did, &private_key, &ctx.storage); 34 - (e.uri.clone(), name) 35 - }) 29 + let decrypted = 30 + document_resolve::decrypt_entries(&entries, &ctx.did, &private_key, &ctx.storage); 31 + let documents: HashMap<String, String> = decrypted 32 + .into_iter() 33 + .map(|d| (d.uri, d.metadata.name)) 36 34 .collect(); 37 35 38 36 println!("{}", tree.render(&documents));
+8 -3
crates/opake-cli/src/commands/upload.rs
··· 13 13 use opake_core::client::Session; 14 14 15 15 use crate::commands::{encrypt_directory, Execute}; 16 - use crate::identity; 17 - use crate::keyring_store; 18 16 use crate::session::{self, CommandContext}; 17 + use crate::{document_resolve, identity, keyring_store}; 19 18 20 19 #[derive(Args)] 21 20 /// Upload and encrypt a file ··· 101 100 let private_key = id.private_key_bytes()?; 102 101 let mut tree = DirectoryTree::load(&mut client).await?; 103 102 tree.decrypt_names(&ctx.did, &private_key); 104 - let resolved = tree.resolve(&mut client, dir_path).await?; 103 + let mut resolver = document_resolve::CliDocumentNameResolver::new( 104 + &mut client, 105 + &ctx.did, 106 + &private_key, 107 + &ctx.storage, 108 + ); 109 + let resolved = tree.resolve(&mut resolver, dir_path).await?; 105 110 106 111 if resolved.kind != EntryKind::Directory { 107 112 anyhow::bail!("{dir_path:?} is not a directory");
+116 -92
crates/opake-cli/src/document_resolve.rs
··· 3 3 // Provides shared helpers for all CLI commands that need to read 4 4 // document names from encrypted metadata: ls, tree, download, rm, mv. 5 5 6 + use std::collections::HashMap; 7 + 6 8 use log::warn; 9 + use opake_core::atproto; 7 10 use opake_core::client::{Transport, XrpcClient}; 8 11 use opake_core::crypto::{self, X25519PrivateKey}; 9 - use opake_core::documents::{self, DocumentEntry}; 12 + use opake_core::directories::DocumentNameResolver; 13 + use opake_core::documents::{DecryptedDocumentEntry, DocumentEntry}; 10 14 use opake_core::error::Error; 11 - use opake_core::records::Encryption; 15 + use opake_core::records::{Document, Encryption}; 12 16 13 17 use crate::config::FileStorage; 14 18 use crate::keyring_store; 15 19 16 - /// Resolve a user-provided reference to an AT-URI, decrypting metadata 17 - /// to match document names. 18 - /// 19 - /// If `reference` is already an `at://` URI, it's returned as-is. 20 - /// Otherwise, lists all documents, decrypts their metadata, and matches 21 - /// by name. 22 - pub async fn resolve_uri( 23 - client: &mut XrpcClient<impl Transport>, 24 - reference: &str, 20 + /// Decrypt all document entries, skipping any that fail decryption. 21 + pub fn decrypt_entries( 22 + entries: &[DocumentEntry], 25 23 did: &str, 26 24 private_key: &X25519PrivateKey, 27 25 storage: &FileStorage, 28 - ) -> Result<String, Error> { 29 - if reference.starts_with("at://") { 30 - return Ok(reference.to_string()); 31 - } 32 - 33 - let entries = documents::list_documents(client).await?; 34 - let matches: Vec<(&DocumentEntry, String)> = entries 26 + ) -> Vec<DecryptedDocumentEntry> { 27 + entries 35 28 .iter() 36 - .filter_map(|e| { 37 - let name = decrypt_entry_name(e, did, private_key, storage); 38 - if name == reference { 39 - Some((e, name)) 40 - } else { 29 + .filter_map(|e| match decrypt_entry(e, did, private_key, storage) { 30 + Ok(d) => Some(d), 31 + Err(e) => { 32 + warn!("{e}"); 41 33 None 42 34 } 43 35 }) 44 - .collect(); 45 - 46 - match matches.len() { 47 - 0 => Err(Error::NotFound(format!( 48 - "no document named {:?} — use `opake ls` to see your documents", 49 - reference 50 - ))), 51 - 1 => Ok(matches[0].0.uri.clone()), 52 - n => { 53 - let uris: Vec<String> = matches.iter().map(|(e, _)| e.uri.clone()).collect(); 54 - Err(Error::AmbiguousName { 55 - name: reference.to_string(), 56 - count: n, 57 - uris, 58 - }) 59 - } 60 - } 36 + .collect() 61 37 } 62 38 63 - /// Decrypt the name from a document entry's encrypted metadata. 64 - /// Falls back to the plaintext name field for old records or on failure. 65 - pub fn decrypt_entry_name( 39 + /// Decrypt a single document entry into a `DecryptedDocumentEntry`. 40 + pub fn decrypt_entry( 66 41 entry: &DocumentEntry, 67 42 did: &str, 68 43 private_key: &X25519PrivateKey, 69 44 storage: &FileStorage, 70 - ) -> String { 71 - let content_key = match unwrap_entry_content_key(entry, did, private_key, storage) { 72 - Ok(key) => key, 73 - Err(e) => { 74 - warn!("could not unwrap key for {}: {e}", entry.uri); 75 - return entry.name.clone(); 76 - } 77 - }; 45 + ) -> anyhow::Result<DecryptedDocumentEntry> { 46 + let content_key = unwrap_entry_content_key(entry, did, private_key, storage)?; 78 47 79 - match crypto::decrypt_metadata::<crypto::DocumentMetadata>( 48 + let metadata = crypto::decrypt_metadata::<crypto::DocumentMetadata>( 80 49 &content_key, 81 50 &entry.encrypted_metadata, 82 - ) { 83 - Ok(metadata) => metadata.name, 84 - Err(e) => { 85 - warn!("metadata decryption failed for {}: {e}", entry.uri); 86 - entry.name.clone() 87 - } 88 - } 89 - } 90 - 91 - /// Decrypt encrypted metadata on a document entry in place, replacing 92 - /// the dummy plaintext fields with real values. 93 - /// 94 - /// Silently falls back to plaintext fields if decryption fails. 95 - pub fn decrypt_entry_in_place( 96 - entry: &mut DocumentEntry, 97 - did: &str, 98 - private_key: &X25519PrivateKey, 99 - storage: &FileStorage, 100 - ) { 101 - let content_key = match unwrap_entry_content_key(entry, did, private_key, storage) { 102 - Ok(key) => key, 103 - Err(e) => { 104 - warn!("could not decrypt metadata for {}: {e}", entry.uri); 105 - return; 106 - } 107 - }; 51 + )?; 108 52 109 - match crypto::decrypt_metadata::<crypto::DocumentMetadata>( 110 - &content_key, 111 - &entry.encrypted_metadata, 112 - ) { 113 - Ok(metadata) => { 114 - entry.name = metadata.name; 115 - entry.mime_type = metadata.mime_type; 116 - entry.size = metadata.size; 117 - entry.tags = metadata.tags; 118 - } 119 - Err(e) => { 120 - warn!("metadata decryption failed for {}: {e}", entry.uri); 121 - } 122 - } 53 + Ok(DecryptedDocumentEntry { 54 + uri: entry.uri.clone(), 55 + created_at: entry.created_at.clone(), 56 + encryption: entry.encryption.clone(), 57 + metadata, 58 + }) 123 59 } 124 60 125 61 /// Unwrap the content key from an entry's encryption envelope. ··· 155 91 } 156 92 } 157 93 } 94 + 95 + /// Lazy document name resolver for the CLI. 96 + /// 97 + /// Fetches and decrypts individual document records on demand, caching 98 + /// results so repeated lookups for the same URI don't hit the PDS. 99 + pub struct CliDocumentNameResolver<'a, T: Transport> { 100 + client: &'a mut XrpcClient<T>, 101 + did: &'a str, 102 + private_key: &'a X25519PrivateKey, 103 + storage: &'a FileStorage, 104 + cache: HashMap<String, String>, 105 + } 106 + 107 + impl<'a, T: Transport> CliDocumentNameResolver<'a, T> { 108 + pub fn new( 109 + client: &'a mut XrpcClient<T>, 110 + did: &'a str, 111 + private_key: &'a X25519PrivateKey, 112 + storage: &'a FileStorage, 113 + ) -> Self { 114 + Self { 115 + client, 116 + did, 117 + private_key, 118 + storage, 119 + cache: HashMap::new(), 120 + } 121 + } 122 + } 123 + 124 + impl<T: Transport> DocumentNameResolver for CliDocumentNameResolver<'_, T> { 125 + async fn resolve_name(&mut self, uri: &str) -> Result<Option<String>, Error> { 126 + if let Some(name) = self.cache.get(uri) { 127 + return Ok(Some(name.clone())); 128 + } 129 + 130 + let at_uri = atproto::parse_at_uri(uri)?; 131 + let record = match self 132 + .client 133 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 134 + .await 135 + { 136 + Ok(r) => r, 137 + Err(Error::Xrpc { status: 404, .. }) => return Ok(None), 138 + Err(e) => return Err(e), 139 + }; 140 + 141 + let doc: Document = serde_json::from_value(record.value) 142 + .map_err(|e| Error::InvalidRecord(e.to_string()))?; 143 + 144 + let content_key = match &doc.encryption { 145 + Encryption::Direct(direct) => { 146 + let wrapped = direct.envelope.keys.iter().find(|k| k.did == self.did); 147 + match wrapped { 148 + Some(w) => crypto::unwrap_key(w, self.private_key) 149 + .map_err(|e| Error::KeyWrap(e.to_string()))?, 150 + None => return Ok(None), 151 + } 152 + } 153 + Encryption::Keyring(kr_enc) => { 154 + let kr_uri = atproto::parse_at_uri(&kr_enc.keyring_ref.keyring)?; 155 + let group_key = keyring_store::load_group_key( 156 + self.storage, 157 + self.did, 158 + &kr_uri.rkey, 159 + kr_enc.keyring_ref.rotation, 160 + ) 161 + .map_err(|e| Error::KeyWrap(e.to_string()))?; 162 + let wrapped_bytes = kr_enc 163 + .keyring_ref 164 + .wrapped_content_key 165 + .decode() 166 + .map_err(|e| Error::Decryption(e.to_string()))?; 167 + crypto::unwrap_content_key_from_keyring(&wrapped_bytes, &group_key) 168 + .map_err(|e| Error::KeyWrap(e.to_string()))? 169 + } 170 + }; 171 + 172 + let metadata = crypto::decrypt_metadata::<crypto::DocumentMetadata>( 173 + &content_key, 174 + &doc.encrypted_metadata, 175 + ) 176 + .map_err(|e| Error::Decryption(e.to_string()))?; 177 + 178 + self.cache.insert(uri.to_owned(), metadata.name.clone()); 179 + Ok(Some(metadata.name)) 180 + } 181 + }
+1 -1
crates/opake-core/src/directories/mod.rs
··· 20 20 pub use list::{list_directories, DirectoryEntry}; 21 21 pub use move_entry::{check_cycle, move_entry, MoveResult}; 22 22 pub use remove::{remove, RemoveResult}; 23 - pub use tree::{DirectoryTree, EntryKind, ResolvedPath}; 23 + pub use tree::{DirectoryTree, DocumentNameResolver, EntryKind, ResolvedPath}; 24 24 25 25 pub const DIRECTORY_COLLECTION: &str = "app.opake.directory"; 26 26 pub const ROOT_DIRECTORY_RKEY: &str = "self";
+44 -31
crates/opake-core/src/directories/remove_tests.rs
··· 1 + use std::collections::HashMap; 2 + 1 3 use super::*; 2 4 use crate::client::HttpResponse; 3 5 use crate::test_utils::MockTransport; ··· 7 9 put_record_response, test_keypair, TEST_DID, 8 10 }; 9 11 12 + use super::super::tree::DocumentNameResolver; 13 + 14 + /// Test resolver that returns names from a pre-built map. 15 + struct MockNameResolver { 16 + names: HashMap<String, String>, 17 + } 18 + 19 + impl MockNameResolver { 20 + fn new(pairs: &[(&str, &str)]) -> Self { 21 + Self { 22 + names: pairs 23 + .iter() 24 + .map(|(uri, name)| (uri.to_string(), name.to_string())) 25 + .collect(), 26 + } 27 + } 28 + } 29 + 30 + impl DocumentNameResolver for MockNameResolver { 31 + async fn resolve_name(&mut self, uri: &str) -> Result<Option<String>, crate::error::Error> { 32 + Ok(self.names.get(uri).cloned()) 33 + } 34 + } 35 + 10 36 const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 11 37 const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.directory/photos"; 12 38 const DIR_VACATION_URI: &str = "at://did:plc:test/app.opake.directory/vacation"; ··· 22 48 } 23 49 } 24 50 25 - fn doc_record_response(uri: &str, name: &str) -> HttpResponse { 26 - use crate::documents::tests::dummy_document; 27 - let doc = dummy_document(name, 100, vec![]); 28 - HttpResponse { 29 - status: 200, 30 - headers: vec![], 31 - body: serde_json::to_vec(&serde_json::json!({ 32 - "uri": uri, 33 - "cid": "bafydocument", 34 - "value": doc, 35 - })) 36 - .unwrap(), 37 - } 38 - } 39 - 40 51 /// Load a simple tree: / → [Photos → [beach.jpg], notes.txt] 41 52 async fn setup_simple( 42 53 mock: &MockTransport, ··· 104 115 let mock = MockTransport::new(); 105 116 let (mut client, tree) = setup_simple(&mock).await; 106 117 107 - // resolve "Photos/beach.jpg": getRecord for beach.jpg 108 - mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 109 - let resolved = tree.resolve(&mut client, "Photos/beach.jpg").await.unwrap(); 118 + let mut resolver = 119 + MockNameResolver::new(&[(DOC_BEACH_URI, "beach.jpg"), (DOC_NOTES_URI, "notes.txt")]); 120 + let resolved = tree 121 + .resolve(&mut resolver, "Photos/beach.jpg") 122 + .await 123 + .unwrap(); 110 124 111 125 // delete_record for the document 112 126 mock.enqueue(delete_ok()); ··· 172 186 let (_, private_key) = test_keypair(); 173 187 tree.decrypt_names(TEST_DID, &private_key); 174 188 175 - let resolved = tree.resolve(&mut client, "Empty").await.unwrap(); 189 + let mut resolver = MockNameResolver::new(&[]); 190 + let resolved = tree.resolve(&mut resolver, "Empty").await.unwrap(); 176 191 177 192 // delete_record for the directory 178 193 mock.enqueue(delete_ok()); ··· 195 210 let mock = MockTransport::new(); 196 211 let (mut client, tree) = setup_simple(&mock).await; 197 212 198 - // resolve "Photos": directory, found in memory. But find_child_any 199 - // also scans document children for ambiguity. 200 - mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 201 - let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 213 + let mut resolver = MockNameResolver::new(&[(DOC_NOTES_URI, "notes.txt")]); 214 + let resolved = tree.resolve(&mut resolver, "Photos").await.unwrap(); 202 215 203 216 let err = remove(&mut client, &tree, &resolved, false, "2026-03-01T12:00:00Z") 204 217 .await ··· 216 229 let mock = MockTransport::new(); 217 230 let (mut client, tree) = setup_simple(&mock).await; 218 231 219 - // resolve "Photos" 220 - mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 221 - let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 232 + let mut resolver = MockNameResolver::new(&[(DOC_NOTES_URI, "notes.txt")]); 233 + let resolved = tree.resolve(&mut resolver, "Photos").await.unwrap(); 222 234 223 235 // delete beach.jpg (descendant document) 224 236 mock.enqueue(delete_ok()); ··· 242 254 let mock = MockTransport::new(); 243 255 let (mut client, tree) = setup_nested(&mock).await; 244 256 245 - // resolve "Photos": directory, found in memory. 246 - // find_child_any scans root's document children — but root has none. 247 - let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 257 + let mut resolver = MockNameResolver::new(&[]); 258 + let resolved = tree.resolve(&mut resolver, "Photos").await.unwrap(); 248 259 249 260 // Post-order: sunset.jpg, Vacation, beach.jpg, then Photos itself 250 261 mock.enqueue(delete_ok()); // sunset.jpg ··· 271 282 let mock = MockTransport::new(); 272 283 let (mut client, tree) = setup_simple(&mock).await; 273 284 274 - let resolved = tree.resolve_at_uri(&mut client, ROOT_URI).await.unwrap(); 285 + let mut resolver = MockNameResolver::new(&[]); 286 + let resolved = tree.resolve(&mut resolver, ROOT_URI).await.unwrap(); 275 287 276 288 let err = remove(&mut client, &tree, &resolved, false, "2026-03-01T12:00:00Z") 277 289 .await ··· 286 298 let mock = MockTransport::new(); 287 299 let (mut client, tree) = setup_simple(&mock).await; 288 300 289 - let resolved = tree.resolve_at_uri(&mut client, ROOT_URI).await.unwrap(); 301 + let mut resolver = MockNameResolver::new(&[]); 302 + let resolved = tree.resolve(&mut resolver, ROOT_URI).await.unwrap(); 290 303 291 304 // Post-order: beach.jpg (doc), Photos (dir), notes.txt (doc), then root itself 292 305 mock.enqueue(delete_ok()); // beach.jpg
+68 -103
crates/opake-core/src/directories/tree.rs
··· 1 1 // In-memory snapshot of the directory hierarchy for path resolution. 2 2 // 3 - // Loads only directory records (one paginated API call). Document names 4 - // are resolved on demand via individual getRecord calls against the 5 - // entries of the relevant directory. This avoids fetching the entire 6 - // document collection for every path-based operation. 7 - // 8 - // Designed for reuse across rm, mv, and any future command that needs 9 - // to resolve user-facing paths to AT-URIs. 3 + // Loads directory records in one paginated API call. Document names are 4 + // resolved lazily during path resolution via an async callback trait, 5 + // so only documents in the target directory need to be fetched. 10 6 11 7 use std::collections::HashMap; 12 8 ··· 17 13 use crate::crypto::{self, DirectoryMetadata, X25519PrivateKey}; 18 14 use crate::documents::DOCUMENT_COLLECTION; 19 15 use crate::error::Error; 20 - use crate::records::{self, Directory, Document, EncryptedMetadata, Encryption}; 16 + use crate::records::{Directory, Document, EncryptedMetadata, Encryption}; 21 17 22 18 use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_NAME, ROOT_DIRECTORY_RKEY}; 19 + 20 + /// Resolves a document AT-URI to its decrypted name on demand. 21 + /// 22 + /// Called lazily during tree path resolution — only for document 23 + /// children of directories actually being searched. Implementations 24 + /// should cache results to avoid repeated PDS fetches. 25 + #[allow(async_fn_in_trait)] // no Send bound needed — used via generics, not dyn; WASM-safe 26 + pub trait DocumentNameResolver { 27 + async fn resolve_name(&mut self, uri: &str) -> Result<Option<String>, Error>; 28 + } 23 29 24 30 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 25 31 pub enum EntryKind { ··· 64 70 } 65 71 } 66 72 67 - /// Fetch a single document record and return its name. 68 - /// 69 - /// Returns None for 404s, unparseable records, and future schema versions 70 - /// (same tolerance as list_collection). 71 - async fn fetch_document_name( 72 - client: &mut XrpcClient<impl Transport>, 73 - uri: &str, 74 - ) -> Result<Option<String>, Error> { 75 - let at_uri = atproto::parse_at_uri(uri)?; 76 - let entry = match client 77 - .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 78 - .await 79 - { 80 - Ok(e) => e, 81 - Err(Error::NotFound(_)) => return Ok(None), 82 - Err(e) => return Err(e), 83 - }; 84 - 85 - let doc: Document = match serde_json::from_value(entry.value) { 86 - Ok(d) => d, 87 - Err(_) => return Ok(None), 88 - }; 89 - 90 - if records::check_version(doc.opake_version).is_err() { 91 - return Ok(None); 92 - } 93 - 94 - Ok(Some(doc.name)) 95 - } 96 - 97 73 impl DirectoryTree { 98 74 /// Load the directory hierarchy from the PDS. 99 75 /// 100 76 /// Makes one paginated API call (all directories). Documents are NOT 101 - /// loaded — they're fetched on demand during resolution. The root is 102 - /// detected from the listing by its rkey ("self"). 77 + /// loaded — callers provide document names separately via `resolve()`. 78 + /// The root is detected from the listing by its rkey ("self"). 103 79 pub async fn load(client: &mut XrpcClient<impl Transport>) -> Result<Self, Error> { 104 80 let dir_entries: Vec<(String, DirectoryInfo)> = 105 81 list_collection(client, DIRECTORY_COLLECTION, |uri, dir: Directory| { ··· 185 161 186 162 /// Resolve a user-provided reference to an AT-URI with metadata. 187 163 /// 164 + /// Document names are resolved lazily via the `resolver` callback — 165 + /// only documents in the target directory are fetched and decrypted. 166 + /// 188 167 /// Accepts three forms: 189 - /// - `at://` URI — directories resolved from memory, documents via getRecord 168 + /// - `at://` URI — directories resolved from memory, documents via resolver 190 169 /// - Path with `/` — walked segment by segment from root 191 - /// - Bare name — searched in root's direct children (directories in memory, 192 - /// documents via getRecord). Without a root, only directories are searched. 170 + /// - Bare name — searched in root's direct children 193 171 pub async fn resolve( 194 172 &self, 195 - client: &mut XrpcClient<impl Transport>, 173 + resolver: &mut impl DocumentNameResolver, 196 174 reference: &str, 197 175 ) -> Result<ResolvedPath, Error> { 198 176 if reference.starts_with("at://") { 199 - return self.resolve_at_uri(client, reference).await; 177 + // Directories are in memory. 178 + if let Some(info) = self.directories.get(reference) { 179 + return Ok(ResolvedPath { 180 + uri: reference.to_owned(), 181 + kind: EntryKind::Directory, 182 + name: info.name.clone(), 183 + parent_uri: self.find_parent(reference), 184 + }); 185 + } 186 + 187 + // Document URIs — use the rkey as the display name. The caller 188 + // already has the URI; no PDS fetch needed. 189 + let at_uri = atproto::parse_at_uri(reference)?; 190 + if at_uri.collection == DOCUMENT_COLLECTION { 191 + return Ok(ResolvedPath { 192 + uri: reference.to_owned(), 193 + kind: EntryKind::Document, 194 + name: at_uri.rkey.clone(), 195 + parent_uri: self.find_parent(reference), 196 + }); 197 + } 198 + 199 + return Err(Error::NotFound(format!("URI not found: {reference}"))); 200 200 } 201 201 202 202 // "/" refers to the root directory. ··· 213 213 } 214 214 215 215 if reference.contains('/') { 216 - return self.resolve_path(client, reference).await; 216 + return self.resolve_path(resolver, reference).await; 217 217 } 218 218 219 - self.resolve_bare_name(client, reference).await 219 + self.resolve_bare_name(resolver, reference).await 220 220 } 221 221 222 - /// Load the full directory hierarchy including document names. 222 + /// Load the full directory hierarchy including document URIs. 223 223 /// 224 224 /// Makes two paginated API calls: one for all directories, one for all 225 - /// documents. Returns a tree that can render without additional API calls. 225 + /// documents. Returns a tree and a set of document URIs. Callers must 226 + /// build the name map separately by decrypting metadata. 226 227 pub async fn load_full( 227 228 client: &mut XrpcClient<impl Transport>, 228 - ) -> Result<(Self, HashMap<String, String>), Error> { 229 + ) -> Result<(Self, Vec<String>), Error> { 229 230 let tree = Self::load(client).await?; 230 231 231 - let doc_entries: Vec<(String, String)> = 232 - list_collection(client, DOCUMENT_COLLECTION, |uri, doc: Document| { 233 - (uri.to_owned(), doc.name) 232 + let doc_uris: Vec<String> = 233 + list_collection(client, DOCUMENT_COLLECTION, |uri, _doc: Document| { 234 + uri.to_owned() 234 235 }) 235 236 .await?; 236 237 237 - let documents: HashMap<String, String> = doc_entries.into_iter().collect(); 238 + debug!("loaded {} document URIs for full tree", doc_uris.len()); 238 239 239 - debug!("loaded {} documents for full tree", documents.len()); 240 - 241 - Ok((tree, documents)) 240 + Ok((tree, doc_uris)) 242 241 } 243 242 244 243 /// Build a tree-formatted string of the entire hierarchy. 245 244 /// 246 - /// Requires the document name map from `load_full`. Entries within each 247 - /// directory are sorted: directories first (alphabetical), then documents 245 + /// Requires a URI → decrypted name map. Entries within each directory 246 + /// are sorted: directories first (alphabetical), then documents 248 247 /// (alphabetical). 249 248 pub fn render(&self, documents: &HashMap<String, String>) -> String { 250 249 let mut output = String::from(ROOT_DIRECTORY_NAME); ··· 338 337 } 339 338 340 339 /// Count descendant documents and directories under a directory URI. 341 - /// 342 - /// Infers entry kind from the collection segment in each child URI. 343 - /// No API calls — works entirely from the loaded directory data. 344 340 pub fn count_descendants(&self, uri: &str) -> (usize, usize) { 345 341 let mut documents = 0usize; 346 342 let mut directories = 0usize; ··· 368 364 369 365 /// Collect all descendant URIs in post-order (children before parents) 370 366 /// for correct deletion ordering. 371 - /// 372 - /// No API calls — kind is inferred from the URI collection segment. 373 367 pub fn collect_descendants(&self, uri: &str) -> Vec<(String, EntryKind)> { 374 368 let mut result = Vec::new(); 375 369 self.collect_descendants_recursive(uri, &mut result); ··· 395 389 } 396 390 } 397 391 398 - pub async fn resolve_at_uri( 399 - &self, 400 - client: &mut XrpcClient<impl Transport>, 401 - uri: &str, 402 - ) -> Result<ResolvedPath, Error> { 403 - // Directories are in memory. 404 - if let Some(info) = self.directories.get(uri) { 405 - return Ok(ResolvedPath { 406 - uri: uri.to_owned(), 407 - kind: EntryKind::Directory, 408 - name: info.name.clone(), 409 - parent_uri: self.find_parent(uri), 410 - }); 411 - } 412 - 413 - // Documents need a getRecord for the name. 414 - if let Some(name) = fetch_document_name(client, uri).await? { 415 - return Ok(ResolvedPath { 416 - uri: uri.to_owned(), 417 - kind: EntryKind::Document, 418 - name, 419 - parent_uri: self.find_parent(uri), 420 - }); 421 - } 422 - 423 - Err(Error::NotFound(format!("URI not found: {uri}"))) 424 - } 425 - 426 392 async fn resolve_path( 427 393 &self, 428 - client: &mut XrpcClient<impl Transport>, 394 + resolver: &mut impl DocumentNameResolver, 429 395 path: &str, 430 396 ) -> Result<ResolvedPath, Error> { 431 397 let root_uri = self ··· 447 413 448 414 // Last segment can be either a document or a directory. 449 415 let last = segments[segments.len() - 1]; 450 - self.find_child_any(client, &current_uri, last).await 416 + self.find_child_any(resolver, &current_uri, last).await 451 417 } 452 418 453 419 async fn resolve_bare_name( 454 420 &self, 455 - client: &mut XrpcClient<impl Transport>, 421 + resolver: &mut impl DocumentNameResolver, 456 422 name: &str, 457 423 ) -> Result<ResolvedPath, Error> { 458 424 match &self.root_uri { 459 - Some(root_uri) => self.find_child_any(client, root_uri, name).await, 425 + Some(root_uri) => self.find_child_any(resolver, root_uri, name).await, 460 426 None => { 461 - // No root — search directories only. Documents should be 462 - // resolved via documents::resolve_uri before reaching the tree. 427 + // No root — search directories only. 463 428 let mut matches = Vec::new(); 464 429 465 430 for (uri, info) in &self.directories { ··· 514 479 515 480 /// Find a child by name in a directory. 516 481 /// 517 - /// Checks directory children in memory first, then fetches document 518 - /// children individually via getRecord. 482 + /// Checks directory children in memory, then resolves document 483 + /// children lazily via the resolver callback. 519 484 async fn find_child_any( 520 485 &self, 521 - client: &mut XrpcClient<impl Transport>, 486 + resolver: &mut impl DocumentNameResolver, 522 487 parent_uri: &str, 523 488 name: &str, 524 489 ) -> Result<ResolvedPath, Error> { ··· 543 508 } 544 509 } 545 510 546 - // Document children: fetched individually. 511 + // Document children: resolved lazily via the callback. 547 512 for entry_uri in &parent.entries { 548 513 if entry_kind_from_uri(entry_uri) != Some(EntryKind::Document) { 549 514 continue; 550 515 } 551 516 552 - if let Some(doc_name) = fetch_document_name(client, entry_uri).await? { 517 + if let Some(doc_name) = resolver.resolve_name(entry_uri).await? { 553 518 if doc_name == name { 554 519 matches.push(ResolvedPath { 555 520 uri: entry_uri.clone(),
+61 -82
crates/opake-core/src/directories/tree_tests.rs
··· 1 + use std::collections::HashMap; 2 + 1 3 use super::*; 2 4 use crate::client::HttpResponse; 3 5 use crate::test_utils::MockTransport; ··· 13 15 const DOC_NOTES_URI: &str = "at://did:plc:test/app.opake.document/notes"; 14 16 const DOC_SUNSET_URI: &str = "at://did:plc:test/app.opake.document/sunset"; 15 17 16 - /// getRecord response for a document — minimal but parseable. 17 - fn doc_record_response(uri: &str, name: &str) -> HttpResponse { 18 - use crate::documents::tests::dummy_document; 19 - let doc = dummy_document(name, 100, vec![]); 20 - HttpResponse { 21 - status: 200, 22 - headers: vec![], 23 - body: serde_json::to_vec(&serde_json::json!({ 24 - "uri": uri, 25 - "cid": "bafydocument", 26 - "value": doc, 27 - })) 28 - .unwrap(), 18 + /// Test resolver that returns names from a pre-built map. 19 + struct MockNameResolver { 20 + names: HashMap<String, String>, 21 + } 22 + 23 + impl MockNameResolver { 24 + fn new(pairs: &[(&str, &str)]) -> Self { 25 + Self { 26 + names: pairs 27 + .iter() 28 + .map(|(uri, name)| (uri.to_string(), name.to_string())) 29 + .collect(), 30 + } 31 + } 32 + } 33 + 34 + impl DocumentNameResolver for MockNameResolver { 35 + async fn resolve_name(&mut self, uri: &str) -> Result<Option<String>, crate::error::Error> { 36 + Ok(self.names.get(uri).cloned()) 29 37 } 30 38 } 31 39 32 40 /// Load a simple tree: / → [Photos → [beach.jpg], notes.txt] 33 - /// 34 - /// Only enqueues the directory listing. Document getRecord calls are 35 - /// enqueued by individual tests as needed for resolve. 36 41 async fn load_simple_tree(mock: &MockTransport) -> DirectoryTree { 37 42 mock.enqueue(list_records_response( 38 43 &[ ··· 133 138 async fn resolve_at_uri_directory() { 134 139 let mock = MockTransport::new(); 135 140 let tree = load_simple_tree(&mock).await; 141 + let mut resolver = MockNameResolver::new(&[]); 136 142 137 - let mut client = mock_client(mock); 138 - let resolved = tree.resolve(&mut client, DIR_PHOTOS_URI).await.unwrap(); 143 + let resolved = tree.resolve(&mut resolver, DIR_PHOTOS_URI).await.unwrap(); 139 144 assert_eq!(resolved.kind, EntryKind::Directory); 140 145 assert_eq!(resolved.name, "Photos"); 141 146 assert_eq!(resolved.parent_uri.as_deref(), Some(ROOT_URI)); ··· 145 150 async fn resolve_at_uri_document() { 146 151 let mock = MockTransport::new(); 147 152 let tree = load_simple_tree(&mock).await; 153 + let mut resolver = MockNameResolver::new(&[]); 148 154 149 - // getRecord for the document to fetch its name 150 - mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 151 - 152 - let mut client = mock_client(mock); 153 - let resolved = tree.resolve(&mut client, DOC_BEACH_URI).await.unwrap(); 155 + // AT-URI resolution uses rkey as name — no resolver call. 156 + let resolved = tree.resolve(&mut resolver, DOC_BEACH_URI).await.unwrap(); 154 157 assert_eq!(resolved.uri, DOC_BEACH_URI); 155 158 assert_eq!(resolved.kind, EntryKind::Document); 156 - assert_eq!(resolved.name, "beach.jpg"); 159 + assert_eq!(resolved.name, "beach"); 157 160 assert_eq!(resolved.parent_uri.as_deref(), Some(DIR_PHOTOS_URI)); 158 161 } 159 162 160 163 #[tokio::test] 161 - async fn resolve_at_uri_not_found() { 164 + async fn resolve_at_uri_unknown_collection() { 162 165 let mock = MockTransport::new(); 163 166 let tree = load_simple_tree(&mock).await; 167 + let mut resolver = MockNameResolver::new(&[]); 164 168 165 - // getRecord 404 for unknown document 166 - mock.enqueue(HttpResponse { 167 - status: 404, 168 - headers: vec![], 169 - body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 170 - }); 171 - 172 - let mut client = mock_client(mock); 173 169 let err = tree 174 - .resolve(&mut client, "at://did:plc:test/app.opake.document/nope") 170 + .resolve(&mut resolver, "at://did:plc:test/app.opake.grant/nope") 175 171 .await 176 172 .unwrap_err(); 177 173 assert!(matches!(err, Error::NotFound(_))); ··· 183 179 async fn resolve_path_document_in_subdirectory() { 184 180 let mock = MockTransport::new(); 185 181 let tree = load_simple_tree(&mock).await; 186 - 187 - // getRecord for beach.jpg (document child of Photos) 188 - mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 182 + let mut resolver = MockNameResolver::new(&[(DOC_BEACH_URI, "beach.jpg")]); 189 183 190 - let mut client = mock_client(mock); 191 - let resolved = tree.resolve(&mut client, "Photos/beach.jpg").await.unwrap(); 184 + let resolved = tree 185 + .resolve(&mut resolver, "Photos/beach.jpg") 186 + .await 187 + .unwrap(); 192 188 assert_eq!(resolved.uri, DOC_BEACH_URI); 193 189 assert_eq!(resolved.kind, EntryKind::Document); 194 190 assert_eq!(resolved.parent_uri.as_deref(), Some(DIR_PHOTOS_URI)); ··· 198 194 async fn resolve_path_nested() { 199 195 let mock = MockTransport::new(); 200 196 let tree = load_nested_tree(&mock).await; 201 - 202 - // getRecord for sunset.jpg (document child of Vacation) 203 - mock.enqueue(doc_record_response(DOC_SUNSET_URI, "sunset.jpg")); 197 + let mut resolver = MockNameResolver::new(&[(DOC_SUNSET_URI, "sunset.jpg")]); 204 198 205 - let mut client = mock_client(mock); 206 199 let resolved = tree 207 - .resolve(&mut client, "Photos/Vacation/sunset.jpg") 200 + .resolve(&mut resolver, "Photos/Vacation/sunset.jpg") 208 201 .await 209 202 .unwrap(); 210 203 assert_eq!(resolved.uri, DOC_SUNSET_URI); ··· 215 208 async fn resolve_path_directory_target() { 216 209 let mock = MockTransport::new(); 217 210 let tree = load_nested_tree(&mock).await; 211 + let mut resolver = MockNameResolver::new(&[(DOC_BEACH_URI, "beach.jpg")]); 218 212 219 - // Vacation found in memory, but find_child_any still scans document 220 - // children of Photos for ambiguity. 221 - mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 222 - 223 - let mut client = mock_client(mock); 224 - let resolved = tree.resolve(&mut client, "Photos/Vacation").await.unwrap(); 213 + let resolved = tree 214 + .resolve(&mut resolver, "Photos/Vacation") 215 + .await 216 + .unwrap(); 225 217 assert_eq!(resolved.uri, DIR_VACATION_URI); 226 218 assert_eq!(resolved.kind, EntryKind::Directory); 227 219 } ··· 230 222 async fn resolve_path_not_found_segment() { 231 223 let mock = MockTransport::new(); 232 224 let tree = load_simple_tree(&mock).await; 233 - 234 - // getRecord for the one document child of Photos — no match 235 - mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 225 + let mut resolver = MockNameResolver::new(&[(DOC_BEACH_URI, "beach.jpg")]); 236 226 237 - let mut client = mock_client(mock); 238 227 let err = tree 239 - .resolve(&mut client, "Photos/missing.txt") 228 + .resolve(&mut resolver, "Photos/missing.txt") 240 229 .await 241 230 .unwrap_err(); 242 231 assert!(matches!(err, Error::NotFound(_))); ··· 246 235 async fn resolve_path_missing_intermediate_directory() { 247 236 let mock = MockTransport::new(); 248 237 let tree = load_simple_tree(&mock).await; 238 + let mut resolver = MockNameResolver::new(&[]); 249 239 250 - let mut client = mock_client(mock); 251 240 let err = tree 252 - .resolve(&mut client, "Nope/beach.jpg") 241 + .resolve(&mut resolver, "Nope/beach.jpg") 253 242 .await 254 243 .unwrap_err(); 255 244 assert!(matches!(err, Error::NotFound(_))); ··· 260 249 let mock = MockTransport::new(); 261 250 mock.enqueue(list_records_response(&[], None)); 262 251 263 - let mut client = mock_client(mock.clone()); 252 + let mut client = mock_client(mock); 264 253 let tree = DirectoryTree::load(&mut client).await.unwrap(); 254 + let mut resolver = MockNameResolver::new(&[]); 265 255 266 - let mut client = mock_client(mock); 267 256 let err = tree 268 - .resolve(&mut client, "Photos/beach.jpg") 257 + .resolve(&mut resolver, "Photos/beach.jpg") 269 258 .await 270 259 .unwrap_err(); 271 260 assert!(matches!(err, Error::NotFound(_))); ··· 277 266 async fn resolve_bare_name_document() { 278 267 let mock = MockTransport::new(); 279 268 let tree = load_simple_tree(&mock).await; 280 - 281 - // Root has 2 entries: DIR_PHOTOS_URI (directory, checked in memory) 282 - // and DOC_NOTES_URI (document, needs getRecord). 283 - mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 269 + let mut resolver = MockNameResolver::new(&[(DOC_NOTES_URI, "notes.txt")]); 284 270 285 - let mut client = mock_client(mock); 286 - let resolved = tree.resolve(&mut client, "notes.txt").await.unwrap(); 271 + let resolved = tree.resolve(&mut resolver, "notes.txt").await.unwrap(); 287 272 assert_eq!(resolved.uri, DOC_NOTES_URI); 288 273 assert_eq!(resolved.kind, EntryKind::Document); 289 274 } ··· 292 277 async fn resolve_bare_name_directory() { 293 278 let mock = MockTransport::new(); 294 279 let tree = load_simple_tree(&mock).await; 280 + let mut resolver = MockNameResolver::new(&[(DOC_NOTES_URI, "notes.txt")]); 295 281 296 - // Photos is a directory — found in memory, no document getRecords needed 297 - // because directory match is found first. 298 - // But find_child_any still scans document children for ambiguity. 299 - mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 300 - 301 - let mut client = mock_client(mock); 302 - let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 282 + let resolved = tree.resolve(&mut resolver, "Photos").await.unwrap(); 303 283 assert_eq!(resolved.uri, DIR_PHOTOS_URI); 304 284 assert_eq!(resolved.kind, EntryKind::Directory); 305 285 } ··· 308 288 async fn resolve_bare_name_not_found() { 309 289 let mock = MockTransport::new(); 310 290 let tree = load_simple_tree(&mock).await; 291 + let mut resolver = MockNameResolver::new(&[(DOC_NOTES_URI, "notes.txt")]); 311 292 312 - // Scans root's document children (notes.txt) — no match. 313 - mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 314 - 315 - let mut client = mock_client(mock); 316 - let err = tree.resolve(&mut client, "missing.txt").await.unwrap_err(); 293 + let err = tree 294 + .resolve(&mut resolver, "missing.txt") 295 + .await 296 + .unwrap_err(); 317 297 assert!(matches!(err, Error::NotFound(_))); 318 298 } 319 299 320 300 #[tokio::test] 321 301 async fn resolve_bare_name_no_root_searches_directories() { 322 302 let mock = MockTransport::new(); 323 - // No root, but a directory named "Photos" exists. 324 303 mock.enqueue(list_records_response( 325 304 &[("photos", dummy_directory_with_entries("Photos", vec![]))], 326 305 None, 327 306 )); 328 307 329 - let mut client = mock_client(mock.clone()); 308 + let mut client = mock_client(mock); 330 309 let mut tree = DirectoryTree::load(&mut client).await.unwrap(); 331 310 assert!(tree.root_uri.is_none()); 332 311 333 312 let (_, private_key) = test_keypair(); 334 313 tree.decrypt_names(TEST_DID, &private_key); 314 + let mut resolver = MockNameResolver::new(&[]); 335 315 336 - let mut client = mock_client(mock); 337 - let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 316 + let resolved = tree.resolve(&mut resolver, "Photos").await.unwrap(); 338 317 assert_eq!(resolved.uri, "at://did:plc:test/app.opake.directory/photos"); 339 318 assert_eq!(resolved.kind, EntryKind::Directory); 340 319 }
-3
crates/opake-core/src/documents/download.rs
··· 221 221 crypto::encrypt_metadata(&fixture.content_key, &metadata, &mut OsRng).unwrap(); 222 222 223 223 Document { 224 - mime_type: Some("text/plain".into()), 225 - size: Some(42), 226 224 visibility: Some("private".into()), 227 225 ..Document::new( 228 - "encrypted".into(), 229 226 BlobRef { 230 227 blob_type: "blob".into(), 231 228 reference: CidLink {
+19 -24
crates/opake-core/src/documents/download_grant.rs
··· 214 214 let encrypted_metadata = 215 215 crypto::encrypt_metadata(content_key, &metadata, &mut OsRng).unwrap(); 216 216 217 - Document { 218 - mime_type: Some("text/plain".into()), 219 - size: Some(42), 220 - ..Document::new( 221 - "encrypted".into(), 222 - BlobRef { 223 - blob_type: "blob".into(), 224 - reference: CidLink { 225 - cid: "bafyblob".into(), 217 + Document::new( 218 + BlobRef { 219 + blob_type: "blob".into(), 220 + reference: CidLink { 221 + cid: "bafyblob".into(), 222 + }, 223 + mime_type: "application/octet-stream".into(), 224 + size: ciphertext_len as u64, 225 + }, 226 + Encryption::Direct(DirectEncryption { 227 + envelope: EncryptionEnvelope { 228 + algo: "aes-256-gcm".into(), 229 + nonce: AtBytes { 230 + encoded: BASE64.encode(nonce), 226 231 }, 227 - mime_type: "application/octet-stream".into(), 228 - size: ciphertext_len as u64, 232 + keys: vec![owner_wrapped], 229 233 }, 230 - Encryption::Direct(DirectEncryption { 231 - envelope: EncryptionEnvelope { 232 - algo: "aes-256-gcm".into(), 233 - nonce: AtBytes { 234 - encoded: BASE64.encode(nonce), 235 - }, 236 - keys: vec![owner_wrapped], 237 - }, 238 - }), 239 - encrypted_metadata, 240 - "2026-03-01T00:00:00Z".into(), 241 - ) 242 - } 234 + }), 235 + encrypted_metadata, 236 + "2026-03-01T00:00:00Z".into(), 237 + ) 243 238 } 244 239 245 240 #[tokio::test]
+42 -52
crates/opake-core/src/documents/download_keyring_tests.rs
··· 104 104 let encrypted_metadata = 105 105 crypto::encrypt_metadata(&fixture.content_key, &metadata, &mut OsRng).unwrap(); 106 106 107 - Document { 108 - mime_type: Some("text/plain".into()), 109 - size: Some(42), 110 - ..Document::new( 111 - "encrypted".into(), 112 - BlobRef { 113 - blob_type: "blob".into(), 114 - reference: CidLink { 115 - cid: "bafyblob".into(), 107 + Document::new( 108 + BlobRef { 109 + blob_type: "blob".into(), 110 + reference: CidLink { 111 + cid: "bafyblob".into(), 112 + }, 113 + mime_type: "application/octet-stream".into(), 114 + size: fixture.ciphertext.len() as u64, 115 + }, 116 + Encryption::Keyring(KeyringEncryption { 117 + keyring_ref: KeyringRef { 118 + keyring: KR_URI.into(), 119 + wrapped_content_key: AtBytes { 120 + encoded: BASE64.encode(&fixture.wrapped_content_key_bytes), 116 121 }, 117 - mime_type: "application/octet-stream".into(), 118 - size: fixture.ciphertext.len() as u64, 122 + rotation, 123 + }, 124 + algo: "aes-256-gcm".into(), 125 + nonce: AtBytes { 126 + encoded: BASE64.encode(fixture.nonce), 119 127 }, 120 - Encryption::Keyring(KeyringEncryption { 121 - keyring_ref: KeyringRef { 122 - keyring: KR_URI.into(), 123 - wrapped_content_key: AtBytes { 124 - encoded: BASE64.encode(&fixture.wrapped_content_key_bytes), 125 - }, 126 - rotation, 127 - }, 128 - algo: "aes-256-gcm".into(), 129 - nonce: AtBytes { 130 - encoded: BASE64.encode(fixture.nonce), 131 - }, 132 - }), 133 - encrypted_metadata, 134 - "2026-03-01T00:00:00Z".into(), 135 - ) 136 - } 128 + }), 129 + encrypted_metadata, 130 + "2026-03-01T00:00:00Z".into(), 131 + ) 137 132 } 138 133 139 134 fn keyring_document(fixture: &KeyringFixture) -> Document { ··· 231 226 }; 232 227 let encrypted_metadata = crypto::encrypt_metadata(&content_key, &metadata, &mut OsRng).unwrap(); 233 228 234 - let doc = Document { 235 - mime_type: Some("text/plain".into()), 236 - size: Some(4), 237 - ..Document::new( 238 - "encrypted".into(), 239 - BlobRef { 240 - blob_type: "blob".into(), 241 - reference: CidLink { 242 - cid: "bafyblob".into(), 229 + let doc = Document::new( 230 + BlobRef { 231 + blob_type: "blob".into(), 232 + reference: CidLink { 233 + cid: "bafyblob".into(), 234 + }, 235 + mime_type: "application/octet-stream".into(), 236 + size: payload.ciphertext.len() as u64, 237 + }, 238 + Encryption::Direct(records::DirectEncryption { 239 + envelope: records::EncryptionEnvelope { 240 + algo: "aes-256-gcm".into(), 241 + nonce: AtBytes { 242 + encoded: BASE64.encode(payload.nonce), 243 243 }, 244 - mime_type: "application/octet-stream".into(), 245 - size: payload.ciphertext.len() as u64, 244 + keys: vec![wrapped], 246 245 }, 247 - Encryption::Direct(records::DirectEncryption { 248 - envelope: records::EncryptionEnvelope { 249 - algo: "aes-256-gcm".into(), 250 - nonce: AtBytes { 251 - encoded: BASE64.encode(payload.nonce), 252 - }, 253 - keys: vec![wrapped], 254 - }, 255 - }), 256 - encrypted_metadata, 257 - "2026-03-01T00:00:00Z".into(), 258 - ) 259 - }; 246 + }), 247 + encrypted_metadata, 248 + "2026-03-01T00:00:00Z".into(), 249 + ); 260 250 261 251 let mock = MockTransport::new(); 262 252 mock.enqueue(did_document_response());
+22 -40
crates/opake-core/src/documents/list.rs
··· 1 1 use crate::client::{list_collection, Transport, XrpcClient}; 2 + use crate::crypto::DocumentMetadata; 2 3 use crate::error::Error; 3 4 use crate::records::{Document, EncryptedMetadata, Encryption}; 4 5 5 6 use super::DOCUMENT_COLLECTION; 6 7 7 - /// A document listing entry with its AT-URI and parsed metadata. 8 - /// 9 - /// A document listing entry with its AT-URI and parsed metadata. 8 + /// A raw document listing entry from the PDS. 10 9 /// 11 - /// The `name`/`size`/`mime_type`/`tags` fields contain dummy placeholder 12 - /// values. Callers must decrypt `encrypted_metadata` using the content key 13 - /// (unwrapped from `encryption`) to get real values. 10 + /// Contains only wire-format data: AT-URI, timestamps, encryption envelope, 11 + /// and the encrypted metadata blob. Callers must decrypt `encrypted_metadata` 12 + /// to obtain the document's name, MIME type, size, tags, and description. 14 13 #[derive(Debug)] 15 14 pub struct DocumentEntry { 16 15 pub uri: String, 17 - pub name: String, 18 - pub size: Option<u64>, 19 - pub mime_type: Option<String>, 20 - pub tags: Vec<String>, 21 16 pub created_at: String, 22 17 pub encrypted_metadata: EncryptedMetadata, 23 18 pub encryption: Encryption, 24 19 } 25 20 21 + /// A document entry with its metadata decrypted. 22 + #[derive(Debug)] 23 + pub struct DecryptedDocumentEntry { 24 + pub uri: String, 25 + pub created_at: String, 26 + pub encryption: Encryption, 27 + pub metadata: DocumentMetadata, 28 + } 29 + 26 30 /// Fetch all document records, paginating through the full collection. 27 31 /// Silently skips records that can't be parsed or have an unsupported 28 32 /// schema version — these are expected when upgrading clients. ··· 32 36 list_collection(client, DOCUMENT_COLLECTION, |uri, doc: Document| { 33 37 DocumentEntry { 34 38 uri: uri.to_owned(), 35 - name: doc.name, 36 - size: doc.size, 37 - mime_type: doc.mime_type, 38 - tags: doc.tags, 39 39 created_at: doc.created_at, 40 40 encrypted_metadata: doc.encrypted_metadata, 41 41 encryption: doc.encryption, ··· 55 55 56 56 #[tokio::test] 57 57 async fn single_document() { 58 - let doc = dummy_document("notes.txt", 1024, vec![]); 58 + let doc = dummy_document(); 59 59 let mock = MockTransport::new(); 60 60 mock.enqueue(list_records_response(&[("abc", doc)], None)); 61 61 ··· 63 63 let entries = list_documents(&mut client).await.unwrap(); 64 64 65 65 assert_eq!(entries.len(), 1); 66 - assert_eq!(entries[0].name, "notes.txt"); 67 - assert_eq!(entries[0].size, Some(1024)); 68 66 assert!(entries[0].uri.contains("abc")); 69 67 70 68 let requests = mock.requests(); ··· 76 74 #[tokio::test] 77 75 async fn multiple_documents() { 78 76 let docs = vec![ 79 - ( 80 - "a1", 81 - dummy_document("photo.jpg", 2_000_000, vec!["photos".into()]), 82 - ), 83 - ("a2", dummy_document("resume.pdf", 50_000, vec![])), 84 - ( 85 - "a3", 86 - dummy_document("secret.key", 256, vec!["crypto".into(), "keys".into()]), 87 - ), 77 + ("a1", dummy_document()), 78 + ("a2", dummy_document()), 79 + ("a3", dummy_document()), 88 80 ]; 89 81 let mock = MockTransport::new(); 90 82 mock.enqueue(list_records_response(&docs, None)); ··· 93 85 let entries = list_documents(&mut client).await.unwrap(); 94 86 95 87 assert_eq!(entries.len(), 3); 96 - assert_eq!(entries[0].name, "photo.jpg"); 97 - assert_eq!(entries[1].name, "resume.pdf"); 98 - assert_eq!(entries[2].name, "secret.key"); 99 - assert_eq!(entries[2].tags, vec!["crypto", "keys"]); 100 88 } 101 89 102 90 #[tokio::test] 103 91 async fn paginates_through_multiple_pages() { 104 92 let mock = MockTransport::new(); 105 93 mock.enqueue(list_records_response( 106 - &[("a1", dummy_document("file1.txt", 100, vec![]))], 94 + &[("a1", dummy_document())], 107 95 Some("cursor-abc"), 108 96 )); 109 - mock.enqueue(list_records_response( 110 - &[("a2", dummy_document("file2.txt", 200, vec![]))], 111 - None, 112 - )); 97 + mock.enqueue(list_records_response(&[("a2", dummy_document())], None)); 113 98 114 99 let mut client = mock_client(mock.clone()); 115 100 let entries = list_documents(&mut client).await.unwrap(); 116 101 117 102 assert_eq!(entries.len(), 2); 118 - assert_eq!(entries[0].name, "file1.txt"); 119 - assert_eq!(entries[1].name, "file2.txt"); 120 103 121 104 let requests = mock.requests(); 122 105 assert_eq!(requests.len(), 2); ··· 145 128 { 146 129 "uri": "at://did:plc:test/app.opake.document/good1", 147 130 "cid": "bafygood", 148 - "value": dummy_document("good.txt", 42, vec![]), 131 + "value": dummy_document(), 149 132 }, 150 133 ] 151 134 }); ··· 161 144 let entries = list_documents(&mut client).await.unwrap(); 162 145 163 146 assert_eq!(entries.len(), 1); 164 - assert_eq!(entries[0].name, "good.txt"); 165 147 } 166 148 167 149 #[tokio::test] 168 150 async fn skips_future_schema_version() { 169 - let mut doc = dummy_document("future.txt", 100, vec![]); 151 + let mut doc = dummy_document(); 170 152 doc.opake_version = records::SCHEMA_VERSION + 1; 171 153 172 154 let mock = MockTransport::new();
+3 -10
crates/opake-core/src/documents/mod.rs
··· 10 10 mod download_grant; 11 11 mod download_keyring; 12 12 mod list; 13 - mod resolve; 14 13 mod upload; 15 14 16 15 pub use delete::delete_document; 17 16 pub use download::{download, download_with_group_key, fetch_content_key}; 18 17 pub use download_grant::download_from_grant; 19 18 pub use download_keyring::{download_from_keyring_member, KeyringDownloadResult}; 20 - pub use list::{list_documents, DocumentEntry}; 21 - pub use resolve::resolve_uri; 19 + pub use list::{list_documents, DecryptedDocumentEntry, DocumentEntry}; 22 20 pub use upload::{ 23 21 encrypt_and_upload, encrypt_and_upload_keyring, KeyringUploadParams, UploadParams, 24 - OPAQUE_PLACEHOLDER_NAME, 25 22 }; 26 23 27 24 pub const DOCUMENT_COLLECTION: &str = "app.opake.document"; ··· 61 58 } 62 59 } 63 60 64 - pub fn dummy_document(name: &str, size: u64, tags: Vec<String>) -> Document { 61 + pub fn dummy_document() -> Document { 65 62 Document { 66 - mime_type: Some("text/plain".into()), 67 - size: Some(size), 68 - tags, 69 63 visibility: Some("private".into()), 70 64 ..Document::new( 71 - name.into(), 72 65 BlobRef { 73 66 blob_type: "blob".into(), 74 67 reference: CidLink { 75 68 cid: "bafytest".into(), 76 69 }, 77 70 mime_type: "application/octet-stream".into(), 78 - size, 71 + size: 1024, 79 72 }, 80 73 Encryption::Direct(DirectEncryption { 81 74 envelope: EncryptionEnvelope {
-116
crates/opake-core/src/documents/resolve.rs
··· 1 - use crate::client::{Transport, XrpcClient}; 2 - use crate::error::Error; 3 - 4 - use super::list::{list_documents, DocumentEntry}; 5 - 6 - /// Resolve a user-provided reference to a document's AT URI. 7 - /// 8 - /// If `reference` already looks like an `at://` URI, it's returned as-is. 9 - /// Otherwise it's treated as a filename: we list all documents and find the 10 - /// matching entry. Exactly one match is required — zero or multiple matches 11 - /// are errors. 12 - pub async fn resolve_uri( 13 - client: &mut XrpcClient<impl Transport>, 14 - reference: &str, 15 - ) -> Result<String, Error> { 16 - if reference.starts_with("at://") { 17 - return Ok(reference.to_string()); 18 - } 19 - 20 - let entries = list_documents(client).await?; 21 - let matches: Vec<&DocumentEntry> = entries.iter().filter(|e| e.name == reference).collect(); 22 - 23 - match matches.len() { 24 - 0 => Err(Error::NotFound(format!( 25 - "no document named {:?} — use `opake ls` to see your documents", 26 - reference 27 - ))), 28 - 1 => Ok(matches[0].uri.clone()), 29 - n => { 30 - let uris: Vec<String> = matches.iter().map(|e| e.uri.clone()).collect(); 31 - Err(Error::AmbiguousName { 32 - name: reference.to_string(), 33 - count: n, 34 - uris, 35 - }) 36 - } 37 - } 38 - } 39 - 40 - #[cfg(test)] 41 - mod tests { 42 - use super::*; 43 - use crate::test_utils::MockTransport; 44 - 45 - use super::super::tests::{dummy_document, list_records_response, mock_client}; 46 - 47 - #[tokio::test] 48 - async fn passthrough_at_uri() { 49 - let mock = MockTransport::new(); 50 - let mut client = mock_client(mock); 51 - 52 - let uri = resolve_uri(&mut client, "at://did:plc:test/app.opake.document/abc") 53 - .await 54 - .unwrap(); 55 - assert_eq!(uri, "at://did:plc:test/app.opake.document/abc"); 56 - } 57 - 58 - #[tokio::test] 59 - async fn resolves_unique_filename() { 60 - let mock = MockTransport::new(); 61 - mock.enqueue(list_records_response( 62 - &[ 63 - ("a1", dummy_document("notes.txt", 100, vec![])), 64 - ("a2", dummy_document("photo.jpg", 200, vec![])), 65 - ], 66 - None, 67 - )); 68 - 69 - let mut client = mock_client(mock); 70 - let uri = resolve_uri(&mut client, "photo.jpg").await.unwrap(); 71 - assert!(uri.contains("a2")); 72 - } 73 - 74 - #[tokio::test] 75 - async fn no_match_returns_not_found() { 76 - let mock = MockTransport::new(); 77 - mock.enqueue(list_records_response( 78 - &[("a1", dummy_document("notes.txt", 100, vec![]))], 79 - None, 80 - )); 81 - 82 - let mut client = mock_client(mock); 83 - let err = resolve_uri(&mut client, "missing.pdf").await.unwrap_err(); 84 - let msg = err.to_string(); 85 - assert!(msg.contains("no document named"), "got: {msg}"); 86 - assert!(msg.contains("opake ls"), "should suggest ls, got: {msg}"); 87 - } 88 - 89 - #[tokio::test] 90 - async fn ambiguous_name_returns_error() { 91 - let mock = MockTransport::new(); 92 - mock.enqueue(list_records_response( 93 - &[ 94 - ("a1", dummy_document("report.pdf", 100, vec![])), 95 - ("a2", dummy_document("report.pdf", 200, vec![])), 96 - ], 97 - None, 98 - )); 99 - 100 - let mut client = mock_client(mock); 101 - let err = resolve_uri(&mut client, "report.pdf").await.unwrap_err(); 102 - let msg = err.to_string(); 103 - assert!(msg.contains("report.pdf"), "got: {msg}"); 104 - assert!(msg.contains("2"), "should mention count, got: {msg}"); 105 - } 106 - 107 - #[tokio::test] 108 - async fn empty_collection_returns_not_found() { 109 - let mock = MockTransport::new(); 110 - mock.enqueue(list_records_response(&[], None)); 111 - 112 - let mut client = mock_client(mock); 113 - let err = resolve_uri(&mut client, "anything.txt").await.unwrap_err(); 114 - assert!(err.to_string().contains("no document named")); 115 - } 116 - }
+4 -14
crates/opake-core/src/documents/upload.rs
··· 14 14 /// Maximum blob size accepted by a standard PDS (50 MB). 15 15 const MAX_BLOB_SIZE: usize = 50 * 1024 * 1024; 16 16 17 - /// Plaintext name written to the record when metadata is encrypted. 18 - pub const OPAQUE_PLACEHOLDER_NAME: &str = "encrypted"; 19 - 20 - /// Plaintext MIME type written to the record when metadata is encrypted. 21 - pub const OPAQUE_PLACEHOLDER_MIME: &str = "application/octet-stream"; 22 - 23 17 /// Build a `DocumentMetadata` from upload parameters and encrypt it. 24 18 fn build_encrypted_metadata( 25 19 content_key: &crypto::ContentKey, ··· 102 96 )?; 103 97 104 98 let document = Document { 105 - mime_type: Some(OPAQUE_PLACEHOLDER_MIME.into()), 106 99 visibility: Some("private".into()), 107 100 ..Document::new( 108 - OPAQUE_PLACEHOLDER_NAME.into(), 109 101 blob_ref, 110 102 Encryption::Direct(DirectEncryption { 111 103 envelope: EncryptionEnvelope { ··· 184 176 )?; 185 177 186 178 let document = Document { 187 - mime_type: Some(OPAQUE_PLACEHOLDER_MIME.into()), 188 179 visibility: Some("private".into()), 189 180 ..Document::new( 190 - OPAQUE_PLACEHOLDER_NAME.into(), 191 181 blob_ref, 192 182 Encryption::Keyring(KeyringEncryption { 193 183 keyring_ref: KeyringRef { ··· 301 291 assert_eq!(v["collection"], "app.opake.document"); 302 292 let record = &v["record"]; 303 293 304 - // Plaintext fields are dummies 305 - assert_eq!(record["name"], OPAQUE_PLACEHOLDER_NAME); 306 - assert_eq!(record["mimeType"], OPAQUE_PLACEHOLDER_MIME); 307 - assert!(record.get("size").is_none() || record["size"].is_null()); 294 + // No plaintext metadata fields on the record 295 + assert!(record.get("name").is_none()); 296 + assert!(record.get("mimeType").is_none()); 297 + assert!(record.get("size").is_none()); 308 298 assert!(record.get("tags").is_none()); 309 299 assert_eq!(record["visibility"], "private"); 310 300
-18
crates/opake-core/src/records/document.rs
··· 33 33 pub struct Document { 34 34 #[serde(default = "default_version")] 35 35 pub opake_version: u32, 36 - pub name: String, 37 - #[serde(skip_serializing_if = "Option::is_none")] 38 - pub mime_type: Option<String>, 39 - #[serde(skip_serializing_if = "Option::is_none")] 40 - pub size: Option<u64>, 41 36 pub blob: BlobRef, 42 37 pub encryption: Encryption, 43 - #[serde(default, skip_serializing_if = "Vec::is_empty")] 44 - pub tags: Vec<String>, 45 - #[serde(skip_serializing_if = "Option::is_none")] 46 - pub description: Option<String>, 47 38 pub encrypted_metadata: EncryptedMetadata, 48 39 #[serde(skip_serializing_if = "Option::is_none")] 49 40 pub visibility: Option<String>, ··· 53 44 } 54 45 55 46 impl Document { 56 - /// Construct a new document with the current schema version and sensible 57 - /// defaults for optional fields. Callers set tags/description/etc. via 58 - /// struct update syntax: `Document::new(..) { tags, ..Document::new(..) }` 59 47 pub fn new( 60 - name: String, 61 48 blob: BlobRef, 62 49 encryption: Encryption, 63 50 encrypted_metadata: EncryptedMetadata, ··· 65 52 ) -> Self { 66 53 Self { 67 54 opake_version: SCHEMA_VERSION, 68 - name, 69 - mime_type: None, 70 - size: None, 71 55 blob, 72 56 encryption, 73 - tags: Vec::new(), 74 - description: None, 75 57 encrypted_metadata, 76 58 visibility: None, 77 59 created_at,
+3 -6
docs/ARCHITECTURE.md
··· 265 265 PUBLICKEY ||--|| ACCOUNT : "one per" 266 266 267 267 DOCUMENT { 268 - string name 269 268 blob encrypted_content 270 269 union encryption "direct or keyring" 271 - string[] tags 270 + ref encryptedMetadata "name, type, size, tags, description" 272 271 string visibility 273 272 } 274 273 ··· 292 291 } 293 292 ``` 294 293 295 - ### Plaintext Metadata Tradeoff 296 - 297 - File names, tags, MIME types, and descriptions are stored unencrypted in the document record. This allows a personal AppView to index and search files server-side without access to encryption keys. 294 + ### Encrypted Metadata 298 295 299 - For full opacity, set these fields to generic values and embed real metadata inside the encrypted blob. The schema supports both approaches. 296 + All document metadata (name, MIME type, size, tags, description) is encrypted inside `encryptedMetadata` using the same content key as the blob. The PDS never sees real filenames or tags. This means server-side search/indexing requires client-side decryption — a deliberate tradeoff for privacy. 300 297 301 298 ## Cross-PDS Access 302 299
+9 -2
docs/flows/documents.md
··· 11 11 participant Crypto 12 12 participant PDS 13 13 14 - User->>CLI: opake upload photo.jpg --tags vacation 14 + User->>CLI: opake upload photo.jpg 15 15 16 16 CLI->>CLI: Read file from disk, detect MIME type 17 17 CLI->>Crypto: generate_content_key() ··· 25 25 26 26 CLI->>Crypto: wrap_key(K, owner_pubkey, owner_did) 27 27 Crypto-->>CLI: wrappedKey (x25519-hkdf-a256kw) 28 + 29 + CLI->>Crypto: encrypt_metadata(K, {name, mimeType, size, tags, ...}) 30 + Crypto-->>CLI: encryptedMetadata { ciphertext, nonce } 28 31 29 32 CLI->>PDS: com.atproto.repo.createRecord (document) 30 33 PDS-->>CLI: { uri, cid } ··· 121 124 PDS-->>CLI: { records: [...], cursor? } 122 125 end 123 126 124 - CLI->>CLI: Parse documents, filter by tag 127 + CLI->>CLI: For each document, unwrap content key 128 + CLI->>Crypto: decrypt_metadata(K, encryptedMetadata) 129 + Crypto-->>CLI: { name, mimeType, size, tags, description } 130 + 131 + CLI->>CLI: Filter by tag, format output 125 132 CLI->>User: Display table (name, size, tags, URI) 126 133 ``` 127 134
+17 -21
lexicons/EXAMPLES.md
··· 60 60 { 61 61 "$type": "app.opake.document", 62 62 "opakeVersion": 1, 63 - "name": "tax-return-2025.pdf", 64 - "mimeType": "application/pdf", 65 - "size": 284619, 66 63 "blob": { 67 64 "$type": "blob", 68 65 "ref": { "$link": "bafkrei..." }, ··· 83 80 ] 84 81 } 85 82 }, 86 - "tags": ["tax", "finance", "2025"], 83 + "encryptedMetadata": { 84 + "ciphertext": { "$bytes": "base64-aes-256-gcm-encrypted-metadata-json" }, 85 + "nonce": { "$bytes": "base64-encoded-12-byte-nonce" } 86 + }, 87 87 "visibility": "private", 88 88 "createdAt": "2026-02-27T10:30:00.000Z" 89 89 } 90 90 ``` 91 91 92 - **What the PDS sees:** a record with some plaintext metadata (name, tags, timestamps) 93 - and an opaque blob. The `keys` array only contains Alice's wrapped key — only she 94 - can decrypt. 92 + **What the PDS sees:** a record with an opaque blob and opaque encrypted metadata. 93 + The real filename ("tax-return-2025.pdf"), MIME type, size, and tags are all inside 94 + `encryptedMetadata`, encrypted with the same content key as the blob. The `keys` 95 + array only contains Alice's wrapped key — only she can decrypt. 95 96 96 97 97 98 ## 4. Alice shares the document with Bob via a grant ··· 164 165 { 165 166 "$type": "app.opake.document", 166 167 "opakeVersion": 1, 167 - "name": "beach-sunset.jpg", 168 - "mimeType": "image/jpeg", 169 - "size": 3841029, 170 168 "blob": { 171 169 "$type": "blob", 172 170 "ref": { "$link": "bafkrei..." }, ··· 183 181 "algo": "aes-256-gcm", 184 182 "nonce": { "$bytes": "base64-encoded-12-byte-nonce" } 185 183 }, 186 - "tags": ["family", "vacation", "beach"], 184 + "encryptedMetadata": { 185 + "ciphertext": { "$bytes": "base64-aes-256-gcm-encrypted-metadata-json" }, 186 + "nonce": { "$bytes": "base64-encoded-12-byte-nonce" } 187 + }, 187 188 "visibility": "shared", 188 189 "createdAt": "2026-02-20T16:45:00.000Z" 189 190 } ··· 257 258 258 259 ## Design Decisions & Notes 259 260 260 - ### Why plaintext metadata? 261 - The `name`, `tags`, `mimeType`, and `description` fields are intentionally unencrypted. 262 - This allows your personal AppView to index and search your files server-side without 263 - needing access to the content encryption keys. It's a conscious tradeoff: someone 264 - inspecting your repo can see *that* you have a file called "tax-return-2025.pdf" tagged 265 - with "finance", but they can't read the actual PDF. 266 - 267 - If you want fully opaque storage, you can encrypt the name/tags too and handle 268 - search purely client-side. The schema supports this — just put garbage/generic 269 - strings in the plaintext fields and store the real metadata inside the encrypted blob. 261 + ### Why encrypted metadata? 262 + All document metadata — name, MIME type, size, tags, description — is encrypted 263 + inside `encryptedMetadata` using the same content key as the blob. The PDS never 264 + sees real filenames or tags. This means server-side search and indexing require 265 + client-side decryption, but no metadata is ever leaked to the storage layer. 270 266 271 267 ### Why separate grant records? 272 268 Instead of adding recipients directly to the document record (like adding to the
+2 -28
lexicons/app.opake.document.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "An encrypted file or document stored in the personal cloud. The actual content is an encrypted blob; this record holds the metadata, encryption envelope, and optional plaintext metadata for discoverability.", 7 + "description": "An encrypted file or document stored in the personal cloud. The actual content is an encrypted blob; this record holds the encryption envelope and encrypted metadata. All document metadata (name, MIME type, size, tags, description) is encrypted inside encryptedMetadata using the same content key as the blob.", 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["opakeVersion", "name", "blob", "encryption", "encryptedMetadata", "createdAt"], 11 + "required": ["opakeVersion", "blob", "encryption", "encryptedMetadata", "createdAt"], 12 12 "properties": { 13 13 "opakeVersion": { 14 14 "type": "integer", 15 15 "description": "Schema version for the app.opake.* namespace. Clients should reject records with a version they do not understand.", 16 16 "minimum": 1 17 17 }, 18 - "name": { 19 - "type": "string", 20 - "description": "Human-readable filename or title. Plaintext — intentionally unencrypted for indexing/search by your own AppView.", 21 - "maxLength": 512 22 - }, 23 - "mimeType": { 24 - "type": "string", 25 - "description": "Original MIME type of the unencrypted content (e.g. 'application/pdf', 'image/png'). Plaintext metadata.", 26 - "maxLength": 128 27 - }, 28 - "size": { 29 - "type": "integer", 30 - "description": "Size of the original unencrypted content in bytes.", 31 - "minimum": 0 32 - }, 33 18 "blob": { 34 19 "type": "blob", 35 20 "description": "The encrypted file content, uploaded via com.atproto.repo.uploadBlob. The PDS stores this as opaque bytes. MimeType on the blob itself will be 'application/octet-stream'.", ··· 43 28 "#directEncryption", 44 29 "#keyringEncryption" 45 30 ] 46 - }, 47 - "tags": { 48 - "type": "array", 49 - "description": "Optional plaintext tags for categorization and search. Keep these non-sensitive — they're public.", 50 - "items": { "type": "string", "maxLength": 128 }, 51 - "maxLength": 32 52 - }, 53 - "description": { 54 - "type": "string", 55 - "description": "Optional plaintext description or summary.", 56 - "maxLength": 1024 57 31 }, 58 32 "encryptedMetadata": { 59 33 "type": "ref",