From d4bebaa2ac9190afe256b71e6f71c363fc0be2a6 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 28 Jul 2026 18:35:36 +0200 Subject: [PATCH] implemented share membership permission + shared with me enumeration --- crates/proton-drive-rs/src/cache.rs | 1 + crates/proton-drive-rs/src/client.rs | 148 +++++++++++++++---- crates/proton-drive-rs/src/lib.rs | 2 +- crates/proton-drive-rs/src/node.rs | 57 ++++++++ crates/proton-drive-rs/src/public_link.rs | 3 + crates/proton-drive-rs/src/sharing.rs | 168 +++++++++++++++++++++- 6 files changed, 347 insertions(+), 32 deletions(-) diff --git a/crates/proton-drive-rs/src/cache.rs b/crates/proton-drive-rs/src/cache.rs index 341fd20..b73bc54 100644 --- a/crates/proton-drive-rs/src/cache.rs +++ b/crates/proton-drive-rs/src/cache.rs @@ -165,6 +165,7 @@ mod tests { is_shared: false, is_shared_publicly: false, signature_email: None, + membership: None, verification: crate::node::NodeVerification::default(), } } diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index c196718..980a860 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -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> { 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> { + 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> { - let mut uids = Vec::new(); + async fn page_shared_with_me(&self) -> Result> { + let mut items = Vec::new(); let mut shares = HashMap::new(); let mut anchor: Option = 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 { + 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"); diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index 90d9050..5904399 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -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; diff --git a/crates/proton-drive-rs/src/node.rs b/crates/proton-drive-rs/src/node.rs index 4d8257a..55ee6e9 100644 --- a/crates/proton-drive-rs/src/node.rs +++ b/crates/proton-drive-rs/src/node.rs @@ -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, + /// 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, /// 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"); + } } diff --git a/crates/proton-drive-rs/src/public_link.rs b/crates/proton-drive-rs/src/public_link.rs index b81787e..9e5c932 100644 --- a/crates/proton-drive-rs/src/public_link.rs +++ b/crates/proton-drive-rs/src/public_link.rs @@ -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(), }) } diff --git a/crates/proton-drive-rs/src/sharing.rs b/crates/proton-drive-rs/src/sharing.rs index 25f2cab..08178fb 100644 --- a/crates/proton-drive-rs/src/sharing.rs +++ b/crates/proton-drive-rs/src/sharing.rs @@ -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 { 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) -> 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) -> Option { + 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::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); + } +}