diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index ce7eb28..3df063d 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -1,6 +1,6 @@ //! The high-level Drive client and its read operations. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::{Cursor, Read}; use std::num::NonZeroUsize; use std::sync::Arc; @@ -57,9 +57,10 @@ use crate::dtos::{ MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, - RevisionDto, RevisionResponse, RevisionUpdateRequest, ShareInvitationDto, - ShareInvitationsResponse, ShareMembersResponse, ShareResponse, ShareTargetType, ShareUrlDto, - ShareUrlsResponse, SharedByMeResponse, SharedWithMeResponse, ThumbnailBlockListRequest, + RevisionDto, RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, + RevisionResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse, + ShareMembersResponse, ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, + SharedByMeResponse, SharedWithMeResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse, @@ -68,7 +69,7 @@ use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem}; use crate::revision::{ - MAX_CONCURRENT_BLOCK_DOWNLOADS, RevisionReader, digest_and_decrypt_block_blocking, + MAX_CONCURRENT_BLOCK_DOWNLOADS, Revision, RevisionReader, digest_and_decrypt_block_blocking, }; use crate::sharing::{ Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole, @@ -220,6 +221,22 @@ impl Default for DriveCache { } } +/// What a fresh-file draft should describe: where it goes, what it claims to +/// be, and which of the upload variants it takes. Inputs to +/// [`ProtonDriveClient::create_file_draft`]. +struct FileDraftSpec<'a> { + parent_uid: &'a NodeUid, + name: &'a str, + media_type: &'a str, + intended_upload_size: i64, + /// Encrypt blocks as SEIPDv2/AEAD rather than legacy SEIPDv1. + aead: bool, + /// Route the draft through the photos volume/share instead of My Files. + for_photos: bool, + /// Replace an existing draft at the same name instead of failing on it. + override_existing_draft: bool, +} + /// An open revision draft ready to receive content blocks: the target revision /// plus the keys and signing identity needed to encrypt, sign and seal it. /// Produced by [`ProtonDriveClient::create_file_draft`] (new file) or @@ -540,6 +557,185 @@ impl ProtonDriveClient { Ok(nodes) } + /// The chain of nodes from the tree root down to `uid`, inclusive. + /// + /// The first element is the root (the node with no parent — My Files, a + /// device root, or a share root); the last is `uid` itself. Mirrors JS + /// `nodesAccess.getNodeHierarchy` and C# `TraversalOperations.FindRootForNode`, + /// which walks `ParentUid` upwards and rejects cycles. + /// + /// Returns `Ok(None)` when `uid` itself does not exist. A missing *ancestor* + /// is an error, not a truncated chain: a node whose parent cannot be read is + /// not placeable in the tree. + pub async fn get_node_hierarchy(&self, uid: &NodeUid) -> Result>> { + let mut timer = self.telemetry.start("get_node_hierarchy"); + + let Some(node) = self.get_node(uid).await? else { + timer.success(); + return Ok(None); + }; + + // Leaf-first while walking, reversed to root-first before returning. + let mut chain = vec![node]; + let mut visited: HashSet = HashSet::new(); + visited.insert(uid.clone()); + + while let Some(parent_uid) = chain.last().and_then(|n| n.parent_uid.clone()) { + // Both upstream SDKs throw on a parent cycle rather than spinning. + if !visited.insert(parent_uid.clone()) { + return Err(ProtonError::invalid_operation(format!( + "Folder structure loop detected at {parent_uid}" + ))); + } + let parent = self.get_node(&parent_uid).await?.ok_or_else(|| { + ProtonError::invalid_operation(format!( + "Node hierarchy is broken: parent {parent_uid} does not exist" + )) + })?; + chain.push(parent); + } + + chain.reverse(); + timer.attr("depth", chain.len()); + timer.success(); + Ok(Some(chain)) + } + + /// The absolute path of `uid` within its tree, e.g. `/Documents/report.pdf`. + /// + /// Built from [`get_node_hierarchy`](Self::get_node_hierarchy) with the root + /// dropped — the root's decrypted name is an internal placeholder ("root"), + /// not something a user ever sees. The root itself is therefore `/`. + /// + /// Beyond upstream: neither the C# nor the TypeScript SDK exposes paths. + /// Names are not path-safe — a Drive node name may legitimately contain any + /// character except `/` — so the result round-trips through + /// [`get_node_by_path`](Self::get_node_by_path) but is not a filesystem path. + pub async fn get_node_path(&self, uid: &NodeUid) -> Result> { + let Some(hierarchy) = self.get_node_hierarchy(uid).await? else { + return Ok(None); + }; + Ok(Some(join_node_path( + hierarchy.iter().skip(1).map(|node| node.name.as_str()), + ))) + } + + /// Resolve a slash-separated path under `root_uid` to a node. + /// + /// `path` is matched segment by segment against decrypted child names; + /// leading, trailing, and repeated slashes are ignored, so `/a/b/`, `a/b`, + /// and `//a//b` are the same lookup. An empty path resolves to the root. + /// Returns `Ok(None)` as soon as a segment has no match. + /// + /// Beyond upstream. Node names are encrypted server-side and there is no + /// name-query endpoint, so this necessarily lists and decrypts one directory + /// level per segment. It uses + /// [`enumerate_nodes_light`](Self::enumerate_nodes_light) — only the name and + /// kind matter here, so the per-file node-key unlock is skipped — then + /// re-reads the final node in full so the returned [`Node`] carries complete + /// metadata. + /// + /// Matching is exact and case-sensitive (Drive names are case-sensitive). + /// When a folder holds several children of the same name — which the API + /// permits — the first in listing order wins. + pub async fn get_node_by_path(&self, root_uid: &NodeUid, path: &str) -> Result> { + let mut timer = self.telemetry.start("get_node_by_path"); + + let mut current = root_uid.clone(); + let mut descended = false; + + for segment in path_segments(path) { + let Some(child) = self.find_child_by_name(¤t, segment).await? else { + timer.success(); + return Ok(None); + }; + current = child; + descended = true; + } + + // The walk only ever knew names and uids; fetch the target in full. + let node = if descended { + self.get_node(¤t).await? + } else { + self.get_node(root_uid).await? + }; + timer.success(); + Ok(node) + } + + /// Create every missing folder along `path` under `root_uid` (`mkdir -p`). + /// + /// Returns the uid of the deepest folder. Segments that already exist are + /// reused — including when they were created concurrently, in which case the + /// losing `create_folder` is retried as a lookup. A segment that exists but + /// is a *file* is an error: the path cannot be extended through it. + /// + /// Beyond upstream. + pub async fn create_folder_path(&self, root_uid: &NodeUid, path: &str) -> Result { + let mut timer = self.telemetry.start("create_folder_path"); + let mut current = root_uid.clone(); + + for segment in path_segments(path) { + if let Some(existing) = self.find_existing_folder(¤t, segment).await? { + current = existing; + continue; + } + + match self.create_folder(¤t, segment, None).await { + Ok(uid) => current = uid, + // Lost a race against another writer (or the name was taken + // between the lookup and the create) — adopt the winner. + Err(err) => { + let Some(existing) = self.find_existing_folder(¤t, segment).await? else { + return Err(err); + }; + current = existing; + } + } + } + + timer.success(); + Ok(current) + } + + /// Resolve one path segment to a child uid by decrypted name. + async fn find_child_by_name( + &self, + parent_uid: &NodeUid, + name: &str, + ) -> Result> { + let child_uids = self.enumerate_folder_children_node_uids(parent_uid).await?; + if child_uids.is_empty() { + return Ok(None); + } + let children = self.enumerate_nodes_light(&child_uids).await?; + Ok(children + .into_iter() + .find(|child| child.name == name) + .map(|child| child.uid)) + } + + /// As [`find_child_by_name`](Self::find_child_by_name), but demands a folder. + async fn find_existing_folder( + &self, + parent_uid: &NodeUid, + name: &str, + ) -> Result> { + let child_uids = self.enumerate_folder_children_node_uids(parent_uid).await?; + if child_uids.is_empty() { + return Ok(None); + } + let children = self.enumerate_nodes_light(&child_uids).await?; + match children.into_iter().find(|child| child.name == name) { + Some(child) if child.is_folder() => Ok(Some(child.uid)), + Some(child) => Err(ProtonError::invalid_operation(format!( + "Cannot create folder path: {name} exists as a file ({})", + child.uid + ))), + None => Ok(None), + } + } + /// Enumerate the [`NodeUid`]s of the main volume's trashed nodes. /// /// Mirrors C# `VolumeOperations.EnumerateTrashAsync` (renamed to @@ -796,6 +992,16 @@ impl ProtonDriveClient { /// The reader is pinned to the revision that is active now; it does not /// follow later revisions of the same file. pub async fn open_revision(&self, uid: &NodeUid) -> Result { + self.open_revision_inner(uid, None).await + } + + /// Open a reader on `revision_id`, or on the file's active revision when it + /// is `None`. + async fn open_revision_inner( + &self, + uid: &NodeUid, + revision_id: Option<&str>, + ) -> Result { let mut timer = self.telemetry.start("open_revision"); let details = self @@ -818,10 +1024,14 @@ impl ProtonDriveClient { let content_key_packet = BASE64.decode(content_key_packet_b64.trim()).map_err(|e| { ProtonError::invalid_operation(format!("decode content key packet: {e}")) })?; - let revision_id = file - .active_revision - .map(|r| r.id) - .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?; + // An explicit revision wins; otherwise pin whatever is active now. + let revision_id = match revision_id { + Some(id) => id.to_string(), + None => file + .active_revision + .map(|r| r.id) + .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?, + }; let parent_key = self.resolve_parent_key(&uid.volume_id, &link).await?; let node_key = decrypt_link(&parent_key, &link)?.node_key; @@ -834,7 +1044,7 @@ impl ProtonDriveClient { let block_sizes = self .resolve_block_sizes(&node_key, &revision, blocks.len()) - .await; + .await?; timer.success(); Ok(RevisionReader::new( @@ -871,46 +1081,333 @@ impl ProtonDriveClient { Ok(out) } + /// List a file's revision history, newest state first as the server orders it. + /// + /// Mirrors TS `NodesRevisons.iterateRevisions` + /// (`GET v2/volumes/{vid}/files/{lid}/revisions`). Only **active** and + /// **superseded** revisions are returned: the listing also carries drafts — + /// in-flight uploads that have no readable content — and TS filters them out + /// the same way. A file always has exactly one active revision unless an + /// upload is mid-flight. + /// + /// Each revision's extended attributes are decrypted with the file's node + /// key to fill the `claimed_*` fields; a revision whose `XAttr` is absent or + /// unreadable is still returned, with those fields `None`. + pub async fn enumerate_revisions(&self, file_uid: &NodeUid) -> Result> { + let mut timer = self.telemetry.start("enumerate_revisions"); + + let node_key = self.file_node_key(file_uid).await?; + let path = format!( + "v2/volumes/{}/files/{}/revisions", + file_uid.volume_id, file_uid.link_id + ); + let response: RevisionListResponse = self.http.get(&path).await?; + + let mut revisions = Vec::new(); + for item in response.revisions { + if !is_listable_revision_state(item.state) { + continue; + } + revisions.push(self.build_revision(file_uid, &node_key, item).await); + } + + timer.attr("revision_count", revisions.len()); + timer.success(); + Ok(revisions) + } + + /// Fetch one revision's metadata by id. + /// + /// Mirrors TS `NodesRevisons.getRevision`. `NoBlockUrls=true`: this is a + /// metadata read, and asking for block URLs would make the server mint + /// short-lived storage tokens nothing here uses. + pub async fn get_revision( + &self, + file_uid: &NodeUid, + revision_id: &str, + ) -> Result> { + let node_key = self.file_node_key(file_uid).await?; + let path = format!( + "v2/volumes/{}/files/{}/revisions/{}?NoBlockUrls=true", + file_uid.volume_id, file_uid.link_id, revision_id + ); + let response: RevisionMetadataResponse = match self.http.get(&path).await { + Ok(response) => response, + // A revision that is not there is a successful lookup, not an error. + Err(e) if is_not_found(&e) => return Ok(None), + Err(e) => return Err(e), + }; + + Ok(Some( + self.build_revision(file_uid, &node_key, response.revision) + .await, + )) + } + + /// Make an older revision current again. + /// + /// Mirrors TS `NodesRevisons.restoreRevision` + /// (`POST …/revisions/{rid}/restore`). The server does **not** move the + /// pointer in place: restoring creates a *new* active revision with the old + /// content, so the revision that was active before stays in the history as + /// superseded and `revision_id` keeps identifying the one restored *from*. + /// + /// **The restore is asynchronous.** The server answers HTTP 202 and applies + /// it in the background, so a read issued straight afterwards may still see + /// the previous active revision. Poll + /// [`enumerate_revisions`](Self::enumerate_revisions) (or re-read the file) + /// until the new revision appears rather than assuming it is live on return. + /// + /// The file's cached entry is dropped, since its active revision — and with + /// it the size and modification time a caller just read — has changed. + pub async fn restore_revision(&self, file_uid: &NodeUid, revision_id: &str) -> Result<()> { + let mut timer = self.telemetry.start("restore_revision"); + let path = format!( + "v2/volumes/{}/files/{}/revisions/{}/restore", + file_uid.volume_id, file_uid.link_id, revision_id + ); + // An empty *object*, not `()`: serde renders the unit type as `null`, + // which the API rejects with "JSON parsing of request body failed". + let result: Result = + self.http.post(&path, &serde_json::json!({})).await; + + match result { + Ok(_) => {} + // Restore is processed asynchronously: the server answers HTTP 202 + // with an envelope whose code is not `1000`, which the generic + // response parser reports as an error. The request was accepted, so + // treat it as success — the new active revision appears once the + // server finishes, which a subsequent read observes. + Err(ProtonError::Api(e)) if e.http_status == 202 => {} + Err(e) => return Err(e), + } + + self.entities.remove_node(file_uid).await?; + timer.success(); + Ok(()) + } + + /// Permanently delete one revision from a file's history. + /// + /// Mirrors TS `NodesRevisons.deleteRevision`. The content is unrecoverable. + /// Deleting the *active* revision is rejected by the server — restore a + /// different one first, or delete the file itself. + pub async fn delete_revision(&self, file_uid: &NodeUid, revision_id: &str) -> Result<()> { + let mut timer = self.telemetry.start("delete_revision"); + self.delete_revision_by_ids(&file_uid.volume_id, &file_uid.link_id, revision_id) + .await?; + timer.success(); + Ok(()) + } + + /// Open a reader on a *specific* revision rather than the active one. + /// + /// The counterpart of [`open_revision`](Self::open_revision), which always + /// pins the active revision. Use it to read a superseded revision's content + /// — previewing a version before deciding whether to + /// [`restore_revision`](Self::restore_revision) it. + pub async fn open_revision_at( + &self, + file_uid: &NodeUid, + revision_id: &str, + ) -> Result { + self.open_revision_inner(file_uid, Some(revision_id)).await + } + + /// Download a specific revision's plaintext. + /// + /// As with [`download_range`](Self::download_range), the content manifest is + /// not verified — see [`download_file_to`](Self::download_file_to) for the + /// verified whole-file path on the active revision. + pub async fn download_revision( + &self, + file_uid: &NodeUid, + revision_id: &str, + ) -> Result> { + let mut out = Vec::new(); + self.download_revision_to(file_uid, revision_id, &mut out) + .await?; + Ok(out) + } + + /// As [`download_revision`](Self::download_revision), streaming into `writer`. + pub async fn download_revision_to( + &self, + file_uid: &NodeUid, + revision_id: &str, + writer: &mut W, + ) -> Result<()> { + let mut timer = self.telemetry.start("download_revision"); + let reader = self.open_revision_at(file_uid, revision_id).await?; + + // Read block-aligned so each block is fetched and decrypted exactly once. + let mut offset = 0_u64; + let size = reader.size(); + while offset < size { + let chunk = reader.read_at(offset, DEFAULT_BLOCK_SIZE as u64).await?; + if chunk.is_empty() { + break; + } + offset += chunk.len() as u64; + writer + .write_all(&chunk) + .map_err(|e| ProtonError::invalid_operation(format!("write revision: {e}")))?; + } + + timer.success(); + Ok(()) + } + + /// Turn a listing entry into a public [`Revision`], decrypting its extended + /// attributes for the `claimed_*` fields. + /// + /// Infallible by construction: unreadable attributes are logged and leave + /// the claimed fields `None`, because a revision the caller cannot describe + /// in full is still one they may want to restore or delete. + async fn build_revision( + &self, + file_uid: &NodeUid, + node_key: &PrivateKey, + item: RevisionListItemDto, + ) -> Revision { + let common = match &item.extended_attributes { + Some(xattr) => match decrypt_extended_attributes_verified( + &self.account, + node_key, + item.signature_email.as_deref(), + xattr, + ) + .await + { + Ok((attrs, _status)) => attrs.common, + Err(e) => { + tracing::debug!( + revision_id = %item.id, + error = %e, + "revision extended attributes did not decrypt" + ); + None + } + }, + None => None, + }; + + Revision { + file_uid: file_uid.clone(), + revision_id: item.id, + state: RevisionState::from_raw(item.state), + creation_time: item.creation_time, + size_on_storage: item.size, + claimed_size: common.as_ref().and_then(|c| c.size), + claimed_modification_time: common.as_ref().and_then(|c| c.modification_time.clone()), + claimed_sha1: common + .as_ref() + .and_then(|c| c.digests.as_ref()) + .and_then(|d| d.sha1.clone()), + signature_email: item.signature_email.filter(|email| !email.is_empty()), + has_thumbnails: !item.thumbnails.is_empty(), + } + } + + /// Resolve a file's own node key, unlocking it via its parent. + async fn file_node_key(&self, file_uid: &NodeUid) -> Result { + let details = self + .get_link_details(&file_uid.volume_id, std::slice::from_ref(&file_uid.link_id)) + .await?; + let detail = + details.links.into_iter().next().ok_or_else(|| { + ProtonError::invalid_operation(format!("file {file_uid} not found")) + })?; + let parent_key = self + .resolve_parent_key(&file_uid.volume_id, &detail.link) + .await?; + Ok(decrypt_link(&parent_key, &detail.link)?.node_key) + } + /// Plaintext size of each content block, in block order. /// - /// Prefers `Common.BlockSizes` from the revision's extended attributes (the - /// authoritative value written by the uploading client). When that is - /// absent, malformed, or the wrong length, assumes every block is - /// [`DEFAULT_BLOCK_SIZE`] with a final short block sized from `Common.Size`, - /// falling back to all-full blocks when even the total size is unknown. + /// These sizes are not merely descriptive: [`RevisionReader`] accumulates + /// them to derive where each block *starts* in the plaintext. A vector that + /// overstates any block but the last therefore shifts every block after it, + /// and the reader splices those blocks at the wrong offsets — returning + /// full-length reads of the wrong bytes, with no error to notice. See + /// `revision.rs::padded_block_sizes_shift_later_blocks`, which pins that + /// consequence. + /// + /// So the sources are ranked by how much they can be trusted, and the + /// function refuses rather than guesses when none of them holds: + /// + /// 1. `Common.BlockSizes` from the revision's extended attributes — the + /// authoritative value, written by the uploading client. + /// 2. `Common.Size` — the total, from which all-but-last full blocks and a + /// final short block follow by subtraction. Sound for the standard + /// layout the uploader produces. + /// 3. Nothing usable. A single-block file cannot be misaligned (there is no + /// later block to shift), so it is assumed full-size and only its + /// reported length can be wrong. A multi-block file is an error: serving + /// it would mean serving misplaced bytes. async fn resolve_block_sizes( &self, node_key: &PrivateKey, revision: &RevisionDto, block_count: usize, - ) -> Vec { + ) -> Result> { let common = match &revision.extended_attributes { - Some(xattr) => decrypt_extended_attributes_verified( + Some(xattr) => match decrypt_extended_attributes_verified( &self.account, node_key, revision.signature_email.as_deref(), xattr, ) .await - .ok() - .and_then(|(attrs, _status)| attrs.common), + { + Ok((attrs, _status)) => attrs.common, + // Distinguished from "absent" on purpose: xattrs that are + // present but unreadable mean either a key/signature problem or + // a schema we do not know, and both are worth seeing in a log + // before the size falls back to inference. + Err(e) => { + tracing::debug!(error = %e, "revision extended attributes did not decrypt"); + None + } + }, None => None, }; + let block = DEFAULT_BLOCK_SIZE as u64; + if let Some(sizes) = common.as_ref().and_then(|c| c.block_sizes.as_ref()) && sizes.len() == block_count { - return sizes.iter().map(|&n| n.max(0) as u64).collect(); + // A non-positive entry is as corrupting as a padded one — a + // zero-length block in the middle shifts everything after it — so a + // malformed vector is rejected outright rather than clamped. + if let Some(bad) = sizes.iter().position(|&n| n <= 0) { + return Err(ProtonError::invalid_operation(format!( + "revision {} records a non-positive size for block {bad}", + revision.id + ))); + } + return Ok(sizes.iter().map(|&n| n as u64).collect()); } - let block = DEFAULT_BLOCK_SIZE as u64; if let Some(total) = common.and_then(|c| c.size).filter(|&n| n >= 0) { let total = total as u64; - return (0..block_count) + return Ok((0..block_count) .map(|i| total.saturating_sub(block * i as u64).min(block)) - .collect(); + .collect()); } - vec![block; block_count] + + if block_count <= 1 { + return Ok(vec![block; block_count]); + } + + Err(ProtonError::invalid_operation(format!( + "revision {} has {block_count} blocks but no usable size information \ + (no BlockSizes, no Size); refusing to guess, since a wrong block \ + layout would serve misaligned content", + revision.id + ))) } /// Download and decrypt a file's thumbnail of the given type, if it has one. @@ -1234,11 +1731,81 @@ impl ProtonDriveClient { thumbnails: Vec, last_modification_time: Option, aead: bool, + ) -> Result { + self.upload_file_from_inner( + parent_uid, + name, + media_type, + reader, + intended_size, + thumbnails, + last_modification_time, + aead, + false, + ) + .await + } + + /// Like [`upload_file_from`](Self::upload_file_from) but recovers a name + /// collision with *any* client's unsealed draft, not only our own: if a draft + /// of this name is already open, delete it and retry. Use only where the local + /// copy is authoritative and a leftover draft is expected to be a stale + /// interrupted upload — e.g. a mirror-sync push resuming across a daemon + /// restart, which rotates the client uid so our own prior draft would otherwise + /// look like a stranger's. A committed file of the same name is still a hard + /// conflict and is never overwritten. Mirrors C# `NewFileDraftProvider` with + /// `overrideExistingDraftByOtherClient: true`. + #[allow(clippy::too_many_arguments)] + pub async fn upload_file_replacing_draft_from( + &self, + parent_uid: &NodeUid, + name: &str, + media_type: &str, + reader: R, + intended_size: i64, + thumbnails: Vec, + last_modification_time: Option, + aead: bool, + ) -> Result { + self.upload_file_from_inner( + parent_uid, + name, + media_type, + reader, + intended_size, + thumbnails, + last_modification_time, + aead, + true, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn upload_file_from_inner( + &self, + parent_uid: &NodeUid, + name: &str, + media_type: &str, + reader: R, + intended_size: i64, + thumbnails: Vec, + last_modification_time: Option, + aead: bool, + override_existing_draft: bool, ) -> Result { let mut timer = self.telemetry.start("upload_file"); timer.attr("aead", aead); let draft = self - .create_file_draft(parent_uid, name, media_type, intended_size, aead, false) + .create_file_draft(FileDraftSpec { + parent_uid, + name, + media_type, + intended_upload_size: intended_size, + aead, + for_photos: false, + override_existing_draft, + }) .await?; let file_uid = NodeUid::new(draft.volume_id.clone(), draft.link_id.clone()); @@ -1328,7 +1895,15 @@ impl ProtonDriveClient { .expect("ensure_photos populated the photos root"); let draft = self - .create_file_draft(&parent_uid, name, media_type, intended_size, aead, true) + .create_file_draft(FileDraftSpec { + parent_uid: &parent_uid, + name, + media_type, + intended_upload_size: intended_size, + aead, + for_photos: true, + override_existing_draft: false, + }) .await?; let file_uid = NodeUid::new(draft.volume_id.clone(), draft.link_id.clone()); @@ -2958,15 +3533,16 @@ impl ProtonDriveClient { /// Create a fresh-file draft: generate a node key + content key, encrypt the /// name/passphrase/content-key-packet to the parent, and POST the draft. /// Mirrors C# `NewFileDraftProvider`. - async fn create_file_draft( - &self, - parent_uid: &NodeUid, - name: &str, - media_type: &str, - intended_upload_size: i64, - aead: bool, - for_photos: bool, - ) -> Result { + async fn create_file_draft(&self, spec: FileDraftSpec<'_>) -> Result { + let FileDraftSpec { + parent_uid, + name, + media_type, + intended_upload_size, + aead, + for_photos, + override_existing_draft, + } = spec; let volume_id = parent_uid.volume_id.clone(); // Resolve the parent folder key + hash key and the membership address. @@ -3014,7 +3590,66 @@ impl ProtonDriveClient { }; let create_path = format!("v2/volumes/{volume_id}/files"); - let created: FileCreationResponse = self.http.post(&create_path, &create_request).await?; + let client_uid = self.http.session_id().to_string(); + + // An upload interrupted after the draft node was created but before it was + // sealed (e.g. a transport error on the blocks endpoint) leaves an unsealed + // draft file of this name in the parent, and every later attempt to create + // the same name then `AlreadyExists` (422) forever. When the conflicting + // link is an unsealed draft — a `ConflictDraftRevisionID` with no committed + // `ConflictRevisionID` — delete that draft node and retry. By default only + // our own client's draft (matching client uid) is cleared; with + // `override_existing_draft` any client's stale draft is (a draft holds no + // committed content, so this only forfeits an in-flight upload that will + // re-create on its next attempt — needed to recover across a daemon restart, + // which rotates our client uid). A committed file of this name is never + // touched: that is a real name collision the caller must resolve. Mirrors C# + // `NewFileDraftProvider.CreateDraftAsync` + `overrideExistingDraftByOtherClient`. + const MAX_DRAFT_ATTEMPTS: usize = 3; + let mut created: Option = None; + for _ in 0..MAX_DRAFT_ATTEMPTS { + match self + .http + .post::<_, FileCreationResponse>(&create_path, &create_request) + .await + { + Ok(response) => { + created = Some(response); + break; + } + Err(ProtonError::Api(e)) if e.code == ResponseCode::AlreadyExists => { + let conflict = e + .details + .as_ref() + .and_then(|d| serde_json::from_value::(d.clone()).ok()); + match conflict { + Some(RevisionConflict { + link_id: Some(link_id), + revision_id: None, + draft_revision_id: Some(_), + draft_client_uid, + }) if override_existing_draft + || draft_client_uid.as_deref() == Some(client_uid.as_str()) => + { + tracing::warn!( + %volume_id, %link_id, override_existing_draft, + "deleting stale draft file node, then retrying create" + ); + self.delete_draft_nodes(&volume_id, std::slice::from_ref(&link_id)) + .await?; + } + // A committed file of this name, or a draft owned by another + // client we were not told to override — surface the original + // conflict unchanged. + _ => return Err(ProtonError::Api(e)), + } + } + Err(other) => return Err(other), + } + } + let created = created.ok_or_else(|| { + ProtonError::invalid_operation("file draft creation kept conflicting after retries") + })?; Ok(RevisionDraft { volume_id, @@ -3107,6 +3742,7 @@ impl ProtonDriveClient { let Some(RevisionConflict { draft_revision_id: Some(draft_id), draft_client_uid: Some(owner), + .. }) = conflict else { // No detail, or a draft we can't attribute — do not delete @@ -3121,7 +3757,7 @@ impl ProtonDriveClient { )); } tracing::warn!(%file_uid, draft_id, "deleting our stale draft revision, then retrying"); - self.delete_revision(&volume_id, &file_uid.link_id, &draft_id) + self.delete_revision_by_ids(&volume_id, &file_uid.link_id, &draft_id) .await?; } Err(other) => return Err(other), @@ -3148,7 +3784,7 @@ impl ProtonDriveClient { /// Used to clear an abandoned draft revision left by an interrupted upload; /// the server permits a writer to delete its own drafts. Mirrors C# /// `FilesApiClient.DeleteRevisionAsync`. - async fn delete_revision( + async fn delete_revision_by_ids( &self, volume_id: &VolumeId, link_id: &LinkId, @@ -3159,6 +3795,35 @@ impl ProtonDriveClient { Ok(()) } + /// Permanently delete unsealed draft file nodes by link id via + /// `POST v2/volumes/{vid}/delete_multiple`. Unlike [`delete_nodes`], this does + /// not route through the trash — a never-sealed draft is not trashed, so the + /// trash-delete path does not apply. A per-link `DoesNotExist` is tolerated + /// (the draft may already be gone). Mirrors C# `LinksApiClient.DeleteMultipleAsync` + /// as used by `NewFileDraftProvider`. + async fn delete_draft_nodes(&self, volume_id: &VolumeId, link_ids: &[LinkId]) -> Result<()> { + let path = format!("v2/volumes/{volume_id}/delete_multiple"); + let body = MultipleLinksRequest { link_ids }; + let response: AggregateLinksResponse = self.http.post(&path, &body).await?; + let failures: Vec = response + .responses + .iter() + .filter(|pair| { + !pair.response.is_success() && pair.response.code != ResponseCode::DoesNotExist + }) + .map(|pair| format!("{} ({:?})", pair.link_id, pair.response.code)) + .collect(); + if failures.is_empty() { + Ok(()) + } else { + Err(ProtonError::invalid_operation(format!( + "delete draft failed for {} link(s): {}", + failures.len(), + failures.join(", ") + ))) + } + } + /// Encrypt, sign, verify and upload every content block of a draft revision, /// accumulating the content manifest and extended-attribute metadata. /// @@ -3591,7 +4256,7 @@ impl ProtonDriveClient { // the volume, then re-read it through the normal path to populate caches. let response: MyFilesShareResponse = match self.http.get("v2/shares/my-files").await { Ok(response) => response, - Err(e) if is_my_files_missing(&e) => { + Err(e) if is_not_found(&e) => { self.create_volume().await?; self.http.get("v2/shares/my-files").await? } @@ -4411,7 +5076,26 @@ fn read_full_block(reader: &mut R, buf: &mut [u8]) -> Result { /// yet (and so one must be created). C# catches [`ResponseCode::DoesNotExist`]; /// in practice a fresh account's lookup also surfaces as a bare HTTP 404, so /// both are treated as "missing". -fn is_my_files_missing(error: &ProtonError) -> bool { +/// Whether a listed revision is one a history view should show. +/// +/// Only active (1) and superseded (2) qualify — TS +/// `NodeAPIService.getRevisions` filters the same pair. Drafts (0) are +/// in-flight uploads with no readable content, and an absent state cannot be +/// vouched for. +/// +/// This deliberately inspects the **wire** value rather than mapping through +/// [`RevisionState::from_raw`] first: that mapping folds draft and unknown into +/// `Active`, which is correct for a link's *active* revision (where the state is +/// often omitted) but would silently promote a draft here. +fn is_listable_revision_state(state: Option) -> bool { + matches!(state, Some(1) | Some(2)) +} + +/// Whether an API error means the requested entity simply is not there. +/// +/// Both `DoesNotExist` in the envelope and a bare HTTP 404 occur in the wild for +/// the same condition, depending on the endpoint. +fn is_not_found(error: &ProtonError) -> bool { matches!( error, ProtonError::Api(e) @@ -4478,16 +5162,24 @@ fn external_invitation_from_dto( /// Split a public-link URL (`https://drive.proton.me/urls/{token}#{password}`) /// into its token and password. Mirrors JS `getTokenAndPasswordFromUrl`: the /// token is the last path segment, the password the fragment after `#`. -fn parse_public_link_url(url: &str) -> Result<(String, String)> { +/// Split a public share URL into its token and secret fragment. +/// +/// The format is `https://drive.proton.me/urls/{token}#{password}` +/// (TS `getTokenAndPasswordFromUrl`). Both halves are required: a URL with no +/// fragment carries no secret and opens nothing. +/// +/// The token is taken as the segment *after* `/urls/` rather than as the last +/// path segment, so a URL with an empty token (`…/urls/#password`) is rejected +/// instead of silently yielding the container segment `"urls"` as the token. +pub(crate) fn parse_public_link_url(url: &str) -> Result<(String, String)> { + const CONTAINER: &str = "/urls/"; let invalid = || ProtonError::invalid_operation("invalid public link url"); + let (before_hash, password) = url.split_once('#').ok_or_else(invalid)?; - let token = before_hash - .trim_end_matches('/') - .rsplit('/') - .next() - .filter(|t| !t.is_empty()) - .ok_or_else(invalid)?; - if password.is_empty() { + let (_, after_container) = before_hash.rsplit_once(CONTAINER).ok_or_else(invalid)?; + let token = after_container.trim_end_matches('/'); + + if token.is_empty() || token.contains('/') || password.is_empty() { return Err(invalid()); } Ok((token.to_string(), password.to_string())) @@ -4527,6 +5219,28 @@ fn epoch_to_iso8601(secs: i64) -> String { format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") } +/// Split a caller-supplied path into its non-empty segments. +/// +/// Leading, trailing, and repeated separators are ignored, so `/a/b/`, `a/b`, +/// and `//a//b` all yield `["a", "b"]`, and `""` / `"/"` yield nothing (the +/// root itself). +fn path_segments(path: &str) -> impl Iterator { + path.split('/').filter(|segment| !segment.is_empty()) +} + +/// Join node names into an absolute path. The empty chain is the root, `/`. +fn join_node_path<'a>(names: impl Iterator) -> String { + let mut path = String::new(); + for name in names { + path.push('/'); + path.push_str(name); + } + if path.is_empty() { + path.push('/'); + } + path +} + fn group_by_volume(uids: &[NodeUid]) -> Vec<(VolumeId, Vec)> { let mut groups: Vec<(VolumeId, Vec)> = Vec::new(); for uid in uids { @@ -4650,11 +5364,52 @@ fn verification_token(code: &[u8], ciphertext: &[u8]) -> Vec { mod tests { use super::{ DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, alternate_names, epoch_to_iso8601, - to_drive_event, + is_listable_revision_state, join_node_path, path_segments, to_drive_event, }; use crate::dtos::{VolumeEventDto, VolumeEventLinkDto}; + use crate::node::RevisionState; use proton_sdk::ids::{DriveEventId, LinkId, VolumeId}; + #[test] + fn only_sealed_revisions_are_listable() { + assert!(is_listable_revision_state(Some(1)), "active"); + assert!(is_listable_revision_state(Some(2)), "superseded"); + // A draft has no readable content, and `RevisionState::from_raw` would + // map it to Active — the exact trap this predicate exists to avoid. + assert!(!is_listable_revision_state(Some(0)), "draft"); + assert!(!is_listable_revision_state(None), "unstated"); + assert!(!is_listable_revision_state(Some(99)), "unknown"); + assert_eq!(RevisionState::from_raw(Some(0)), RevisionState::Active); + } + + #[test] + fn path_segments_ignores_separator_noise() { + let split = |p| path_segments(p).collect::>(); + assert_eq!(split("a/b"), ["a", "b"]); + assert_eq!(split("/a/b"), ["a", "b"]); + assert_eq!(split("/a/b/"), ["a", "b"]); + assert_eq!(split("//a///b//"), ["a", "b"]); + // The root is reachable as any of these, and must not yield a segment. + assert!(split("").is_empty()); + assert!(split("/").is_empty()); + assert!(split("///").is_empty()); + } + + #[test] + fn join_node_path_round_trips_through_path_segments() { + // A name may hold any character but `/`, so joining then re-splitting + // must recover exactly the names `get_node_by_path` will match on. + let names = ["Documents", "a b", "weird: name?", "trailing "]; + let path = join_node_path(names.iter().copied()); + assert_eq!(path, "/Documents/a b/weird: name?/trailing "); + assert_eq!(path_segments(&path).collect::>(), names); + } + + #[test] + fn join_node_path_of_the_root_is_a_bare_slash() { + assert_eq!(join_node_path(std::iter::empty()), "/"); + } + #[test] fn folder_key_cache_is_bounded() { // The LRU that plan #9 introduced must actually carry the cap, or the diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 84d1d30..b5f05e2 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -692,11 +692,20 @@ pub struct RevisionCreationIdentity { pub revision_id: String, } -/// `Details` object attached to an `AlreadyExists` (2500) error from -/// `POST .../revisions`: the server names the draft revision already open on the -/// link, and which client left it there. Mirrors C# `RevisionConflict`. +/// `Details` object attached to an `AlreadyExists` (2500) error from a file +/// creation (`POST .../files`) or a revision creation (`POST .../revisions`): +/// the server names the conflicting link, its committed revision (if any) and +/// the draft revision already open on it, plus which client left that draft. +/// Mirrors C# `RevisionConflict`. #[derive(Debug, Deserialize)] pub struct RevisionConflict { + /// The link that already carries this name (set on file-creation conflicts). + #[serde(rename = "ConflictLinkID", default)] + pub link_id: Option, + /// The conflicting link's committed (active) revision, if it has one. A + /// value here means a real, sealed file — not a recoverable stale draft. + #[serde(rename = "ConflictRevisionID", default)] + pub revision_id: Option, #[serde(rename = "ConflictDraftRevisionID", default)] pub draft_revision_id: Option, #[serde(rename = "ConflictDraftClientUID", default)] @@ -1573,3 +1582,149 @@ pub struct ShareUrlDto { #[serde(rename = "NumAccesses", default)] pub num_accesses: i64, } + +// --------------------------------------------------------------------------- +// Public-link session (consuming someone else's shared link) +// --------------------------------------------------------------------------- + +/// `GET urls/{token}/info` — opens the SRP handshake for a public link. +/// +/// Callable without any session (TS `SharingPublicSessionAPIService.initPublicLinkSession`). +#[derive(Debug, Clone, Deserialize)] +pub struct PublicLinkInfoResponse { + /// SRP auth version. + #[serde(rename = "Version", default)] + pub version: i32, + /// The cleartext-signed SRP modulus. + #[serde(rename = "Modulus", default)] + pub modulus: String, + /// base64 server ephemeral `B`. + #[serde(rename = "ServerEphemeral", default)] + pub server_ephemeral: String, + /// base64 SRP salt for the URL password. + #[serde(rename = "UrlPasswordSalt", default)] + pub url_password_salt: String, + #[serde(rename = "SRPSession", default)] + pub srp_session: String, + /// Bit 0 set = the link also needs a custom password. `0` or `1` alone marks + /// a legacy link the SDK no longer supports. + #[serde(rename = "Flags", default)] + pub flags: i32, + /// Non-zero for vendor links (Proton Docs and friends) that belong to + /// another app rather than to Drive. + #[serde(rename = "VendorType", default)] + pub vendor_type: i32, +} + +impl PublicLinkInfoResponse { + /// Whether the link needs a custom password on top of the URL fragment. + pub fn is_custom_password_protected(&self) -> bool { + self.flags & 1 == 1 + } + + /// Whether this is a legacy link, which neither this SDK nor the upstream + /// TypeScript SDK can open (TS: `Flags === 0 || Flags === 1`). + pub fn is_legacy(&self) -> bool { + self.flags == 0 || self.flags == 1 + } +} + +/// `POST urls/{token}/auth` — completes the SRP handshake. +#[derive(Debug, Serialize)] +pub struct PublicLinkAuthRequest { + #[serde(rename = "ClientProof")] + pub client_proof: String, + #[serde(rename = "ClientEphemeral")] + pub client_ephemeral: String, + #[serde(rename = "SRPSession")] + pub srp_session: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PublicLinkAuthResponse { + /// base64 `M2`, to be checked against the locally computed expectation. + #[serde(rename = "ServerProof", default)] + pub server_proof: String, + /// The session id for subsequent requests (`x-pm-uid`). + #[serde(rename = "UID", default)] + pub uid: String, + /// Bearer token. Absent when the caller already has a Proton session that + /// the server accepted instead of minting an anonymous one. + #[serde(rename = "AccessToken", default)] + pub access_token: Option, + #[serde(rename = "Share")] + pub share: PublicLinkShareDto, +} + +/// The link's share crypto, returned by the auth handshake. +#[derive(Debug, Clone, Deserialize)] +pub struct PublicLinkShareDto { + #[serde(rename = "VolumeID")] + pub volume_id: VolumeId, + /// The link id of the shared node — the root of what the visitor can see. + #[serde(rename = "LinkID")] + pub link_id: LinkId, + /// base64 bcrypt salt for deriving the key password from the link password. + #[serde(rename = "SharePasswordSalt", default)] + pub share_password_salt: String, + /// The armored share private key. + #[serde(rename = "ShareKey", default)] + pub share_key: String, + /// The share key's passphrase, symmetrically encrypted under the derived + /// key password. + #[serde(rename = "SharePassphrase", default)] + pub share_passphrase: String, + #[serde(rename = "PublicPermissions", default)] + pub public_permissions: Option, +} + +// --------------------------------------------------------------------------- +// Revision history +// --------------------------------------------------------------------------- + +/// `GET v2/volumes/{vid}/files/{lid}/revisions` — a file's revision history. +#[derive(Debug, Deserialize)] +pub struct RevisionListResponse { + #[serde(rename = "Revisions", default)] + pub revisions: Vec, +} + +/// `GET …/revisions/{rid}?NoBlockUrls=true` — one revision's metadata. +/// +/// Deliberately reuses [`RevisionListItemDto`], which has no `Blocks` field: with +/// `NoBlockUrls` the server still emits a `Blocks` array but with null `BareURL` +/// and `Token`, which [`BlockDto`] rejects. Ignoring the array outright is both +/// correct for a metadata read and stops a null from breaking it. +#[derive(Debug, Deserialize)] +pub struct RevisionMetadataResponse { + #[serde(rename = "Revision")] + pub revision: RevisionListItemDto, +} + +/// One entry of a revision listing. +/// +/// Distinct from [`RevisionDto`], which is the *single-revision* response and +/// carries the block table. The listing omits blocks but adds `State` and +/// `Size`, since a history view needs to tell active from superseded. +#[derive(Debug, Deserialize)] +pub struct RevisionListItemDto { + #[serde(rename = "ID")] + pub id: String, + /// Wire `ApiRevisionState`: 0 draft, 1 active, 2 obsolete/superseded. + #[serde(rename = "State", default)] + pub state: Option, + #[serde(rename = "CreateTime", default)] + pub creation_time: i64, + /// Encrypted size on cloud storage. + #[serde(rename = "Size", default)] + pub size: i64, + #[serde(rename = "ManifestSignature", default)] + pub manifest_signature: Option, + /// Email of the signer; empty/absent means the node key signed. + #[serde(rename = "SignatureEmail", default)] + pub signature_email: Option, + #[serde(rename = "XAttr", default)] + pub extended_attributes: Option, + #[serde(rename = "Thumbnails", default)] + pub thumbnails: Vec, +} diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index 1f2bc5a..9d56833 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -41,6 +41,7 @@ mod event_manager; mod events; mod node; mod photos; +mod public_link; mod revision; mod sharing; @@ -56,7 +57,8 @@ pub use node::{Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient}; pub use proton_sdk::account::KeySalt; pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository}; -pub use revision::RevisionReader; +pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo}; +pub use revision::{Revision, RevisionReader}; pub use sharing::{ Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole, PublicLink, ShareInvitation, ShareMember, diff --git a/crates/proton-drive-rs/src/public_link.rs b/crates/proton-drive-rs/src/public_link.rs new file mode 100644 index 0000000..81a6f66 --- /dev/null +++ b/crates/proton-drive-rs/src/public_link.rs @@ -0,0 +1,646 @@ +//! Consuming a public share link — the read side of someone *else's* link. +//! +//! Ported from the TypeScript SDK (`internal/sharingPublic`, exposed as +//! `ProtonDrivePublicLinkClient`); the C# SDK has no equivalent. +//! [`ProtonDriveClient::create_public_link`](crate::ProtonDriveClient::create_public_link) +//! is the mirror image of this module — it *mints* the link this opens. +//! +//! A visitor needs no Proton account. The flow is: +//! +//! 1. `GET urls/{token}/info` (unauthenticated) opens an SRP handshake and says +//! whether a custom password is also required. +//! 2. `POST urls/{token}/auth` completes it, returning an anonymous session +//! (`x-pm-uid` + bearer) and the link's encrypted share key. +//! 3. The share key is unlocked with the link password: bcrypt-derive the key +//! password from `SharePasswordSalt`, symmetrically decrypt `SharePassphrase` +//! with it, then unlock `ShareKey` with the recovered passphrase. +//! +//! After that the *ordinary* Drive endpoints serve the shared subtree — only the +//! session headers and the root key differ — so node listing and download reuse +//! the same wire shapes as [`crate::ProtonDriveClient`]. +//! +//! ## Verification +//! +//! Signature verification is deliberately absent here. Verifying authorship +//! needs the owner's public keys from `core/v4/keys/all`, which an anonymous +//! visitor cannot read; the TypeScript SDK disables the same fallback +//! (`SharingPublicNodesCryptoService.allowContentKeyPacketFallbackVerification = +//! false`). Nodes come back with a default [`NodeVerification`], i.e. nothing +//! claimed, rather than a verification that silently passed. + +use std::collections::HashMap; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; + +use proton_sdk::config::ProtonClientConfiguration; +use proton_sdk::crypto::{ + ContentKey, DEFAULT_BIT_LENGTH, PrivateKey, decrypt_armored_with_password, + derive_key_passphrase, generate_proofs, +}; +use proton_sdk::error::{ProtonError, Result}; +use proton_sdk::http::{ApiHttpClient, Tokens, get_unauthenticated}; +use proton_sdk::ids::{LinkId, NodeUid, SessionId, VolumeId}; + +use crate::client::parse_public_link_url; +use crate::crypto::decrypt_link; +use crate::dtos::{ + FileDto, FolderChildrenResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, + LinkType, PublicLinkAuthRequest, PublicLinkAuthResponse, PublicLinkInfoResponse, + RevisionResponse, +}; +use crate::node::{Node, NodeKind, RevisionState}; +use crate::sharing::MemberRole; + +/// Link metadata readable before authenticating, from `GET urls/{token}/info`. +/// +/// Fetch it with [`ProtonDrivePublicLinkClient::info`] to learn whether the user +/// must also be prompted for a custom password before +/// [`open`](ProtonDrivePublicLinkClient::open) can succeed. +#[derive(Debug, Clone)] +pub struct PublicLinkInfo { + /// The link needs a custom password in addition to the URL fragment. + pub is_custom_password_protected: bool, + /// A legacy link, which neither this SDK nor the upstream TypeScript SDK + /// can open. + pub is_legacy: bool, + /// Non-zero for links owned by another Proton app (Docs and friends) rather + /// than by Drive. + pub vendor_type: i32, +} + +/// An authenticated, anonymous session against one public link. +/// +/// Mirrors TS `SharingPublicLinkSession`. Holds the link password so an expired +/// session can be re-established by replaying the handshake. +struct PublicLinkSession { + config: ProtonClientConfiguration, + token: String, + /// URL fragment password concatenated with any custom password — the TS SDK + /// treats the pair as one secret throughout. + password: String, +} + +/// What one completed handshake yields. +struct AuthOutcome { + http: ApiHttpClient, + share_key: PrivateKey, + root_uid: NodeUid, + public_role: MemberRole, +} + +impl PublicLinkSession { + async fn info(&self) -> Result { + get_unauthenticated(&self.config, &format!("drive/urls/{}/info", self.token)).await + } + + /// Run the SRP handshake and unlock the share key. + async fn auth(&self) -> Result { + let info = self.info().await?; + if info.is_legacy() { + return Err(ProtonError::invalid_operation( + "this public link uses a legacy format that is no longer supported", + )); + } + + let proofs = generate_proofs( + info.version, + self.password.as_bytes(), + &decode_base64(&info.url_password_salt, "UrlPasswordSalt")?, + &info.modulus, + &decode_base64(&info.server_ephemeral, "ServerEphemeral")?, + DEFAULT_BIT_LENGTH, + )?; + + let request = PublicLinkAuthRequest { + client_proof: BASE64.encode(&proofs.client_proof), + client_ephemeral: BASE64.encode(&proofs.client_ephemeral), + srp_session: info.srp_session, + }; + let response: PublicLinkAuthResponse = proton_sdk::http::post_unauthenticated( + &self.config, + &format!("drive/urls/{}/auth", self.token), + &request, + ) + .await?; + + // The server proves it knows the verifier. A mismatch means we are not + // talking to the party that holds the link's secret — refuse before + // handing it any further requests. + if response.server_proof != BASE64.encode(&proofs.expected_server_proof) { + return Err(ProtonError::invalid_operation( + "public link server proof did not match; refusing the session", + )); + } + + let share_key = self.unlock_share_key(&response)?; + + // An absent AccessToken means the server accepted an existing Proton + // session instead of minting an anonymous one. We have no such session + // to fall back on, so it is an error rather than a silent no-auth state. + let access_token = response.access_token.clone().ok_or_else(|| { + ProtonError::invalid_operation("public link auth returned no access token") + })?; + + let http = ApiHttpClient::new( + self.config.clone(), + SessionId::from(response.uid.clone()), + Tokens { + access_token, + // An anonymous public-link session cannot be refreshed; it is + // re-established by replaying the handshake instead. + refresh_token: String::new(), + }, + )? + // Public-link data requests take the `drive/unauth/` prefix, not the + // `drive/` one an authenticated client uses (TS `getUnauthEndpoint`). + // The endpoints and payloads are otherwise identical; only this prefix + // tells the API to accept a session that has no `full`/`nondelinquent` + // scope. Sending them to `drive/` returns 403 `MissingScopes`. + // The session routes (`drive/urls/{token}/…`) are exempt — they run + // unauthenticated, before this client exists. + .with_base_route("drive/unauth/"); + + Ok(AuthOutcome { + http, + share_key, + root_uid: NodeUid::new( + response.share.volume_id.clone(), + response.share.link_id.clone(), + ), + public_role: MemberRole::from_permissions(response.share.public_permissions), + }) + } + + /// Recover the share private key from the link password. + /// + /// TS `SharingPublicSessionManager.decryptShareKey` → + /// `DriveCrypto.decryptKeyWithSrpPassword`. The salt is a bcrypt salt, not + /// the SRP one: the same `derive_key_passphrase` the create side used to + /// wrap this passphrase. + fn unlock_share_key(&self, response: &PublicLinkAuthResponse) -> Result { + let salt = decode_base64(&response.share.share_password_salt, "SharePasswordSalt")?; + let key_password = derive_key_passphrase(self.password.as_bytes(), &salt)?; + let passphrase = + decrypt_armored_with_password(&response.share.share_passphrase, &key_password)?; + Ok(PrivateKey::from_armored( + &response.share.share_key, + &passphrase, + )?) + } +} + +/// Read access to one public share link, as a visitor. +/// +/// Obtained from [`open`](Self::open). The client holds an anonymous session and +/// the link's share key; every operation is scoped to the shared subtree. +pub struct ProtonDrivePublicLinkClient { + session: PublicLinkSession, + http: ApiHttpClient, + /// The link's share key — the parent key of the shared root node. + share_key: PrivateKey, + root_uid: NodeUid, + public_role: MemberRole, +} + +impl ProtonDrivePublicLinkClient { + /// Read a link's public metadata without opening it. + /// + /// Use this to discover [`is_custom_password_protected`](PublicLinkInfo::is_custom_password_protected) + /// before prompting for a password. `url` is the full share URL including + /// its `#password` fragment. + pub async fn info(config: ProtonClientConfiguration, url: &str) -> Result { + let (token, password) = parse_public_link_url(url)?; + let session = PublicLinkSession { + config, + token, + password, + }; + let raw = session.info().await?; + Ok(PublicLinkInfo { + is_custom_password_protected: raw.is_custom_password_protected(), + is_legacy: raw.is_legacy(), + vendor_type: raw.vendor_type, + }) + } + + /// Authenticate against a public link and open it for reading. + /// + /// `url` is the full share URL including its `#password` fragment (the form + /// [`PublicLink::url`](crate::PublicLink::url) hands back). + /// `custom_password` is required exactly when + /// [`info`](Self::info) reports the link is custom-password protected; + /// supplying the wrong one fails the SRP handshake, not the decryption. + pub async fn open( + config: ProtonClientConfiguration, + url: &str, + custom_password: Option<&str>, + ) -> Result { + let (token, url_password) = parse_public_link_url(url)?; + let password = match custom_password { + Some(custom) if !custom.is_empty() => format!("{url_password}{custom}"), + _ => url_password, + }; + + let session = PublicLinkSession { + config, + token, + password, + }; + let auth = session.auth().await?; + + Ok(Self { + session, + http: auth.http, + share_key: auth.share_key, + root_uid: auth.root_uid, + public_role: auth.public_role, + }) + } + + /// The role the link grants its visitors — [`MemberRole::Viewer`] for a + /// read-only link, [`MemberRole::Editor`] for one that permits uploads. + pub fn public_role(&self) -> MemberRole { + self.public_role + } + + /// The uid of the shared node: the folder or file the link points at. + pub fn root_uid(&self) -> &NodeUid { + &self.root_uid + } + + /// The shared node itself, decrypted. + /// + /// A link may point at a single file as easily as at a folder — check + /// [`Node::is_folder`] before listing children. + pub async fn get_root_node(&self) -> Result { + self.get_node(&self.root_uid.clone()) + .await? + .ok_or_else(|| ProtonError::invalid_operation("the shared node no longer exists")) + } + + /// Fetch one node inside the shared subtree. + /// + /// Returns `Ok(None)` when the node does not exist. Nodes outside the shared + /// subtree are not readable — the server rejects them, and their parent key + /// is not derivable from the share key regardless. + pub async fn get_node(&self, uid: &NodeUid) -> Result> { + let mut nodes = self.enumerate_nodes(std::slice::from_ref(uid)).await?; + Ok(nodes.pop()) + } + + /// Enumerate the [`NodeUid`]s of a shared folder's children. + /// + /// Same paging contract as + /// [`ProtonDriveClient::enumerate_folder_children_node_uids`](crate::ProtonDriveClient::enumerate_folder_children_node_uids): + /// uids only, materialized on demand via [`enumerate_nodes`](Self::enumerate_nodes). + pub async fn enumerate_folder_children_node_uids( + &self, + folder_uid: &NodeUid, + ) -> Result> { + let mut uids = Vec::new(); + let mut anchor: Option = None; + + loop { + let mut path = format!( + "v2/volumes/{}/folders/{}/children", + folder_uid.volume_id, folder_uid.link_id + ); + if let Some(anchor_id) = &anchor { + path.push_str(&format!("?AnchorID={anchor_id}")); + } + + let page: FolderChildrenResponse = self.http.get(&path).await?; + if page.link_ids.is_empty() { + break; + } + for link_id in page.link_ids { + uids.push(NodeUid::new(folder_uid.volume_id.clone(), link_id)); + } + if !page.more_results_exist { + break; + } + anchor = page.anchor_id; + if anchor.is_none() { + break; + } + } + + Ok(uids) + } + + /// Fetch decrypted metadata for many nodes in the shared subtree. + /// + /// A node that does not exist is omitted; one that fails to decrypt is + /// logged and skipped, so the result may be shorter than `uids` — the same + /// partial-listing behavior as the authenticated client. + pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result> { + if uids.is_empty() { + return Ok(Vec::new()); + } + + let mut nodes = Vec::with_capacity(uids.len()); + for (volume_id, link_ids) in group_by_volume(uids) { + let response: LinkDetailsResponse = self + .http + .post( + &format!("v2/volumes/{volume_id}/links"), + &LinkDetailsRequest { + link_ids: &link_ids, + }, + ) + .await?; + + for details in &response.links { + let parent_key = match self.resolve_parent_key(&volume_id, details).await { + Ok(key) => key, + Err(e) => { + tracing::warn!(link_id = %details.link.id, "skipping node: {e}"); + continue; + } + }; + match self.build_node(&volume_id, details, &parent_key) { + Ok(node) => nodes.push(node), + Err(e) => { + tracing::warn!(link_id = %details.link.id, "skipping node: {e}") + } + } + } + } + + Ok(nodes) + } + + /// Download a shared file's active revision. + /// + /// Unlike the authenticated client's + /// [`download_file`](crate::ProtonDriveClient::download_file), the content + /// manifest is **not** verified: doing so needs the uploader's address keys, + /// which an anonymous visitor cannot fetch. The bytes are decrypted with the + /// file's content key, so they are still authentic to whoever holds the node + /// key — but nothing here attests *who* that was. + pub async fn download_file(&self, uid: &NodeUid) -> Result> { + let mut out = Vec::new(); + self.download_file_to(uid, &mut out).await?; + Ok(out) + } + + /// As [`download_file`](Self::download_file), streaming into `writer`. + pub async fn download_file_to( + &self, + uid: &NodeUid, + writer: &mut W, + ) -> Result<()> { + let details = self.link_details(uid).await?; + + let revision_id = file_properties(&details) + .and_then(|file| file.active_revision.as_ref()) + .map(|revision| revision.id.clone()) + .ok_or_else(|| { + ProtonError::invalid_operation("the shared file has no active revision") + })?; + + let parent_key = self.resolve_parent_key(&uid.volume_id, &details).await?; + let decrypted = decrypt_link(&parent_key, &details.link)?; + let content_key = self.content_key(&details, &decrypted.node_key)?; + + let response: RevisionResponse = self + .http + .get(&format!( + "v2/volumes/{}/files/{}/revisions/{}", + uid.volume_id, uid.link_id, revision_id + )) + .await?; + + // Blocks arrive in index order; decrypt each in turn straight into the + // writer so a large file never has to be resident in full. + let mut blocks = response.revision.blocks; + blocks.sort_by_key(|block| block.index); + for block in blocks { + let ciphertext = self + .http + .get_storage_blob(&block.bare_url, &block.token) + .await?; + let plaintext = content_key.decrypt_block(&ciphertext)?; + writer + .write_all(&plaintext) + .map_err(|e| ProtonError::invalid_operation(format!("write block: {e}")))?; + } + + Ok(()) + } + + /// Re-run the handshake, replacing the session behind this client. + /// + /// The anonymous session carries no refresh token, so an expiry is recovered + /// from by authenticating again (TS `SharingPublicLinkSession.reauth`). + /// Returns a fresh client rather than mutating this one, so an in-flight + /// caller never observes a half-swapped session. + pub async fn reauth(&self) -> Result { + let auth = self.session.auth().await?; + Ok(Self { + session: PublicLinkSession { + config: self.session.config.clone(), + token: self.session.token.clone(), + password: self.session.password.clone(), + }, + http: auth.http, + share_key: auth.share_key, + root_uid: auth.root_uid, + public_role: auth.public_role, + }) + } + + /// The key that decrypts `link`: the share key at the shared root, otherwise + /// the parent node's key, resolved recursively up to the root. + /// + /// The recursion terminates at the shared root because the visitor cannot + /// see past it — a link whose parent chain does not reach the root is not + /// readable with this session's material. + async fn resolve_parent_key( + &self, + volume_id: &VolumeId, + details: &LinkDetailsDto, + ) -> Result { + if details.link.id == self.root_uid.link_id { + return Ok(self.share_key.clone()); + } + + let parent_id = details.link.parent_id.clone().ok_or_else(|| { + ProtonError::invalid_operation("node has no parent and is not the shared root") + })?; + + let parent = self + .link_details(&NodeUid::new(volume_id.clone(), parent_id)) + .await?; + // Recursion is boxed: the chain length is the tree depth, which the + // compiler cannot bound for an `async fn` that calls itself. + let grandparent_key = Box::pin(self.resolve_parent_key(volume_id, &parent)).await?; + Ok(decrypt_link(&grandparent_key, &parent.link)?.node_key) + } + + async fn link_details(&self, uid: &NodeUid) -> Result { + let response: LinkDetailsResponse = self + .http + .post( + &format!("v2/volumes/{}/links", uid.volume_id), + &LinkDetailsRequest { + link_ids: std::slice::from_ref(&uid.link_id), + }, + ) + .await?; + response + .links + .into_iter() + .next() + .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} was not found"))) + } + + /// Unwrap a file's content key from its `ContentKeyPacket`. + fn content_key(&self, details: &LinkDetailsDto, node_key: &PrivateKey) -> Result { + let packet = file_properties(details) + .and_then(|file| file.content_key_packet.as_ref()) + .ok_or_else(|| ProtonError::invalid_operation("the file has no content key packet"))?; + let packet_bytes = decode_base64(packet, "ContentKeyPacket")?; + Ok(node_key.decrypt_content_key(&packet_bytes)?) + } + + /// Build a public [`Node`] from a decrypted link. + /// + /// Verification is left at its default (nothing claimed) — see the module + /// note on why an anonymous visitor cannot check authorship. + fn build_node( + &self, + volume_id: &VolumeId, + details: &LinkDetailsDto, + parent_key: &PrivateKey, + ) -> Result { + let link = &details.link; + let decrypted = decrypt_link(parent_key, link)?; + + let kind = if matches!(link.parsed_type(), LinkType::Folder) { + NodeKind::Folder + } else { + let file = file_properties(details); + NodeKind::File { + media_type: file.map(|f| f.media_type.clone()).unwrap_or_default(), + total_size_on_storage: file.map(|f| f.total_size_on_storage).unwrap_or_default(), + active_revision_state: file + .and_then(|f| f.active_revision.as_ref()) + .map(|revision| RevisionState::from_raw(revision.state)), + // Claimed metadata lives in the revision's extended attributes, + // which this read surface does not fetch. + claimed_size: None, + claimed_modification_time: None, + } + }; + + Ok(Node { + uid: NodeUid::new(volume_id.clone(), link.id.clone()), + parent_uid: link + .parent_id + .clone() + .map(|parent| NodeUid::new(volume_id.clone(), parent)), + kind, + name: decrypted.name, + creation_time: link.creation_time, + modification_time: link.modification_time, + trashed: link.is_trashed(), + is_shared: true, + is_shared_publicly: true, + signature_email: link.name_signature_email.clone(), + verification: Default::default(), + }) + } +} + +/// A node's file properties, wherever the server chose to put them. +/// +/// The photos volume returns them under `Photo` rather than `File` +/// (C# `linkDetailsDto.File ?? linkDetailsDto.Photo`) — a public link may point +/// into either. +fn file_properties(details: &LinkDetailsDto) -> Option<&FileDto> { + details.file.as_ref().or(details.photo.as_ref()) +} + +fn decode_base64(value: &str, what: &str) -> Result> { + BASE64 + .decode(value) + .map_err(|e| ProtonError::invalid_operation(format!("{what} is not valid base64: {e}"))) +} + +/// Group uids by volume, preserving first-seen order. +fn group_by_volume(uids: &[NodeUid]) -> Vec<(VolumeId, Vec)> { + let mut order: Vec = Vec::new(); + let mut groups: HashMap> = HashMap::new(); + for uid in uids { + let entry = groups.entry(uid.volume_id.clone()).or_insert_with(|| { + order.push(uid.volume_id.clone()); + Vec::new() + }); + entry.push(uid.link_id.clone()); + } + order + .into_iter() + .map(|volume_id| { + let link_ids = groups.remove(&volume_id).unwrap_or_default(); + (volume_id, link_ids) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::client::parse_public_link_url; + use crate::dtos::PublicLinkInfoResponse; + + fn info(flags: i32) -> PublicLinkInfoResponse { + PublicLinkInfoResponse { + version: 4, + modulus: String::new(), + server_ephemeral: String::new(), + url_password_salt: String::new(), + srp_session: String::new(), + flags, + vendor_type: 0, + } + } + + #[test] + fn parses_token_and_password_from_a_share_url() { + let (token, password) = + parse_public_link_url("https://drive.proton.me/urls/ABC123#s3cr3t").unwrap(); + assert_eq!(token, "ABC123"); + assert_eq!(password, "s3cr3t"); + } + + #[test] + fn tolerates_a_trailing_slash_before_the_fragment() { + let (token, password) = + parse_public_link_url("https://drive.proton.me/urls/ABC123/#s3cr3t").unwrap(); + assert_eq!(token, "ABC123"); + assert_eq!(password, "s3cr3t"); + } + + #[test] + fn rejects_urls_without_a_usable_secret() { + // No fragment at all: the URL carries no password to open anything with. + assert!(parse_public_link_url("https://drive.proton.me/urls/ABC123").is_err()); + // Empty fragment, empty token. + assert!(parse_public_link_url("https://drive.proton.me/urls/ABC123#").is_err()); + assert!(parse_public_link_url("https://drive.proton.me/urls/#s3cr3t").is_err()); + } + + #[test] + fn link_flags_classify_custom_password_and_legacy_links() { + // TS: isCustomPasswordProtected = (Flags & 1) === 1, + // isLegacy = Flags === 0 || Flags === 1. + assert!(info(0).is_legacy()); + assert!(info(1).is_legacy()); + assert!(!info(2).is_legacy()); + assert!(!info(3).is_legacy()); + + assert!(!info(2).is_custom_password_protected()); + assert!(info(3).is_custom_password_protected()); + } +} diff --git a/crates/proton-drive-rs/src/revision.rs b/crates/proton-drive-rs/src/revision.rs index 1a0e3f3..e0eaa0a 100644 --- a/crates/proton-drive-rs/src/revision.rs +++ b/crates/proton-drive-rs/src/revision.rs @@ -9,6 +9,8 @@ use futures::stream::{self, StreamExt, TryStreamExt}; use proton_sdk::crypto::ContentKey; use proton_sdk::error::{ProtonError, Result}; use proton_sdk::ids::NodeUid; + +use crate::node::RevisionState; use sha2::{Digest, Sha256}; use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock}; @@ -72,6 +74,45 @@ async fn join_decrypt( .map_err(Into::into) } +/// One entry of a file's revision history. +/// +/// Mirrors C# `Proton.Drive.Sdk.Nodes.Revision` and the TypeScript SDK's +/// `DecryptedRevision`. The `claimed_*` fields come from the revision's +/// decrypted extended attributes — they are what the *uploader* asserted, not +/// something the server computed, hence the name (C# `ClaimedSize` / +/// `ClaimedModificationTime`). They are `None` when the revision carries no +/// `XAttr` or it failed to decrypt. +#[derive(Debug, Clone)] +pub struct Revision { + /// The file this revision belongs to. + pub file_uid: NodeUid, + /// Server-assigned revision id, unique within the file. + pub revision_id: String, + pub state: RevisionState, + /// Creation time, epoch seconds. + pub creation_time: i64, + /// Encrypted size on cloud storage, in bytes. + pub size_on_storage: i64, + /// Plaintext size as claimed by the uploader. + pub claimed_size: Option, + /// ISO-8601 modification time as claimed by the uploader, verbatim. + pub claimed_modification_time: Option, + /// Lowercase-hex SHA-1 of the full plaintext, as claimed by the uploader. + pub claimed_sha1: Option, + /// Email that signed the revision manifest; `None`/empty means the node key + /// signed it anonymously. + pub signature_email: Option, + /// Whether the revision carries any thumbnails. + pub has_thumbnails: bool, +} + +impl Revision { + /// Whether this is the file's current revision. + pub fn is_active(&self) -> bool { + matches!(self.state, RevisionState::Active) + } +} + /// The block table, versioned so a refresh can be deduplicated: a caller that /// hit an expired URL records the generation it saw, and only re-lists the /// revision if nobody else has already replaced that generation. @@ -173,18 +214,7 @@ impl RevisionReader { // Which blocks overlap the range, and where each one starts in the // plaintext — resolved up front so the fetches can run concurrently. - let mut wanted: Vec<(usize, u64)> = Vec::new(); - let mut block_start: u64 = 0; - for (index, &block_size) in self.block_sizes.iter().enumerate() { - if block_start >= end { - break; - } - let block_end = block_start + block_size; - if block_end > offset { - wanted.push((index, block_start)); - } - block_start = block_end; - } + let wanted = plan_blocks(&self.block_sizes, offset, end); // `buffered` yields in input order, so the slices append in block order. // The closure takes the index *by value*: a closure taking a reference @@ -202,11 +232,7 @@ impl RevisionReader { let (_, block_start) = wanted[next]; next += 1; - let from = offset.saturating_sub(block_start) as usize; - let to = ((end - block_start) as usize).min(plaintext.len()); - if from < to { - out.extend_from_slice(&plaintext[from..to]); - } + splice_block(&mut out, &plaintext, block_start, offset, end); } Ok(out) @@ -294,6 +320,42 @@ impl RevisionReader { } } +/// Which blocks overlap the plaintext range `[offset, end)`, paired with where +/// each one starts in the plaintext. +/// +/// A block's start is the running sum of the *preceding* blocks' sizes, so this +/// is only correct if `block_sizes` describes the revision truthfully. A vector +/// that pads a short block up to the full block size does not merely misreport +/// the file's length — it shifts the computed start of every block after it, and +/// the caller then splices those blocks at the wrong plaintext offsets. See +/// `padded_block_sizes_shift_later_blocks` below, and the guarantee +/// `ProtonDriveClient::resolve_block_sizes` has to uphold to make this sound. +fn plan_blocks(block_sizes: &[u64], offset: u64, end: u64) -> Vec<(usize, u64)> { + let mut wanted = Vec::new(); + let mut block_start: u64 = 0; + for (index, &block_size) in block_sizes.iter().enumerate() { + if block_start >= end { + break; + } + let block_end = block_start + block_size; + if block_end > offset { + wanted.push((index, block_start)); + } + block_start = block_end; + } + wanted +} + +/// Append the part of `plaintext` — a block beginning at `block_start` in the +/// file — that falls inside the requested range `[offset, end)`. +fn splice_block(out: &mut Vec, plaintext: &[u8], block_start: u64, offset: u64, end: u64) { + let from = offset.saturating_sub(block_start) as usize; + let to = ((end - block_start) as usize).min(plaintext.len()); + if from < to { + out.extend_from_slice(&plaintext[from..to]); + } +} + /// Whether a storage fetch failed in a way that an expired block URL would /// produce. Block URLs carry their own authorization and are handed out with a /// server-side lifetime we neither control nor are told, so recovery is @@ -304,3 +366,100 @@ fn is_expired_block_url(error: &ProtonError) -> bool { ProtonError::Api(e) if matches!(e.http_status, 401 | 403 | 404) ) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Assemble a read exactly as [`RevisionReader::read_at`] does: plan the + /// blocks from what the reader *believes* the sizes are, then splice the + /// blocks the server *actually* returns. Splitting belief from reality is + /// the whole point — that gap is the bug. + fn read_as_reader_would( + believed_sizes: &[u64], + actual_blocks: &[Vec], + offset: u64, + length: u64, + ) -> Vec { + let file_size: u64 = believed_sizes.iter().sum(); + if length == 0 || offset >= file_size { + return Vec::new(); + } + let end = offset.saturating_add(length).min(file_size); + let mut out = Vec::new(); + for (index, block_start) in plan_blocks(believed_sizes, offset, end) { + splice_block(&mut out, &actual_blocks[index], block_start, offset, end); + } + out + } + + /// The file used throughout: two genuinely 3 MiB blocks, 6 MiB total, with + /// every byte carrying its own offset so a misplaced slice is identifiable + /// rather than just unequal. + fn short_block_file() -> (Vec, Vec>) { + const SHORT: usize = 3 << 20; + let truthful = vec![SHORT as u64, SHORT as u64]; + let blocks: Vec> = (0..2) + .map(|b| (0..SHORT).map(|i| ((b * SHORT + i) % 251) as u8).collect()) + .collect(); + (truthful, blocks) + } + + /// The whole file's plaintext, for comparison. + fn flatten(blocks: &[Vec]) -> Vec { + blocks.iter().flatten().copied().collect() + } + + /// Truthful block sizes read the file correctly — the control. + #[test] + fn truthful_block_sizes_read_correctly() { + let (truthful, blocks) = short_block_file(); + let whole = flatten(&blocks); + for (offset, length) in [(0, 1 << 20), (3 << 20, 1 << 20), (4 << 20, 1 << 20)] { + let got = read_as_reader_would(&truthful, &blocks, offset, length); + let want = &whole[offset as usize..(offset + length) as usize]; + assert_eq!(got, want, "offset={offset} length={length}"); + } + } + + /// **The A1 reproduce.** `resolve_block_sizes`'s terminal fallback pads every + /// block to the full 4 MiB. For a file whose blocks are actually shorter, + /// that shifts each later block's computed start and the reader serves bytes + /// from the wrong part of the file — silently, with no error and no short + /// read to hint at it. + #[test] + fn padded_block_sizes_shift_later_blocks() { + let (_, blocks) = short_block_file(); + let whole = flatten(&blocks); + // What the fallback would produce for a 2-block file: `vec![4 MiB; 2]`. + let padded = [4u64 << 20; 2]; + + // Read 1 MiB at 4 MiB. Block 1 truly starts at 3 MiB, but the padded + // sizes place it at 4 MiB, so the splice is off by exactly 1 MiB. + let got = read_as_reader_would(&padded, &blocks, 4 << 20, 1 << 20); + let want = &whole[(4 << 20)..(5 << 20)]; + let shifted = &whole[(3 << 20)..(4 << 20)]; + + assert_eq!( + got.len(), + 1 << 20, + "full length returned: nothing looks wrong" + ); + assert_eq!(got, shifted, "bytes come from 1 MiB earlier than requested"); + assert_ne!(got, want, "and they are not the bytes that were asked for"); + } + + /// The same padding also inflates the reported file size, so a read wholly + /// past the real EOF returns bytes instead of nothing. + #[test] + fn padded_block_sizes_inflate_file_size() { + let (truthful, _) = short_block_file(); + let padded = [4u64 << 20; 2]; + assert_eq!(truthful.iter().sum::(), 6 << 20, "the real size"); + assert_eq!( + padded.iter().sum::(), + 8 << 20, + "the size stat would show" + ); + } +} diff --git a/crates/proton-drive-rs/tests/common/mod.rs b/crates/proton-drive-rs/tests/common/mod.rs index be413fd..bcd5b67 100644 --- a/crates/proton-drive-rs/tests/common/mod.rs +++ b/crates/proton-drive-rs/tests/common/mod.rs @@ -23,8 +23,18 @@ use sha1::Sha1; // Must follow Proton's required shape (sdk/README.md "Operational requirements"). // A malformed channel fails honest-identification → 422 "unusual activity". -const APP_VERSION: &str = "external-drive-rust@0.1.0-alpha"; -const USER_AGENT: &str = "external-drive-rust/0.1.0"; +pub const APP_VERSION: &str = "external-drive-rust@0.1.0-alpha"; +pub const USER_AGENT: &str = "external-drive-rust/0.1.0"; + +/// A fresh, unauthenticated client configuration — what the public-link client +/// takes, since a visitor has no session to borrow one from. +/// +/// `allow(dead_code)`: `common` is compiled into every live-test binary, and +/// only `live_sharing` needs this one. +#[allow(dead_code)] +pub fn config() -> ProtonClientConfiguration { + ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT) +} /// An authenticated Drive client plus the data password it was built with. pub struct LiveClient { diff --git a/crates/proton-drive-rs/tests/live_drive_ops.rs b/crates/proton-drive-rs/tests/live_drive_ops.rs index ecb8569..1e73984 100644 --- a/crates/proton-drive-rs/tests/live_drive_ops.rs +++ b/crates/proton-drive-rs/tests/live_drive_ops.rs @@ -331,3 +331,109 @@ async fn verification_folder_fully_verified() { cleanup(client, &[uid]).await; } + +// --------------------------------------------------------------------------- +// Hierarchy & path helpers +// --------------------------------------------------------------------------- + +/// `create_folder_path` → `get_node_by_path` → `get_node_hierarchy` → +/// `get_node_path`, asserting the four agree on the same nested tree. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn folder_path_helpers_round_trip() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let root = client + .get_my_files_folder() + .await + .expect("get my-files root"); + + let top = format!("path-ops-{}", common::unique_suffix()); + let path = format!("{top}/nested/deep"); + + // mkdir -p + let deep = client + .create_folder_path(&root.uid, &path) + .await + .expect("create_folder_path"); + + // Idempotent: a second call adopts the existing folders, same uid. + let again = client + .create_folder_path(&root.uid, &path) + .await + .expect("create_folder_path is idempotent"); + assert_eq!(again, deep, "re-creating a path must reuse the folders"); + + // Lookup by path, tolerating separator noise. + for variant in [path.as_str(), &format!("/{path}"), &format!("//{path}//")] { + let found = client + .get_node_by_path(&root.uid, variant) + .await + .unwrap_or_else(|e| panic!("get_node_by_path({variant}) errored: {e}")) + .unwrap_or_else(|| panic!("get_node_by_path({variant}) returned None")); + assert_eq!(found.uid, deep, "path {variant} must resolve to the folder"); + } + + // An empty path is the root itself. + let at_root = client + .get_node_by_path(&root.uid, "/") + .await + .expect("get_node_by_path(/)") + .expect("root must resolve"); + assert_eq!(at_root.uid, root.uid, "empty path is the root"); + + // A missing segment resolves to None, not an error. + assert!( + client + .get_node_by_path(&root.uid, &format!("{top}/nope")) + .await + .expect("missing path must not error") + .is_none(), + "a missing segment must resolve to None" + ); + + // Hierarchy is root-first and ends at the queried node. + let hierarchy = client + .get_node_hierarchy(&deep) + .await + .expect("get_node_hierarchy") + .expect("hierarchy for an existing node"); + assert_eq!( + hierarchy.first().map(|n| &n.uid), + Some(&root.uid), + "hierarchy must start at the tree root" + ); + assert_eq!( + hierarchy.last().map(|n| &n.uid), + Some(&deep), + "hierarchy must end at the queried node" + ); + let names: Vec<&str> = hierarchy.iter().skip(1).map(|n| n.name.as_str()).collect(); + assert_eq!(names, [top.as_str(), "nested", "deep"]); + + // The rendered path round-trips back through the path lookup. + let rendered = client + .get_node_path(&deep) + .await + .expect("get_node_path") + .expect("path for an existing node"); + assert_eq!(rendered, format!("/{path}")); + let reresolved = client + .get_node_by_path(&root.uid, &rendered) + .await + .expect("re-resolve rendered path") + .expect("rendered path must resolve"); + assert_eq!(reresolved.uid, deep, "rendered path must round-trip"); + + // Only the top folder needs cleaning — delete cascades to its children. + let top_uid = client + .get_node_by_path(&root.uid, &top) + .await + .expect("locate top folder") + .expect("top folder must exist") + .uid; + cleanup(client, &[top_uid]).await; +} diff --git a/crates/proton-drive-rs/tests/live_sharing.rs b/crates/proton-drive-rs/tests/live_sharing.rs index 15abfd0..ce5b3eb 100644 --- a/crates/proton-drive-rs/tests/live_sharing.rs +++ b/crates/proton-drive-rs/tests/live_sharing.rs @@ -18,7 +18,7 @@ mod common; -use proton_drive_rs::{MemberRole, ProtonDriveClient}; +use proton_drive_rs::{MemberRole, ProtonDriveClient, ProtonDrivePublicLinkClient}; use proton_sdk::ids::NodeUid; /// Trash then permanently delete the given node; best-effort, logs on failure. @@ -250,3 +250,161 @@ async fn shared_with_me_and_incoming_invitations_read() { .expect("list_incoming_invitations must not error"); eprintln!("[info] incoming invitations count: {}", incoming.len()); } + +// --------------------------------------------------------------------------- +// Public link — consuming a link as an anonymous visitor +// --------------------------------------------------------------------------- + +/// Full loop: upload a file, publish a link on its folder, then open that link +/// with [`ProtonDrivePublicLinkClient`] — no session — and read the file back. +/// +/// This is the only test that exercises both halves of the public-link feature +/// against each other, so a break in either the mint or the consume side shows +/// up here. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn public_link_round_trips_through_the_visitor_client() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let folder = scratch_folder(client, "public-visit").await; + + let contents = b"public link visitor payload".to_vec(); + let name = format!("shared-{}.txt", common::unique_suffix()); + let file = client + .upload_file(&folder, &name, "text/plain", &contents) + .await + .expect("upload_file"); + + let link = client + .create_public_link(&folder, MemberRole::Viewer, None, None) + .await + .expect("create_public_link"); + + // Pre-auth metadata: a link with no custom password must say so, or a caller + // would prompt for one that does not exist. + let url = link.url.clone().expect("a created link must carry its URL"); + + let info = ProtonDrivePublicLinkClient::info(common::config(), &url) + .await + .expect("public link info"); + assert!( + !info.is_legacy, + "a freshly minted link must not read as legacy" + ); + assert!( + !info.is_custom_password_protected, + "no custom password was set" + ); + + // Open it as a visitor — no session, only the URL. + let visitor = ProtonDrivePublicLinkClient::open(common::config(), &url, None) + .await + .expect("open public link"); + + assert_eq!( + visitor.public_role(), + MemberRole::Viewer, + "the link was minted read-only" + ); + + let root = visitor.get_root_node().await.expect("visitor root node"); + assert_eq!(root.uid, folder, "the link points at the shared folder"); + assert!(root.is_folder(), "the shared node is a folder"); + + // The shared subtree lists and decrypts with the share key alone. + let child_uids = visitor + .enumerate_folder_children_node_uids(&folder) + .await + .expect("visitor lists children"); + assert!( + child_uids.contains(&file), + "the uploaded file must appear in the shared listing" + ); + + let children = visitor + .enumerate_nodes(&child_uids) + .await + .expect("visitor decrypts children"); + let shared_file = children + .iter() + .find(|node| node.uid == file) + .expect("the uploaded file must decrypt for the visitor"); + assert_eq!( + shared_file.name, name, + "the name must decrypt to what was uploaded" + ); + + // The payload itself: content key unwrapped from the node key, blocks pulled + // from storage with the anonymous session. + let downloaded = visitor + .download_file(&file) + .await + .expect("visitor download"); + assert_eq!( + downloaded, contents, + "the visitor must read back exactly what was uploaded" + ); + + // Revoking the link must actually close the door. + client + .remove_public_link(&link) + .await + .expect("remove_public_link"); + assert!( + ProtonDrivePublicLinkClient::open(common::config(), &url, None) + .await + .is_err(), + "a revoked link must no longer open" + ); + + cleanup(client, &folder).await; +} + +/// A custom-password link must advertise the requirement and refuse to open +/// without it. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn custom_password_link_requires_the_password() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let folder = scratch_folder(client, "public-custompw").await; + let custom = "s3cr3t!"; + + let link = client + .create_public_link(&folder, MemberRole::Viewer, Some(custom), None) + .await + .expect("create_public_link with custom password"); + + let url = link.url.clone().expect("a created link must carry its URL"); + + let info = ProtonDrivePublicLinkClient::info(common::config(), &url) + .await + .expect("public link info"); + assert!( + info.is_custom_password_protected, + "the link must advertise that a custom password is needed" + ); + + // Without the custom password the SRP handshake itself fails — the server + // never hands over the encrypted share key. + assert!( + ProtonDrivePublicLinkClient::open(common::config(), &url, None) + .await + .is_err(), + "opening without the custom password must fail" + ); + + let visitor = ProtonDrivePublicLinkClient::open(common::config(), &url, Some(custom)) + .await + .expect("open with the custom password"); + let root = visitor.get_root_node().await.expect("visitor root node"); + assert_eq!(root.uid, folder); + + cleanup(client, &folder).await; +} diff --git a/crates/proton-drive-rs/tests/live_upload_download.rs b/crates/proton-drive-rs/tests/live_upload_download.rs index 3854ae6..2cfc0a4 100644 --- a/crates/proton-drive-rs/tests/live_upload_download.rs +++ b/crates/proton-drive-rs/tests/live_upload_download.rs @@ -395,3 +395,202 @@ async fn open_revision_reader_reuses_resolved_metadata() { cleanup(&client, &[uid]).await; } + +// --------------------------------------------------------------------------- +// Revision history +// --------------------------------------------------------------------------- + +/// Upload a file, replace it twice, then walk the history: list → read an old +/// revision → restore it → delete a superseded one. +/// +/// This is the only test that exercises revisions as a *history* rather than as +/// upload plumbing, so it covers `enumerate_revisions`, `get_revision`, +/// `download_revision`, `restore_revision` and `delete_revision` together. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn revision_history_lists_reads_restores_and_deletes() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + + let root = client + .get_my_files_folder() + .await + .expect("get my-files root"); + + let name = format!("revisions-{}.txt", common::unique_suffix()); + let v1 = b"first revision contents".to_vec(); + let v2 = b"second revision contents, longer than the first".to_vec(); + let v3 = b"third".to_vec(); + + let file = client + .upload_file(&root.uid, &name, "text/plain", &v1) + .await + .expect("upload_file"); + client + .upload_new_revision(&file, &v2) + .await + .expect("upload second revision"); + client + .upload_new_revision(&file, &v3) + .await + .expect("upload third revision"); + + // Three sealed revisions, exactly one of them active. + let revisions = client + .enumerate_revisions(&file) + .await + .expect("enumerate_revisions"); + assert_eq!(revisions.len(), 3, "three uploads means three revisions"); + let active: Vec<_> = revisions.iter().filter(|r| r.is_active()).collect(); + assert_eq!(active.len(), 1, "exactly one revision may be active"); + + // The active one must describe the newest upload. `claimed_size` is the + // uploader's plaintext size, so it is comparable to what we wrote — + // `size_on_storage` is the ciphertext and is not. + let active = active[0]; + assert_eq!( + active.claimed_size, + Some(v3.len() as i64), + "the active revision must claim the newest payload's size" + ); + assert!( + active.size_on_storage > 0, + "a sealed revision occupies storage" + ); + + // Fetching one by id must agree with the listing. + let fetched = client + .get_revision(&file, &active.revision_id) + .await + .expect("get_revision") + .expect("the active revision must exist"); + assert_eq!(fetched.revision_id, active.revision_id); + assert!(fetched.is_active()); + assert_eq!(fetched.claimed_size, active.claimed_size); + + // A malformed id is a caller bug, not a missing entity: the server rejects + // it with 400 `InvalidEncryptedIdFormat` and that must surface as an error, + // not be flattened into `None`. (The genuine not-found path is checked + // against a deleted-but-well-formed id at the end of this test.) + assert!( + client.get_revision(&file, "does-not-exist").await.is_err(), + "a malformed revision id must error rather than read as absent" + ); + + // Superseded revisions, oldest first, are the two earlier uploads. + let mut superseded: Vec<_> = revisions.iter().filter(|r| !r.is_active()).collect(); + superseded.sort_by_key(|r| r.creation_time); + assert_eq!(superseded.len(), 2); + + let oldest = superseded[0]; + assert_eq!( + oldest.claimed_size, + Some(v1.len() as i64), + "the oldest revision must still claim the first payload's size" + ); + + // Reading a superseded revision must give back its own content, not the + // active one's — the whole point of keeping history. + let old_bytes = client + .download_revision(&file, &oldest.revision_id) + .await + .expect("download_revision"); + assert_eq!( + old_bytes, v1, + "a superseded revision must read back verbatim" + ); + assert_eq!( + client.download_file(&file).await.expect("download_file"), + v3, + "the file itself must still serve the active revision" + ); + + // Restoring does not move the pointer in place: it mints a *new* active + // revision carrying the old content. The server applies it asynchronously + // (HTTP 202), so poll rather than assume it is live on return. + client + .restore_revision(&file, &oldest.revision_id) + .await + .expect("restore_revision"); + + let mut restored = false; + for _ in 0..30 { + if client + .download_file(&file) + .await + .expect("download after restore") + == v1 + { + restored = true; + break; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + assert!( + restored, + "after restore the file must serve the restored content within 30s" + ); + + let after_restore = client + .enumerate_revisions(&file) + .await + .expect("enumerate after restore"); + assert!( + after_restore.len() >= 3, + "restoring must not drop history, got {}", + after_restore.len() + ); + assert_eq!( + after_restore.iter().filter(|r| r.is_active()).count(), + 1, + "still exactly one active revision after restore" + ); + + // Deleting a superseded revision removes it and leaves the rest alone. + let victim = after_restore + .iter() + .find(|r| !r.is_active()) + .expect("a superseded revision to delete"); + client + .delete_revision(&file, &victim.revision_id) + .await + .expect("delete_revision"); + + let after_delete = client + .enumerate_revisions(&file) + .await + .expect("enumerate after delete"); + assert_eq!( + after_delete.len(), + after_restore.len() - 1, + "exactly one revision must disappear" + ); + assert!( + !after_delete + .iter() + .any(|r| r.revision_id == victim.revision_id), + "the deleted revision must be gone from the history" + ); + assert_eq!( + client + .download_file(&file) + .await + .expect("download after delete"), + v1, + "deleting a superseded revision must not disturb the active one" + ); + + // Now the real not-found path: a well-formed id whose revision is gone. + assert!( + client + .get_revision(&file, &victim.revision_id) + .await + .expect("a deleted revision must not error") + .is_none(), + "a deleted revision must resolve to None" + ); + + cleanup(client, &[file]).await; +} diff --git a/crates/proton-sdk/src/crypto.rs b/crates/proton-sdk/src/crypto.rs index 7ae4111..376a276 100644 --- a/crates/proton-sdk/src/crypto.rs +++ b/crates/proton-sdk/src/crypto.rs @@ -24,6 +24,7 @@ pub use encrypt::{ }; pub use errors::CryptoError; pub use keys::{PrivateKey, decrypt_armored_with_keys}; +pub use messages::decrypt_armored_with_password; pub use srp::{DEFAULT_BIT_LENGTH, SrpProofs, SrpVerifier, generate_proofs, generate_verifier}; pub use verify::{PublicKey, VerificationKeyRing, VerificationStatus, verify_detached}; diff --git a/crates/proton-sdk/src/crypto/messages.rs b/crates/proton-sdk/src/crypto/messages.rs index 9bc18e5..50e07e0 100644 --- a/crates/proton-sdk/src/crypto/messages.rs +++ b/crates/proton-sdk/src/crypto/messages.rs @@ -41,6 +41,28 @@ pub fn decrypt_binary( read_plaintext(decrypted) } +/// Decrypt an armored, **symmetrically** encrypted message (SKESK + SEIPD). +/// +/// The counterpart of [`ContentKey::encrypt_with_password`](super::ContentKey::encrypt_with_password): +/// no PGP key is involved, only a passphrase. Proton uses this for a public +/// share link's `SharePassphrase`, where the passphrase is the bcrypt-derived +/// key password (see [`derive_key_passphrase`](super::derive_key_passphrase)) +/// rather than the raw link password. The S2K parameters travel in the packet, +/// so they need not be agreed on out of band. +pub fn decrypt_armored_with_password( + armored: &str, + password: &[u8], +) -> Result, CryptoError> { + let (message, _headers) = + Message::from_string(armored).map_err(|e| CryptoError::Parse(format!("message: {e}")))?; + + let decrypted = message + .decrypt_with_password(&Password::from(password)) + .map_err(|e| CryptoError::Decrypt(e.to_string()))?; + + read_plaintext(decrypted) +} + /// Decrypt an armored message, trying each candidate key until one succeeds. /// /// Proton key tokens are encrypted to one of several user keys; we don't know @@ -71,3 +93,51 @@ pub(crate) fn read_plaintext(mut message: Message<'_>) -> Result, Crypto .as_data_vec() .map_err(|e| CryptoError::Decrypt(format!("read literal: {e}"))) } + +#[cfg(test)] +mod tests { + use super::decrypt_armored_with_password; + use crate::crypto::derive_key_passphrase; + use pgp::composed::MessageBuilder; + use pgp::crypto::sym::SymmetricKeyAlgorithm; + use pgp::types::{Password, StringToKey}; + + /// Build the shape Proton's `SharePassphrase` has: an armored, symmetrically + /// encrypted message whose passphrase is the bcrypt-derived key password. + fn encrypt_armored_with_password(plaintext: &[u8], password: &[u8]) -> String { + let mut rng = rand_08::thread_rng(); + let s2k = StringToKey::new_default(&mut rng); + let mut builder = MessageBuilder::from_bytes(String::new(), plaintext.to_vec()) + .seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256); + builder + .encrypt_with_password(s2k, &Password::from(password)) + .expect("encrypt with password"); + builder + .to_armored_string(&mut rng, Default::default()) + .expect("armor") + } + + #[test] + fn password_encrypted_message_round_trips() { + let password = b"link-password-and-custom".as_slice(); + let salt = [7u8; 16]; + let key_password = derive_key_passphrase(password, &salt).expect("derive"); + + let armored = encrypt_armored_with_password(b"the share passphrase", &key_password); + let plaintext = decrypt_armored_with_password(&armored, &key_password).expect("decrypt"); + + assert_eq!(plaintext, b"the share passphrase"); + } + + #[test] + fn the_wrong_password_does_not_decrypt() { + // A wrong *link* password derives a different key password, which is how + // a bad custom password surfaces if it ever gets past the SRP handshake. + let salt = [7u8; 16]; + let right = derive_key_passphrase(b"correct", &salt).expect("derive"); + let wrong = derive_key_passphrase(b"incorrect", &salt).expect("derive"); + + let armored = encrypt_armored_with_password(b"secret", &right); + assert!(decrypt_armored_with_password(&armored, &wrong).is_err()); + } +} diff --git a/crates/proton-sdk/src/http.rs b/crates/proton-sdk/src/http.rs index 6e22897..6fc379f 100644 --- a/crates/proton-sdk/src/http.rs +++ b/crates/proton-sdk/src/http.rs @@ -444,6 +444,38 @@ struct SessionRefreshResponse { /// Used by the SRP login flow (`auth/v4/info`, `auth/v4`), which runs before a /// session exists. Mirrors the C# SDK's `BeginAsync`, which issues these calls /// on a session-less `HttpClient`. +/// Issue a GET with no session, for endpoints reachable before authentication. +/// +/// The public-link flow needs this: `drive/urls/{token}/info` opens the SRP +/// handshake and is by definition callable by a visitor who has no Proton +/// session at all. +pub async fn get_unauthenticated( + config: &ProtonClientConfiguration, + path: &str, +) -> Result { + let http = reqwest::Client::builder() + .timeout(config.request_timeout) + .gzip(true) + .build()?; + + let base_url = ensure_trailing_slash(&config.base_url); + let url = format!("{}{}", base_url, path.trim_start_matches('/')); + + let response = send_retrying(&config.retry_policy, || { + let mut request = http + .get(&url) + .header(APP_VERSION_HEADER, &config.app_version) + .header(reqwest::header::ACCEPT, API_CONTENT_TYPE); + if !config.user_agent.is_empty() { + request = request.header(reqwest::header::USER_AGENT, &config.user_agent); + } + request + }) + .await?; + + parse_response(response).await +} + pub async fn post_unauthenticated( config: &ProtonClientConfiguration, path: &str,