implemented share membership permission + shared with me enumeration

This commit is contained in:
nvrl committed 2026-07-28 18:35:36 +02:00
1 parent 5b38a85ccb
commit d4bebaa2ac
6 files changed
+347 -32

No files matched your search

+1
View File
@@ -165,6 +165,7 @@ mod tests {
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: crate::node::NodeVerification::default(),
}
}
+123 -25
View File
@@ -62,12 +62,12 @@ use crate::dtos::{
NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionConflict,
RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto,
RevisionListResponse, RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest,
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareResponse,
ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse, SharedWithMeResponse,
SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse,
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto,
VolumeEventListResponse, VolumeTrashResponse,
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto,
ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse,
SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest,
SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse,
ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest,
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
};
use crate::events::{DriveEvent, DriveEventScopeId};
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
@@ -77,7 +77,7 @@ use crate::revision::{
};
use crate::sharing::{
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
PublicLink, ShareInvitation, ShareMember,
PublicLink, ShareInvitation, ShareMember, ShareMembership, SharedWithMeItem,
};
use crate::single_flight::SingleFlight;
@@ -2883,9 +2883,29 @@ impl ProtonDriveClient {
/// [`enumerate_nodes`](Self::enumerate_nodes).
pub async fn enumerate_shared_with_me_node_uids(&self) -> Result<Vec<NodeUid>> {
let mut timer = self.telemetry.start("enumerate_shared_with_me_node_uids");
let uids = self.page_shared_with_me().await?;
let items = self.page_shared_with_me().await?;
timer.success();
Ok(uids)
Ok(items.into_iter().map(|item| item.uid).collect())
}
/// The nodes shared *with* me, each with the share it was granted through.
///
/// Same listing as
/// [`enumerate_shared_with_me_node_uids`](Self::enumerate_shared_with_me_node_uids),
/// but keeping the `ShareID` the wire carries: a shared node is the root of
/// the sharer's share on *their* volume, so it comes back parentless and
/// only that share's key unlocks it. A caller that mounts shared content
/// needs the share id to reason about it without re-deriving it from link
/// details.
///
/// Order is the API's and is preserved. Materialize the uids with
/// [`enumerate_nodes`](Self::enumerate_nodes) — the resulting
/// [`Node::membership`](crate::Node::membership) carries our role.
pub async fn enumerate_shared_with_me(&self) -> Result<Vec<SharedWithMeItem>> {
let mut timer = self.telemetry.start("enumerate_shared_with_me");
let items = self.page_shared_with_me().await?;
timer.success();
Ok(items)
}
/// Page `v2/sharedwithme` in the order the API returns, recording each item's
@@ -2894,8 +2914,8 @@ impl ProtonDriveClient {
///
/// Order is the API's and is preserved: it is what a front-end lists, and a
/// set's iteration order would reshuffle the page on every refresh.
async fn page_shared_with_me(&self) -> Result<Vec<NodeUid>> {
let mut uids = Vec::new();
async fn page_shared_with_me(&self) -> Result<Vec<SharedWithMeItem>> {
let mut items = Vec::new();
let mut shares = HashMap::new();
let mut anchor: Option<String> = None;
@@ -2906,14 +2926,9 @@ impl ProtonDriveClient {
};
let page: SharedWithMeResponse = self.http.get(&path).await?;
for link in &page.links {
let is_drive_item = ShareTargetType::from_raw(link.share_target_type)
.is_some_and(ShareTargetType::is_drive_item);
if is_drive_item {
let uid = NodeUid::new(link.volume_id.clone(), link.link_id.clone());
shares.insert(uid.clone(), link.share_id.clone());
uids.push(uid);
}
for item in drive_items(&page) {
shares.insert(item.uid.clone(), item.share_id.clone());
items.push(item);
}
anchor = page.anchor_id.filter(|id| !id.is_empty());
@@ -2923,7 +2938,7 @@ impl ProtonDriveClient {
}
self.cache.lock().await.shared_with_me_shares = Some(shares);
Ok(uids)
Ok(items)
}
/// The membership share id behind each node shared with us, keyed by the
@@ -5833,6 +5848,9 @@ impl ProtonDriveClient {
.as_ref()
.is_some_and(|sharing| sharing.share_url_id.is_some()),
signature_email: link.signature_email.clone(),
// Present only when the node is shared *with* us; it is what says
// whether we may write to it.
membership: details.membership.as_ref().map(share_membership_from_dto),
verification,
};
@@ -6496,22 +6514,55 @@ fn is_upload_timeout(error: &ProtonError) -> bool {
matches!(error, ProtonError::Transport(e) if e.is_timeout())
}
/// Copy the already-fetched wire membership into the public read model.
fn share_membership_from_dto(dto: &ShareMembershipSummaryDto) -> ShareMembership {
ShareMembership {
share_id: dto.share_id.clone(),
membership_id: dto.membership_id.clone(),
permissions: dto.permissions,
}
}
/// The Drive-owned items of one `v2/sharedwithme` page, in the order the API
/// returned them.
///
/// Albums and photos belong to [`ProtonPhotosClient`](crate::ProtonPhotosClient)
/// and a `Root` target is not an item, so all three are dropped — as is a target
/// type this build does not recognise, which is safer to skip than to guess at.
/// Order is preserved: it is what a front-end lists, and reshuffling it would
/// move rows around on every refresh.
fn drive_items(page: &SharedWithMeResponse) -> Vec<SharedWithMeItem> {
page.links
.iter()
.filter(|link| {
ShareTargetType::from_raw(link.share_target_type)
.is_some_and(ShareTargetType::is_drive_item)
})
.map(|link| SharedWithMeItem {
uid: NodeUid::new(link.volume_id.clone(), link.link_id.clone()),
share_id: link.share_id.clone(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{
DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, aggregate_outcomes, alternate_names,
assemble_manifest, check_aggregate, epoch_to_iso8601, is_expired_upload_token,
assemble_manifest, check_aggregate, drive_items, epoch_to_iso8601, is_expired_upload_token,
is_listable_revision_state, is_upload_timeout, join_node_path, path_segments,
small_upload_applicable, take_block_target, take_thumbnail_target, to_drive_event,
share_membership_from_dto, small_upload_applicable, take_block_target,
take_thumbnail_target, to_drive_event,
};
use crate::dtos::{
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, VolumeEventDto,
VolumeEventLinkDto,
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto,
SharedWithMeLinkDto, SharedWithMeResponse, VolumeEventDto, VolumeEventLinkDto,
};
use crate::node::RevisionState;
use crate::sharing::MemberRole;
use proton_sdk::api::{ApiResponse, ResponseCode};
use proton_sdk::error::{ProtonApiError, ProtonError};
use proton_sdk::ids::{DriveEventId, LinkId, VolumeId};
use proton_sdk::ids::{DriveEventId, LinkId, ShareId, ShareMembershipId, VolumeId};
/// A batch envelope with one successful link and one that failed with `code`.
fn mixed_aggregate(code: ResponseCode) -> AggregateLinksResponse {
@@ -6570,6 +6621,53 @@ mod tests {
assert!(!message.contains("ok-link"), "{message}");
}
#[test]
fn shared_with_me_items_keep_share_ids_order_and_drive_targets() {
let link = |link_id: &str, share_id: &str, target_type| SharedWithMeLinkDto {
volume_id: VolumeId::new("shared-volume"),
share_id: ShareId::new(share_id),
link_id: LinkId::new(link_id),
share_target_type: target_type,
};
let page = SharedWithMeResponse {
links: vec![
link("folder", "share-folder", 1),
link("album", "share-album", 3),
link("file", "share-file", 2),
link("root", "share-root", 0),
link("vendor", "share-vendor", 5),
link("future", "share-future", 99),
],
anchor_id: None,
more: false,
};
let items = drive_items(&page);
assert_eq!(items.len(), 3);
assert_eq!(items[0].uid.link_id, LinkId::new("folder"));
assert_eq!(items[0].share_id, ShareId::new("share-folder"));
assert_eq!(items[1].uid.link_id, LinkId::new("file"));
assert_eq!(items[1].share_id, ShareId::new("share-file"));
assert_eq!(items[2].uid.link_id, LinkId::new("vendor"));
assert_eq!(items[2].share_id, ShareId::new("share-vendor"));
}
#[test]
fn membership_dto_copies_raw_authority_into_the_public_shape() {
let dto = ShareMembershipSummaryDto {
share_id: ShareId::new("share-1"),
membership_id: ShareMembershipId::new("membership-1"),
permissions: 38,
};
let membership = share_membership_from_dto(&dto);
assert_eq!(membership.share_id, dto.share_id);
assert_eq!(membership.membership_id, dto.membership_id);
assert_eq!(membership.permissions, 38);
assert_eq!(membership.role(), MemberRole::Viewer);
assert_eq!(membership.role_exact(), None);
}
#[test]
fn only_sealed_revisions_are_listable() {
assert!(is_listable_revision_state(Some(1)), "active");
+1 -1
View File
@@ -62,7 +62,7 @@ pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo};
pub use revision::{Revision, RevisionReader};
pub use sharing::{
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
PublicLink, ShareInvitation, ShareMember,
PublicLink, ShareInvitation, ShareMember, ShareMembership, SharedWithMeItem,
};
pub use proton_sdk;
+57
View File
@@ -4,6 +4,8 @@ use proton_sdk::crypto::VerificationStatus;
use proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use crate::sharing::ShareMembership;
/// A decrypted Drive node (folder or file).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
@@ -27,6 +29,14 @@ pub struct Node {
pub is_shared_publicly: bool,
/// Email address that signed the node, if present.
pub signature_email: Option<String>,
/// Our membership in the share this node was reached through, when it is
/// shared *with* us — this is what says whether we may write to it. `None`
/// for nodes we own (the link details carry no `Membership` block).
///
/// `#[serde(default)]` is load-bearing: consumers persist `Node` blobs, and
/// one written before this field existed must keep deserializing.
#[serde(default)]
pub membership: Option<ShareMembership>,
/// Per-field signature-verification results gathered while decrypting the
/// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`):
/// the node is always returned; the caller inspects this to decide trust.
@@ -277,4 +287,51 @@ mod tests {
assert_eq!(RevisionState::from_raw(Some(0)), RevisionState::Active);
assert_eq!(RevisionState::from_raw(None), RevisionState::Active);
}
/// Consumers persist whole `Node` blobs (the Linux client keeps them in a
/// SQLite column), so a node written before `membership` existed has to keep
/// deserializing — otherwise adding the field bricks every cached row on
/// upgrade.
#[test]
fn a_node_round_trips_with_and_without_a_membership() {
let node = Node {
uid: NodeUid::new("vol1".into(), "link1".into()),
parent_uid: None,
kind: NodeKind::Folder,
name: "Team Budget".into(),
creation_time: 1,
modification_time: 2,
trashed: false,
is_shared: true,
is_shared_publicly: false,
signature_email: None,
membership: Some(ShareMembership {
share_id: "share-1".into(),
membership_id: "member-1".into(),
permissions: 6,
}),
verification: NodeVerification::default(),
};
let back: Node = serde_json::from_str(&serde_json::to_string(&node).unwrap()).unwrap();
let membership = back.membership.expect("membership survives the round trip");
assert_eq!(membership.role_exact(), Some(crate::MemberRole::Editor));
assert_eq!(membership.share_id.as_str(), "share-1");
// A blob written before the field existed reads back as "not shared with
// us", which is the same thing an owned node says.
let legacy = r#"{
"uid": {"volume_id": "vol1", "link_id": "link1"},
"parent_uid": null,
"kind": "Folder",
"name": "My Documents",
"creation_time": 1,
"modification_time": 2,
"trashed": false,
"signature_email": null
}"#;
let back: Node = serde_json::from_str(legacy).unwrap();
assert!(back.membership.is_none());
assert_eq!(back.name, "My Documents");
}
}
@@ -553,6 +553,9 @@ impl ProtonDrivePublicLinkClient {
is_shared: true,
is_shared_publicly: true,
signature_email: link.name_signature_email.clone(),
// A public-link visitor has no account membership — access comes
// from the link's own share password, not from a share member row.
membership: None,
verification: Default::default(),
})
}
+162 -6
View File
@@ -6,6 +6,7 @@
//! Reading shared-with-me nodes and leaving them lives on the client already.
use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
use serde::{Deserialize, Serialize};
/// A sharing permission role. The wire form is a permissions bitmask
/// (`Read = 4`, `Write = 2`, `Admin = 16`); Proton only uses three combinations.
@@ -23,14 +24,18 @@ pub enum MemberRole {
}
impl MemberRole {
const VIEWER_PERMISSIONS: i32 = 4;
const EDITOR_PERMISSIONS: i32 = 6;
const ADMIN_PERMISSIONS: i32 = 22;
/// The permissions bitmask for this role, or `None` for [`MemberRole::Inherited`]
/// (which has no wire representation when inviting). Mirrors JS
/// `memberRoleToPermission`.
pub fn to_permissions(self) -> Option<i32> {
match self {
MemberRole::Viewer => Some(4),
MemberRole::Editor => Some(6),
MemberRole::Admin => Some(22),
MemberRole::Viewer => Some(Self::VIEWER_PERMISSIONS),
MemberRole::Editor => Some(Self::EDITOR_PERMISSIONS),
MemberRole::Admin => Some(Self::ADMIN_PERMISSIONS),
MemberRole::Inherited => None,
}
}
@@ -41,12 +46,73 @@ impl MemberRole {
pub fn from_permissions(permissions: Option<i32>) -> Self {
match permissions {
None => MemberRole::Inherited,
Some(4) => MemberRole::Viewer,
Some(6) => MemberRole::Editor,
Some(22) => MemberRole::Admin,
Some(Self::VIEWER_PERMISSIONS) => MemberRole::Viewer,
Some(Self::EDITOR_PERMISSIONS) => MemberRole::Editor,
Some(Self::ADMIN_PERMISSIONS) => MemberRole::Admin,
Some(_) => MemberRole::Viewer,
}
}
/// Like [`from_permissions`](Self::from_permissions), but `None` for a mask
/// this build does not recognise instead of degrading it to
/// [`MemberRole::Viewer`].
///
/// The lenient mapping is right for display — the holder can at least read —
/// but exact decoding preserves "we do not know" for permission enforcement.
/// P2 can then deny writes while logging or otherwise handling the unknown
/// mask explicitly.
pub fn from_permissions_exact(permissions: Option<i32>) -> Option<Self> {
match permissions {
None => Some(MemberRole::Inherited),
Some(Self::VIEWER_PERMISSIONS) => Some(MemberRole::Viewer),
Some(Self::EDITOR_PERMISSIONS) => Some(MemberRole::Editor),
Some(Self::ADMIN_PERMISSIONS) => Some(MemberRole::Admin),
Some(_) => None,
}
}
}
/// Our membership in a share someone else owns — the wire's
/// `LinkDetailsDto.Membership`, which says what *we* are allowed to do with a
/// node shared with us.
///
/// Rides on [`Node::membership`](crate::Node::membership) for anything reached
/// through a share, and is `None` for nodes we own.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareMembership {
/// The share the node was granted through.
pub share_id: ShareId,
/// Our membership in it (the handle for leaving the share).
pub membership_id: ShareMembershipId,
/// The raw permissions bitmask, kept so an unrecognised value can still be
/// logged or round-tripped rather than being flattened into a role.
pub permissions: i32,
}
impl ShareMembership {
/// Decode the raw permissions using the existing lenient mapping.
pub fn role(&self) -> MemberRole {
MemberRole::from_permissions(Some(self.permissions))
}
/// Decode the raw permissions without guessing at an unrecognised mask.
pub fn role_exact(&self) -> Option<MemberRole> {
MemberRole::from_permissions_exact(Some(self.permissions))
}
}
/// One item from the shared-with-me listing, with the share it was granted
/// through.
///
/// The share id matters because a shared node is the **root of the sharer's
/// share on the sharer's volume**: it comes back parentless and only that
/// share's key unlocks it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedWithMeItem {
/// The shared node.
pub uid: NodeUid,
/// The share the node was granted through.
pub share_id: ShareId,
}
/// A member of a share (an invitation that has been accepted).
@@ -182,3 +248,93 @@ pub struct Bookmark {
/// When the bookmark was created (Unix seconds).
pub creation_time: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_strict_role_mapping_refuses_to_guess_at_an_unknown_mask() {
// The three masks Proton actually uses, plus the absent case.
for (mask, want) in [
(None, MemberRole::Inherited),
(Some(4), MemberRole::Viewer),
(Some(6), MemberRole::Editor),
(Some(22), MemberRole::Admin),
] {
assert_eq!(MemberRole::from_permissions_exact(mask), Some(want));
assert_eq!(MemberRole::from_permissions(mask), want);
}
// An unrecognised mask is where the two mappings part company: the
// lenient one degrades to Viewer so the holder can at least read, the
// strict one admits it does not know so an enforcing caller can fail
// closed instead of silently denying a real editor.
for mask in [0, 2, 8, 16, 30, -1] {
assert_eq!(MemberRole::from_permissions_exact(Some(mask)), None);
assert_eq!(MemberRole::from_permissions(Some(mask)), MemberRole::Viewer);
}
}
#[test]
fn a_membership_decodes_roles_from_its_raw_permissions() {
let membership = |permissions| ShareMembership {
share_id: "s1".into(),
membership_id: "m1".into(),
permissions,
};
let editor = membership(6);
assert_eq!(editor.role(), MemberRole::Editor);
assert_eq!(editor.role_exact(), Some(MemberRole::Editor));
let viewer = membership(4);
assert_eq!(viewer.role(), MemberRole::Viewer);
assert_eq!(viewer.role_exact(), Some(MemberRole::Viewer));
// A future permission bit remains available to callers. The lenient
// display mapping and exact decoder are derived each time.
let unknown = membership(38);
assert_eq!(unknown.role(), MemberRole::Viewer);
assert_eq!(unknown.role_exact(), None);
assert_eq!(unknown.permissions, 38);
}
#[test]
fn membership_serde_stores_only_authoritative_data() {
let membership = ShareMembership {
share_id: "share-1".into(),
membership_id: "member-1".into(),
permissions: 22,
};
let json = serde_json::to_value(&membership).unwrap();
assert_eq!(
json,
serde_json::json!({
"share_id": "share-1",
"membership_id": "member-1",
"permissions": 22
})
);
let back: ShareMembership = serde_json::from_value(json).unwrap();
assert_eq!(back, membership);
assert_eq!(back.role(), MemberRole::Admin);
assert_eq!(back.role_exact(), Some(MemberRole::Admin));
}
#[test]
fn forged_serialized_roles_cannot_override_raw_permissions() {
let json = serde_json::json!({
"share_id": "share-1",
"membership_id": "member-1",
"permissions": 38,
"role": "admin",
"role_exact": "admin"
});
let membership: ShareMembership = serde_json::from_value(json).unwrap();
assert_eq!(membership.permissions, 38);
assert_eq!(membership.role(), MemberRole::Viewer);
assert_eq!(membership.role_exact(), None);
}
}