mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
implemented share membership permission + shared with me enumeration
This commit is contained in:
1 parent
5b38a85ccb
commit
d4bebaa2ac
6 files changed
+347
-32
No files matched your search
@@ -165,6 +165,7 @@ mod tests {
|
|||||||
is_shared: false,
|
is_shared: false,
|
||||||
is_shared_publicly: false,
|
is_shared_publicly: false,
|
||||||
signature_email: None,
|
signature_email: None,
|
||||||
|
membership: None,
|
||||||
verification: crate::node::NodeVerification::default(),
|
verification: crate::node::NodeVerification::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ use crate::dtos::{
|
|||||||
NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionConflict,
|
NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionConflict,
|
||||||
RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto,
|
RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto,
|
||||||
RevisionListResponse, RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest,
|
RevisionListResponse, RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest,
|
||||||
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareResponse,
|
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto,
|
||||||
ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse, SharedWithMeResponse,
|
ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse,
|
||||||
SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse,
|
SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest,
|
||||||
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
|
SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse,
|
||||||
TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto,
|
ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest,
|
||||||
VolumeEventListResponse, VolumeTrashResponse,
|
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
|
||||||
};
|
};
|
||||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||||
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
|
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
|
||||||
@@ -77,7 +77,7 @@ use crate::revision::{
|
|||||||
};
|
};
|
||||||
use crate::sharing::{
|
use crate::sharing::{
|
||||||
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
|
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
|
||||||
PublicLink, ShareInvitation, ShareMember,
|
PublicLink, ShareInvitation, ShareMember, ShareMembership, SharedWithMeItem,
|
||||||
};
|
};
|
||||||
use crate::single_flight::SingleFlight;
|
use crate::single_flight::SingleFlight;
|
||||||
|
|
||||||
@@ -2883,9 +2883,29 @@ impl ProtonDriveClient {
|
|||||||
/// [`enumerate_nodes`](Self::enumerate_nodes).
|
/// [`enumerate_nodes`](Self::enumerate_nodes).
|
||||||
pub async fn enumerate_shared_with_me_node_uids(&self) -> Result<Vec<NodeUid>> {
|
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 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();
|
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
|
/// 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
|
/// 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.
|
/// set's iteration order would reshuffle the page on every refresh.
|
||||||
async fn page_shared_with_me(&self) -> Result<Vec<NodeUid>> {
|
async fn page_shared_with_me(&self) -> Result<Vec<SharedWithMeItem>> {
|
||||||
let mut uids = Vec::new();
|
let mut items = Vec::new();
|
||||||
let mut shares = HashMap::new();
|
let mut shares = HashMap::new();
|
||||||
let mut anchor: Option<String> = None;
|
let mut anchor: Option<String> = None;
|
||||||
|
|
||||||
@@ -2906,14 +2926,9 @@ impl ProtonDriveClient {
|
|||||||
};
|
};
|
||||||
let page: SharedWithMeResponse = self.http.get(&path).await?;
|
let page: SharedWithMeResponse = self.http.get(&path).await?;
|
||||||
|
|
||||||
for link in &page.links {
|
for item in drive_items(&page) {
|
||||||
let is_drive_item = ShareTargetType::from_raw(link.share_target_type)
|
shares.insert(item.uid.clone(), item.share_id.clone());
|
||||||
.is_some_and(ShareTargetType::is_drive_item);
|
items.push(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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
anchor = page.anchor_id.filter(|id| !id.is_empty());
|
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);
|
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
|
/// The membership share id behind each node shared with us, keyed by the
|
||||||
@@ -5833,6 +5848,9 @@ impl ProtonDriveClient {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|sharing| sharing.share_url_id.is_some()),
|
.is_some_and(|sharing| sharing.share_url_id.is_some()),
|
||||||
signature_email: link.signature_email.clone(),
|
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,
|
verification,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -6496,22 +6514,55 @@ fn is_upload_timeout(error: &ProtonError) -> bool {
|
|||||||
matches!(error, ProtonError::Transport(e) if e.is_timeout())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, aggregate_outcomes, alternate_names,
|
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,
|
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::{
|
use crate::dtos::{
|
||||||
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, VolumeEventDto,
|
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto,
|
||||||
VolumeEventLinkDto,
|
SharedWithMeLinkDto, SharedWithMeResponse, VolumeEventDto, VolumeEventLinkDto,
|
||||||
};
|
};
|
||||||
use crate::node::RevisionState;
|
use crate::node::RevisionState;
|
||||||
|
use crate::sharing::MemberRole;
|
||||||
use proton_sdk::api::{ApiResponse, ResponseCode};
|
use proton_sdk::api::{ApiResponse, ResponseCode};
|
||||||
use proton_sdk::error::{ProtonApiError, ProtonError};
|
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`.
|
/// A batch envelope with one successful link and one that failed with `code`.
|
||||||
fn mixed_aggregate(code: ResponseCode) -> AggregateLinksResponse {
|
fn mixed_aggregate(code: ResponseCode) -> AggregateLinksResponse {
|
||||||
@@ -6570,6 +6621,53 @@ mod tests {
|
|||||||
assert!(!message.contains("ok-link"), "{message}");
|
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]
|
#[test]
|
||||||
fn only_sealed_revisions_are_listable() {
|
fn only_sealed_revisions_are_listable() {
|
||||||
assert!(is_listable_revision_state(Some(1)), "active");
|
assert!(is_listable_revision_state(Some(1)), "active");
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo};
|
|||||||
pub use revision::{Revision, RevisionReader};
|
pub use revision::{Revision, RevisionReader};
|
||||||
pub use sharing::{
|
pub use sharing::{
|
||||||
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
|
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
|
||||||
PublicLink, ShareInvitation, ShareMember,
|
PublicLink, ShareInvitation, ShareMember, ShareMembership, SharedWithMeItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use proton_sdk;
|
pub use proton_sdk;
|
||||||
@@ -4,6 +4,8 @@ use proton_sdk::crypto::VerificationStatus;
|
|||||||
use proton_sdk::ids::NodeUid;
|
use proton_sdk::ids::NodeUid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::sharing::ShareMembership;
|
||||||
|
|
||||||
/// A decrypted Drive node (folder or file).
|
/// A decrypted Drive node (folder or file).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Node {
|
pub struct Node {
|
||||||
@@ -27,6 +29,14 @@ pub struct Node {
|
|||||||
pub is_shared_publicly: bool,
|
pub is_shared_publicly: bool,
|
||||||
/// Email address that signed the node, if present.
|
/// Email address that signed the node, if present.
|
||||||
pub signature_email: Option<String>,
|
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
|
/// Per-field signature-verification results gathered while decrypting the
|
||||||
/// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`):
|
/// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`):
|
||||||
/// the node is always returned; the caller inspects this to decide trust.
|
/// 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(Some(0)), RevisionState::Active);
|
||||||
assert_eq!(RevisionState::from_raw(None), 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: true,
|
||||||
is_shared_publicly: true,
|
is_shared_publicly: true,
|
||||||
signature_email: link.name_signature_email.clone(),
|
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(),
|
verification: Default::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! 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 proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
|
use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A sharing permission role. The wire form is a permissions bitmask
|
/// A sharing permission role. The wire form is a permissions bitmask
|
||||||
/// (`Read = 4`, `Write = 2`, `Admin = 16`); Proton only uses three combinations.
|
/// (`Read = 4`, `Write = 2`, `Admin = 16`); Proton only uses three combinations.
|
||||||
@@ -23,14 +24,18 @@ pub enum MemberRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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`]
|
/// The permissions bitmask for this role, or `None` for [`MemberRole::Inherited`]
|
||||||
/// (which has no wire representation when inviting). Mirrors JS
|
/// (which has no wire representation when inviting). Mirrors JS
|
||||||
/// `memberRoleToPermission`.
|
/// `memberRoleToPermission`.
|
||||||
pub fn to_permissions(self) -> Option<i32> {
|
pub fn to_permissions(self) -> Option<i32> {
|
||||||
match self {
|
match self {
|
||||||
MemberRole::Viewer => Some(4),
|
MemberRole::Viewer => Some(Self::VIEWER_PERMISSIONS),
|
||||||
MemberRole::Editor => Some(6),
|
MemberRole::Editor => Some(Self::EDITOR_PERMISSIONS),
|
||||||
MemberRole::Admin => Some(22),
|
MemberRole::Admin => Some(Self::ADMIN_PERMISSIONS),
|
||||||
MemberRole::Inherited => None,
|
MemberRole::Inherited => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,12 +46,73 @@ impl MemberRole {
|
|||||||
pub fn from_permissions(permissions: Option<i32>) -> Self {
|
pub fn from_permissions(permissions: Option<i32>) -> Self {
|
||||||
match permissions {
|
match permissions {
|
||||||
None => MemberRole::Inherited,
|
None => MemberRole::Inherited,
|
||||||
Some(4) => MemberRole::Viewer,
|
Some(Self::VIEWER_PERMISSIONS) => MemberRole::Viewer,
|
||||||
Some(6) => MemberRole::Editor,
|
Some(Self::EDITOR_PERMISSIONS) => MemberRole::Editor,
|
||||||
Some(22) => MemberRole::Admin,
|
Some(Self::ADMIN_PERMISSIONS) => MemberRole::Admin,
|
||||||
Some(_) => MemberRole::Viewer,
|
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).
|
/// 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).
|
/// When the bookmark was created (Unix seconds).
|
||||||
pub creation_time: i64,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user