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
+126 -35

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
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 `<redacted>` 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.
+39 -31
View File
@@ -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<String> {
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<String>, Option<String>) {
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<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`).
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`.
+74 -2
View File
@@ -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<i64>,
/// 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<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).
@@ -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", &"<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)]
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("<redacted>"), "{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.
@@ -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");