diff --git a/CHANGELOG.md b/CHANGELOG.md index b38e35a..3501e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ This file starts after 0.9.2; earlier changes are in the git history. expiry of the link on a node. The URL does not change. `None` removes a custom password or an expiry. A legacy link cannot be updated. Ported from the TypeScript SDK (`updateSharedLink`). +- **A public link's custom password.** `PublicLink::custom_password` holds the + custom password of a link, recovered from the stored link password, so a + caller can keep it while `update_public_link` changes the role or the expiry. - **Walking a public link.** `ProtonDrivePublicLinkClient::enumerate_subtree` and `enumerate_subtree_reporting` return every node below a shared folder, breadth first, listing several folders of a level at once. @@ -50,6 +53,8 @@ This file starts after 0.9.2; earlier changes are in the git history. is retried under the client's retry policy. A write still is not: it may have been applied. Read-only POSTs in `proton-drive-rs` (link details, thumbnails, photo duplicates, `checkAvailableHashes`) now use `post_query`. +- `Debug` for `PublicLink` and `Bookmark` no longer prints the URL or the + custom password; it shows `` instead. - `remove_photos_from_album` refuses a photo on another volume per photo, in the input order, without sending it. @@ -60,6 +65,6 @@ This file starts after 0.9.2; earlier changes are in the git history. ### Breaking -- `Bookmark` has a new public field, `custom_password`. Code that builds a - `Bookmark` with a struct literal must set it. +- `Bookmark` and `PublicLink` have a new public field, `custom_password`. Code + that builds either with a struct literal must set it. - `ResponseCode` has a new variant. An exhaustive `match` on it needs an arm. diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 9748bbe..0ccc62e 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -3794,6 +3794,7 @@ impl ProtonDriveClient { creation_time: now_epoch_seconds(), expiration_time, has_custom_password, + custom_password: custom_password.filter(|p| !p.is_empty()).map(str::to_owned), }) } @@ -3856,6 +3857,7 @@ impl ProtonDriveClient { role, expiration_time, has_custom_password, + custom_password: custom_password.filter(|p| !p.is_empty()).map(str::to_owned), ..link }) } @@ -3936,7 +3938,7 @@ impl ProtonDriveClient { let response: ShareUrlsResponse = self.http.get(&path).await?; match response.share_urls.into_iter().next() { Some(dto) => { - let url = self.recover_public_link_url(&dto).await; + let (url, custom_password) = self.recover_public_link_secret(&dto).await; Some(PublicLink { share_id, public_link_id: dto.share_url_id, @@ -3945,6 +3947,7 @@ impl ProtonDriveClient { creation_time: dto.create_time, expiration_time: dto.expiration_time, has_custom_password: dto.flags == 3, + custom_password, }) } None => None, @@ -3956,24 +3959,32 @@ impl ProtonDriveClient { Ok(link) } - /// Rebuild the full public URL (`{public_url}#{generated}`) for an existing - /// link by decrypting its stored `Password` with the owner's address key. The - /// stored secret is the generated password with any custom password appended; - /// only the leading generated portion belongs in the URL fragment (the - /// recipient supplies the custom password separately). Returns `None` if the - /// link carries no password or it can't be decrypted, so the caller still gets - /// the link's metadata. - async fn recover_public_link_url(&self, dto: &ShareUrlDto) -> Option { - if dto.public_url.is_empty() { - return None; - } - let enc = dto.password.as_deref()?; - let (_, email, _) = self.membership_address().await.ok()?; - let (address_keys, _) = self.own_address_keys(&email).await.ok()?; - let bytes = decrypt_armored_with_keys(enc, &address_keys).ok()?; - let full = String::from_utf8_lossy(&bytes); - let generated: String = full.chars().take(PUBLIC_LINK_PASSWORD_LEN).collect(); - Some(format!("{}#{generated}", dto.public_url)) + /// Rebuild the full public URL (`{public_url}#{generated}`) and the custom + /// password of an existing link by decrypting its stored `Password` with the + /// owner's address key. The stored secret is the generated password with any + /// custom password appended; only the leading generated portion belongs in + /// the URL fragment (the recipient supplies the custom password separately). + /// Both are `None` if the link carries no password or it can't be + /// decrypted, so the caller still gets the link's metadata. + async fn recover_public_link_secret( + &self, + dto: &ShareUrlDto, + ) -> (Option, Option) { + let Some(enc) = dto.password.as_deref() else { + return (None, None); + }; + let full = async { + let (_, email, _) = self.membership_address().await.ok()?; + let (address_keys, _) = self.own_address_keys(&email).await.ok()?; + let bytes = decrypt_armored_with_keys(enc, &address_keys).ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) + }; + let Some(full) = full.await else { + return (None, None); + }; + let (generated, custom) = split_link_password(&full); + let url = (!dto.public_url.is_empty()).then(|| format!("{}#{generated}", dto.public_url)); + (url, custom.map(str::to_owned)) } /// Remove a public link, revoking access via the URL @@ -4221,7 +4232,7 @@ impl ProtonDriveClient { // fragment is the generated part; the share key is locked with the // whole string. let password = String::from_utf8_lossy(&bytes).into_owned(); - let (url_password, custom_password) = split_bookmark_password(&password); + let (url_password, custom_password) = split_link_password(&password); let url = format!( "https://drive.proton.me/urls/{}#{url_password}", dto.token.token @@ -8078,9 +8089,9 @@ fn public_link_permissions(role: MemberRole) -> Result { }) } -/// Split a bookmark's stored secret into the generated URL password and the +/// Split a public link's stored secret into the generated URL password and the /// custom password appended to it, if any (TS `splitGeneratedAndCustomPassword`). -fn split_bookmark_password(password: &str) -> (&str, Option<&str>) { +fn split_link_password(password: &str) -> (&str, Option<&str>) { let split = password .char_indices() .nth(PUBLIC_LINK_PASSWORD_LEN) @@ -8984,7 +8995,7 @@ mod tests { is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links, path_segments, photos_only_in_album, record_transfer_outcome, resolve_parent_keys, run_context_share_mutation, share_membership_from_dto, small_upload_applicable, - small_upload_should_fall_back, split_bookmark_password, take_block_target, + small_upload_should_fall_back, split_link_password, take_block_target, take_thumbnail_target, to_drive_event, validate_node_name, }; use crate::dtos::{ @@ -9003,23 +9014,20 @@ mod tests { use tokio::sync::{Mutex, Notify, RwLock}; #[test] - fn a_bookmark_password_splits_into_the_generated_part_and_the_custom_one() { + fn a_link_password_splits_into_the_generated_part_and_the_custom_one() { + assert_eq!(split_link_password("AbCdEf123456"), ("AbCdEf123456", None)); assert_eq!( - split_bookmark_password("AbCdEf123456"), - ("AbCdEf123456", None) - ); - assert_eq!( - split_bookmark_password("AbCdEf123456s3cr3t!"), + split_link_password("AbCdEf123456s3cr3t!"), ("AbCdEf123456", Some("s3cr3t!")) ); // A custom password may hold any character; the split counts chars, // not bytes, and never cuts one in half. assert_eq!( - split_bookmark_password("AbCdEf123456pässwört"), + split_link_password("AbCdEf123456pässwört"), ("AbCdEf123456", Some("pässwört")) ); // A legacy password shorter than a generated one is all URL password. - assert_eq!(split_bookmark_password("short"), ("short", None)); + assert_eq!(split_link_password("short"), ("short", None)); } /// A batch envelope with one successful link and one that failed with `code`. diff --git a/crates/proton-drive-rs/src/sharing.rs b/crates/proton-drive-rs/src/sharing.rs index b3d31c3..a3f8d13 100644 --- a/crates/proton-drive-rs/src/sharing.rs +++ b/crates/proton-drive-rs/src/sharing.rs @@ -5,6 +5,8 @@ //! expose; the behavior is ported from the TypeScript SDK (`internal/sharing`). //! Reading shared-with-me nodes and leaving them lives on the client already. +use std::fmt; + use proton_sdk::crypto::VerificationStatus; use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId}; use serde::{Deserialize, Serialize}; @@ -149,7 +151,9 @@ pub struct ShareMember { } /// A public share link on a node. -#[derive(Debug, Clone)] +/// +/// `Debug` leaves out the custom password. +#[derive(Clone)] pub struct PublicLink { /// The share this link belongs to. pub share_id: ShareId, @@ -166,6 +170,31 @@ pub struct PublicLink { pub expiration_time: Option, /// Whether the link is additionally protected by a custom password. pub has_custom_password: bool, + /// The custom password itself, when the link has one and the stored link + /// password could be decrypted. Pass it back to + /// [`update_public_link`](crate::ProtonDriveClient::update_public_link) to + /// keep it while changing the role or the expiry. + pub custom_password: Option, +} + +impl fmt::Debug for PublicLink { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PublicLink") + .field("share_id", &self.share_id) + .field("public_link_id", &self.public_link_id) + .field("url", &redacted(&self.url)) + .field("role", &self.role) + .field("creation_time", &self.creation_time) + .field("expiration_time", &self.expiration_time) + .field("has_custom_password", &self.has_custom_password) + .field("custom_password", &redacted(&self.custom_password)) + .finish() + } +} + +/// What `Debug` shows for a secret: whether there is one, never its value. +fn redacted(secret: &Option) -> Option<&'static str> { + secret.as_ref().map(|_| "") } /// A pending invitation to a Proton user (not yet accepted). @@ -250,7 +279,9 @@ pub struct IncomingInvitation { /// A public link the user has saved to their account ("bookmark"). Opening it /// visits the public URL; there is no local copy. -#[derive(Debug, Clone)] +/// +/// `Debug` leaves out the URL's secret fragment and the custom password. +#[derive(Clone)] pub struct Bookmark { /// The bookmark's token — the handle for /// [`delete_bookmark`](crate::ProtonDriveClient::delete_bookmark). @@ -270,10 +301,51 @@ pub struct Bookmark { pub creation_time: i64, } +impl fmt::Debug for Bookmark { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Bookmark") + .field("token", &self.token) + .field("url", &"") + .field("custom_password", &redacted(&self.custom_password)) + .field("node_name", &self.node_name) + .field("is_folder", &self.is_folder) + .field("creation_time", &self.creation_time) + .finish() + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn debug_output_leaves_out_link_secrets() { + let link = PublicLink { + share_id: "s1".into(), + public_link_id: "l1".into(), + url: Some("https://drive.proton.me/urls/T0K3N#AbCdEf123456".into()), + role: MemberRole::Viewer, + creation_time: 1, + expiration_time: None, + has_custom_password: true, + custom_password: Some("s3cr3t".into()), + }; + let bookmark = Bookmark { + token: "T0K3N".into(), + url: "https://drive.proton.me/urls/T0K3N#AbCdEf123456".into(), + custom_password: Some("s3cr3t".into()), + node_name: Some("Holiday".into()), + is_folder: true, + creation_time: 1, + }; + for shown in [format!("{link:?}"), format!("{bookmark:?}")] { + assert!(!shown.contains("AbCdEf123456"), "{shown}"); + assert!(!shown.contains("s3cr3t"), "{shown}"); + assert!(shown.contains(""), "{shown}"); + } + assert!(format!("{bookmark:?}").contains("Holiday")); + } + #[test] fn the_strict_role_mapping_refuses_to_guess_at_an_unknown_mask() { // The three masks Proton actually uses, plus the absent case. diff --git a/crates/proton-drive-rs/tests/live_sharing.rs b/crates/proton-drive-rs/tests/live_sharing.rs index de4aea3..de89f77 100644 --- a/crates/proton-drive-rs/tests/live_sharing.rs +++ b/crates/proton-drive-rs/tests/live_sharing.rs @@ -846,6 +846,11 @@ async fn update_public_link_changes_role_password_and_expiry() { "the expiry must be stored" ); assert_eq!(fetched.url.as_deref(), Some(url.as_str())); + assert_eq!( + fetched.custom_password.as_deref(), + Some(custom), + "the custom password is recovered from the stored link password" + ); assert!( ProtonDrivePublicLinkClient::open(common::config(), &url, None) @@ -873,6 +878,7 @@ async fn update_public_link_changes_role_password_and_expiry() { "the custom password is removed" ); assert_eq!(fetched.expiration_time, None, "the expiry is cleared"); + assert_eq!(fetched.custom_password, None); ProtonDrivePublicLinkClient::open(common::config(), &url, None) .await .expect("the link opens with the URL alone again");