From 24a99e6b9a0b183ed9f8ff351701d281c9cfdb69 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 19 Jul 2026 13:14:10 +0200 Subject: [PATCH] public link consume, revision history, path helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public links (public_link.rs, ported from the TS SDK — the C# SDK has no equivalent): ProtonDrivePublicLinkClient lets a visitor with no Proton account open someone else's share link. `info` reads pre-auth metadata, `open` runs the SRP handshake against drive/urls/{token}/info + /auth and unlocks the share key (bcrypt SharePasswordSalt -> key password -> the SKESK'd SharePassphrase -> ShareKey). The read surface then reuses the ordinary link/children/revision endpoints. Signature verification is deliberately absent: an anonymous visitor cannot read core/v4/keys/all, matching the TS SDK. Supporting primitives: http::get_unauthenticated and crypto::decrypt_armored_with_password. Revision history: enumerate_revisions / get_revision / restore_revision / delete_revision, plus open_revision_at / download_revision{,_to} to read a specific revision. Drafts are filtered on the wire value, since RevisionState::from_raw maps 0 to Active. Three server behaviors found live and worked around: ?NoBlockUrls=true still emits Blocks with null BareURL, so get_revision deserializes into a DTO without that field; the restore POST needs an empty object body, not null; and restore is async (HTTP 202, non-1000 envelope) so the new active revision is not live on return. Path and hierarchy helpers: get_node_hierarchy, get_node_path, get_node_by_path, create_folder_path. Names are encrypted and there is no name-query endpoint, so lookup walks and decrypts one directory level per segment. Fix a silent-corruption path in block-size resolution. RevisionReader accumulates block sizes to derive where each block starts in the plaintext, so a vector that overstates any block but the last shifts every block after it and the reader splices at the wrong offsets — full-length reads of the wrong bytes, no error raised. resolve_block_sizes previously guessed all-full blocks when both Common.BlockSizes and Common.Size were unusable. It now errors for multi-block files and only assumes a full block when there is exactly one (which cannot be misaligned). Regression test in revision.rs::padded_block_sizes_shift_later_blocks. Recover from stale upload drafts. An upload interrupted after the draft node was created but before it was sealed leaves an unsealed draft, and every later attempt at that name returns 422 AlreadyExists forever. Draft creation now deletes the conflicting node and retries when the conflict is an unsealed draft; by default only our own client's, with upload_file_replacing_draft_from opting into any client's to recover across a daemon restart (which rotates our client uid). A committed file of that name is never touched. Live tests for the public-link round trip, custom-password links, revision history, and the path helpers. --- crates/proton-drive-rs/src/client.rs | 853 +++++++++++++++++- crates/proton-drive-rs/src/dtos.rs | 161 +++- crates/proton-drive-rs/src/lib.rs | 4 +- crates/proton-drive-rs/src/public_link.rs | 646 +++++++++++++ crates/proton-drive-rs/src/revision.rs | 193 +++- crates/proton-drive-rs/tests/common/mod.rs | 14 +- .../proton-drive-rs/tests/live_drive_ops.rs | 106 +++ crates/proton-drive-rs/tests/live_sharing.rs | 160 +++- .../tests/live_upload_download.rs | 199 ++++ crates/proton-sdk/src/crypto.rs | 1 + crates/proton-sdk/src/crypto/messages.rs | 70 ++ crates/proton-sdk/src/http.rs | 32 + 12 files changed, 2366 insertions(+), 73 deletions(-) create mode 100644 crates/proton-drive-rs/src/public_link.rs 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,