feat(sharing): expose a public link's custom password

get_public_link recovers the custom password along with the URL fragment,
so a caller can keep it while update_public_link changes the role or the
expiry. Debug for PublicLink and Bookmark redacts the URL and the custom
password.
This commit is contained in:
nvrl committed 2026-10-25 22:01:13 +01:00
1 parent 781301a77b
commit 2c5d62e9cf
4 files changed
+123 -32

No files matched your search

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