From 7c9d16b405977fafd634c3dddefede45ed864401 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 14 Jul 2026 15:03:06 +0200 Subject: [PATCH] added device support + sharing --- UPSTREAM_SYNC.md | 11 +- crates/proton-drive-rs/src/cache.rs | 2 + crates/proton-drive-rs/src/client.rs | 337 ++++++++++++++++++++++++-- crates/proton-drive-rs/src/devices.rs | 104 ++++++++ crates/proton-drive-rs/src/dtos.rs | 258 +++++++++++++++++++- crates/proton-drive-rs/src/lib.rs | 4 +- crates/proton-drive-rs/src/node.rs | 48 ++++ crates/proton-sdk/src/ids.rs | 12 + scripts/upstream-sync.sh | 20 +- 9 files changed, 771 insertions(+), 25 deletions(-) create mode 100644 crates/proton-drive-rs/src/devices.rs diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index 1cc80d1..d97e910 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -5,9 +5,13 @@ Tracks reconciliation of this pure-Rust port against the canonical **C#** SDK gitignored, so this file is the only durable record of what we last reviewed. - **Upstream**: https://github.com/ProtonDriveApps/sdk -- **Reconciled subtree**: `client/cs/sdk/src` -- **Pinned**: `f2496161c2f704b72511aa1b804961285993850c` -- **Date**: 2026-07-10 +- **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the + mid-2026 projects reorg; the script watches both) +- **Pinned**: `2219a42f018cce42d156379fbda2405034c7779a` +- **Date**: 2026-07-14 + +> **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer +> exist in its history. Triage by commit subject/date when a pin goes stale. ## Workflow @@ -24,3 +28,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 2026-06-25 | initial pin `fb4173ab` | — | baseline; delta reviewed, 0 behavioral changes outstanding | | 2026-06-26 | `fb4173ab` → `36430318` | `36430318` | ported: enumeration returns `NodeUid`s (`enumerate_folder_children_node_uids` / `enumerate_trash_node_uids`), caller materializes via `enumerate_nodes`. noise dropped: BOM/deps/kt-enum-order/cs-account-refactor | | 2026-07-10 | `36430318` → `f2496161` | `9a1c39b3` | ported: validation check in `Thumbnail::new` returning `Result` instead of panicking. noise dropped: projects reorg / C# Account client move to incubating. deferred: device support (`32a8eed0`). | +| 2026-07-14 | `f2496161` → `2219a42f` | `d15fa9b9`, `bead8f65`, `f6b12911`, `c0940f52`, `b88b8041`, `2eb69c46` | upstream force-pushed + moved the cs subtree, so the old pin was dangling and the script silently reported "up to date" (both fixed). ported: **devices** (`devices.rs` + `enumerate_devices` / `create_device` / `rename_device` / `delete_device`; volume-free `DeviceUid`), **node sharing flags** (`Node::is_shared` / `is_shared_publicly` from the link `Sharing` block), **revision state** (`NodeKind::File::active_revision_state`), **shared-with-me** (`enumerate_shared_with_me_node_uids`, `GET v2/sharedwithme`), **leave shared node** (`leave_shared_node`, `DELETE v2/shares/{sid}/members/{mid}`). no-op: `15fa845e` (non-JSON error bodies — `http.rs` already falls back to a status-only error when the envelope won't parse). diverged deliberately: `9a7f1d02` + `b4dadf02` removed the C# entity cache and its tags; ours stays. cosmetic, not ported: `30ab32c1` / `388d3900` (C# field renames), `1a781ade` (buffer lengths). | diff --git a/crates/proton-drive-rs/src/cache.rs b/crates/proton-drive-rs/src/cache.rs index d09c4db..341fd20 100644 --- a/crates/proton-drive-rs/src/cache.rs +++ b/crates/proton-drive-rs/src/cache.rs @@ -162,6 +162,8 @@ mod tests { creation_time: 1, modification_time: 2, trashed: false, + is_shared: false, + is_shared_publicly: false, signature_email: 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 99d9003..e21c24c 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -18,7 +18,10 @@ use proton_sdk::crypto::{ }; use proton_sdk::error::{ProtonError, Result}; use proton_sdk::http::ApiHttpClient; -use proton_sdk::ids::{AddressId, DriveEventId, LinkId, NodeUid, ShareId, VolumeId}; +use proton_sdk::ids::{ + AddressId, AddressKeyId, DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, ShareMembershipId, + VolumeId, +}; use proton_sdk::session::ProtonApiSession; use proton_sdk::telemetry::{NoopTelemetry, Telemetry, TelemetryExt}; @@ -30,22 +33,26 @@ use crate::crypto::{ decrypt_content_key_verified, decrypt_extended_attributes_verified, decrypt_link, decrypt_link_verified, decrypt_share_key, }; +use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::dtos::{ AggregateLinksResponse, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockVerificationInputResponse, BlockVerifier, - CommonExtendedAttributes, ExtendedAttributes, FileContentDigests, FileCreationRequest, - FileCreationResponse, FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse, - LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, - LinkType, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest, - MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, - NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionCreationRequest, - RevisionCreationResponse, RevisionDto, RevisionResponse, RevisionUpdateRequest, + CommonExtendedAttributes, DeviceCreationDeviceDto, DeviceCreationLinkDto, + DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, + DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, FileContentDigests, + FileCreationRequest, FileCreationResponse, FolderChildrenResponse, FolderCreationRequest, + FolderCreationResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, + LinkDetailsResponse, LinkDto, LinkType, MoveLinkRequest, MoveMultipleLinksItem, + MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, + NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotosAttributesDto, + RenameLinkRequest, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, + RevisionResponse, RevisionUpdateRequest, ShareResponse, ShareTargetType, SharedWithMeResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; -use crate::node::{FileThumbnail, Node, NodeKind, Thumbnail, ThumbnailType}; +use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem}; /// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`). @@ -1555,8 +1562,294 @@ impl ProtonDriveClient { Ok(()) } + /// The nodes other users share with us, as [`NodeUid`]s. + /// + /// Mirrors C# `SharingOperations.EnumerateSharedWithMeNodeUidsAsync`: page + /// `GET v2/sharedwithme` on its `AnchorID` cursor and keep only the target + /// types the Drive client owns (folder / file / vendor) — albums and photos + /// belong to the Photos client. Materialize the uids with + /// [`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 mut uids = Vec::new(); + let mut anchor: Option = None; + + loop { + let path = match &anchor { + Some(anchor_id) => format!("v2/sharedwithme?AnchorID={anchor_id}"), + None => "v2/sharedwithme".to_string(), + }; + 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 { + uids.push(NodeUid::new(link.volume_id.clone(), link.link_id.clone())); + } + } + + anchor = page.anchor_id.filter(|id| !id.is_empty()); + if !page.more || anchor.is_none() { + break; + } + } + + timer.success(); + Ok(uids) + } + + /// Leave a node someone shared with us, giving up access to it. + /// + /// Mirrors C# `SharingOperations.LeaveSharedNodeAsync`: read the node's link + /// details, take our membership in the sharer's share, and delete it + /// (`DELETE v2/shares/{sid}/members/{mid}`). Errors when the node is not + /// shared with us — there is nothing to leave (C# throws `ValidationException`). + pub async fn leave_shared_node(&self, uid: &NodeUid) -> Result<()> { + let mut timer = self.telemetry.start("leave_shared_node"); + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let membership = details + .links + .into_iter() + .find(|detail| detail.link.id == uid.link_id) + .and_then(|detail| detail.membership) + .ok_or_else(|| { + ProtonError::invalid_operation("you can leave only an item that is shared with you") + })?; + + self.remove_share_member(&membership.share_id, &membership.membership_id) + .await?; + timer.success(); + Ok(()) + } + + /// The account's registered devices, with their root-folder names decrypted. + /// + /// Mirrors C# `DeviceOperations.EnumerateDevicesAsync` (`GET devices` then + /// resolve each root folder's name). A device's root folder lives in its own + /// share, so the name is decrypted with that share's key rather than the My + /// Files one; a per-device name failure is carried in [`Device::name`] rather + /// than failing the enumeration. Each resolved root-folder key is cached, so + /// enumerating a device's children works straight afterwards. + pub async fn enumerate_devices(&self) -> Result> { + let mut timer = self.telemetry.start("enumerate_devices"); + let metadata = self.device_metadata().await?; + + let mut devices = Vec::with_capacity(metadata.len()); + for device in metadata { + let name = self.device_root_folder_name(&device).await; + devices.push(device.into_device(name)); + } + + timer.success(); + Ok(devices) + } + + /// Register a new device with its own share and root folder. + /// + /// Mirrors C# `DeviceOperations.CreateDeviceAsync` / `DeviceCrypto`: generate + /// a share key and a root-folder key, wrap the share passphrase to the My + /// Files membership address key, the folder passphrase to the share key, and + /// the folder name + hash key to the share/folder keys, then `POST devices`. + /// The crypto material is exactly a volume's root share + root folder, so it + /// is built by the same `build_volume_creation_material` — with the device + /// name in place of the root folder name. Live validation pending. + pub async fn create_device(&self, name: &str, device_type: DeviceType) -> Result { + let mut timer = self.telemetry.start("create_device"); + let root = self.get_my_files_folder().await?; + let volume_id = root.uid.volume_id.clone(); + + let (address_id, _email, address_key) = self.membership_address().await?; + let address_key_id = self.address_primary_key_id(&address_id).await?; + + let material = build_volume_creation_material(&address_key, name)?; + + let request = DeviceCreationRequest { + device: DeviceCreationDeviceDto { + device_type: device_type.as_i32(), + sync_state: 0, + }, + share: DeviceCreationShareDto { + address_id, + address_key_id, + key: material.share_key_armored, + passphrase: material.share_passphrase, + passphrase_signature: material.share_passphrase_signature, + }, + link: DeviceCreationLinkDto { + name: material.folder_name, + key: material.folder_key_armored, + passphrase: material.folder_passphrase, + passphrase_signature: material.folder_passphrase_signature, + node_hash_key: material.folder_hash_key, + }, + }; + + let created: DeviceCreationResponse = self.http.post("devices", &request).await?; + + timer.success(); + Ok(Device { + uid: created.device.id, + device_type, + name: Ok(name.to_string()), + root_folder_uid: NodeUid::new(volume_id, created.device.root_link_id), + creation_time: now_epoch_seconds(), + last_sync_time: None, + share_id: created.device.share_id, + }) + } + + /// Rename a device, i.e. rename its root folder. + /// + /// Mirrors C# `DeviceOperations.RenameDeviceAsync`. A root node has no parent + /// and no siblings, so — unlike [`rename_node`](Self::rename_node) — the name + /// is encrypted to the device's *share* key and carries no name hash. Devices + /// registered before the name moved onto the root folder also keep a copy on + /// the share; that copy is cleared first (best-effort, as in C#). + pub async fn rename_device(&self, device_uid: &DeviceUid, name: &str) -> Result { + let mut timer = self.telemetry.start("rename_device"); + let device = self.device_metadata_by_uid(device_uid).await?; + + if device.has_deprecated_name { + let request = DeviceUpdateRequest { + share: DeviceUpdateShareDto { + name: String::new(), + }, + }; + let path = format!("devices/{device_uid}"); + let removed: Result = + self.http.put(&path, &request).await; + if let Err(e) = removed { + tracing::warn!(device_uid = %device_uid, error = %e, "failed to remove deprecated device name"); + } + } + + let (share_key, membership_address_id) = self.share_key_by_id(&device.share_id).await?; + let (_address_id, email, signing_key) = self + .resolve_membership_address(membership_address_id) + .await?; + + let encrypted_name = + share_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?; + let request = RenameLinkRequest { + name: encrypted_name, + name_hash: String::new(), + name_signature_email: email, + media_type: None, + original_hash: String::new(), + }; + let path = format!( + "v2/volumes/{}/links/{}/rename", + device.root_folder_uid.volume_id, device.root_folder_uid.link_id + ); + let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; + + timer.success(); + Ok(device.into_device(Ok(name.to_string()))) + } + + /// Delete a device and its root folder (`DELETE devices/{uid}`). + /// Mirrors C# `DeviceOperations.DeleteDeviceAsync`. + pub async fn delete_device(&self, device_uid: &DeviceUid) -> Result<()> { + let mut timer = self.telemetry.start("delete_device"); + let path = format!("devices/{device_uid}"); + let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; + timer.success(); + Ok(()) + } + // ---- internals --------------------------------------------------------- + /// `DELETE v2/shares/{sid}/members/{mid}` — drop a membership from a share. + /// C# `SharesApiClient.RemoveMemberAsync`. + async fn remove_share_member( + &self, + share_id: &ShareId, + membership_id: &ShareMembershipId, + ) -> Result<()> { + let path = format!("v2/shares/{share_id}/members/{membership_id}"); + let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; + Ok(()) + } + + /// All registered devices, without their (decrypted) names. + /// C# `DeviceOperations.GetDeviceMetadataAsync`. + async fn device_metadata(&self) -> Result> { + let response: DeviceListResponse = self.http.get("devices").await?; + + let mut devices = Vec::with_capacity(response.devices.len()); + for item in response.devices { + devices.push(DeviceMetadata { + uid: item.device.id, + device_type: DeviceType::from_raw(item.device.device_type)?, + root_folder_uid: NodeUid::new(item.device.volume_id, item.share.root_link_id), + creation_time: item.device.creation_time, + last_sync_time: item.device.last_sync_time, + has_deprecated_name: item.share.name.is_some_and(|name| !name.is_empty()), + share_id: item.share.id, + }); + } + Ok(devices) + } + + async fn device_metadata_by_uid(&self, device_uid: &DeviceUid) -> Result { + self.device_metadata() + .await? + .into_iter() + .find(|device| &device.uid == device_uid) + .ok_or_else(|| ProtonError::invalid_operation(format!("device {device_uid} not found"))) + } + + /// Decrypt a device's name: the name of its root folder, which is encrypted + /// to the device's own share key. Caches the root-folder key so the device's + /// children can be enumerated without re-resolving the share. + async fn device_root_folder_name(&self, device: &DeviceMetadata) -> Result { + let (share_key, _address_id) = self.share_key_by_id(&device.share_id).await?; + + let uid = &device.root_folder_uid; + let details = self + .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) + .await?; + let link = &details + .links + .first() + .ok_or_else(|| { + ProtonError::invalid_operation(format!("device root folder {uid} not found")) + })? + .link; + + let decrypted = decrypt_link(&share_key, link)?; + self.cache + .lock() + .await + .folder_keys + .insert(uid.clone(), decrypted.node_key); + Ok(decrypted.name) + } + + /// Fetch a share by id and unlock its key, returning it with the share's + /// membership address id. C# `ShareOperations.GetShareAsync`. + async fn share_key_by_id(&self, share_id: &ShareId) -> Result<(PrivateKey, AddressId)> { + let path = format!("shares/{share_id}"); + let response: ShareResponse = self.http.get(&path).await?; + let key = decrypt_share_key(&self.account, &response.share).await?; + Ok((key, response.share.address_id)) + } + + /// The `AddressKeyID` of an address's primary key (write requests that bind + /// material to an address key need it alongside the `AddressID`). + async fn address_primary_key_id(&self, address_id: &AddressId) -> Result { + self.account + .addresses() + .await? + .into_iter() + .find(|address| &address.id == address_id) + .map(|address| address.primary_key_id) + .ok_or_else(|| ProtonError::invalid_operation("membership address not found")) + } + /// Create a fresh-file draft: generate a node key + content key, encrypt the /// name/passphrase/content-key-packet to the parent, and POST the draft. /// Mirrors C# `NewFileDraftProvider`. @@ -2625,6 +2918,10 @@ impl ProtonDriveClient { NodeKind::File { media_type: file.media_type.clone(), total_size_on_storage: file.total_size_on_storage, + active_revision_state: file + .active_revision + .as_ref() + .map(|rev| RevisionState::from_raw(rev.state)), claimed_size, claimed_modification_time, } @@ -2637,6 +2934,8 @@ impl ProtonDriveClient { } }; + // C# `DtoToMetadataConverter`: the `Sharing` block's presence marks the + // node as shared; a `ShareURLID` inside it marks it as publicly shared. let node = Node { uid: uid.clone(), parent_uid, @@ -2645,6 +2944,11 @@ impl ProtonDriveClient { creation_time: link.creation_time, modification_time: link.modification_time, trashed: link.is_trashed(), + is_shared: details.sharing.is_some(), + is_shared_publicly: details + .sharing + .as_ref() + .is_some_and(|sharing| sharing.share_url_id.is_some()), signature_email: link.signature_email.clone(), verification, }; @@ -2785,6 +3089,14 @@ fn is_my_files_missing(error: &ProtonError) -> bool { ) } +/// The current Unix epoch, in seconds. +fn now_epoch_seconds() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + /// Format a Unix epoch (seconds, UTC) as an ISO-8601 `YYYY-MM-DDTHH:MM:SSZ` /// string for the `ExtendedAttributes.ModificationTime` field. C# writes the /// round-trip ("O") format; this drops the fractional-second component, which @@ -2904,12 +3216,7 @@ fn build_photos_attributes( written: &BlockWriteResult, metadata: &PhotoUploadMetadata, ) -> PhotosAttributesDto { - let capture_time = metadata.capture_time.unwrap_or_else(|| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) - }); + let capture_time = metadata.capture_time.unwrap_or_else(now_epoch_seconds); PhotosAttributesDto { capture_time, content_hash: hex::encode(hmac_sha256(parent_hash_key, written.sha1_hex.as_bytes())), diff --git a/crates/proton-drive-rs/src/devices.rs b/crates/proton-drive-rs/src/devices.rs new file mode 100644 index 0000000..9e59c1a --- /dev/null +++ b/crates/proton-drive-rs/src/devices.rs @@ -0,0 +1,104 @@ +//! Devices: the synced folders a desktop client registers on the main volume. +//! +//! Mirrors C# `Proton.Drive.Sdk.Devices`. A device owns its own share and root +//! folder inside the main volume; the device *name* is the name of that root +//! folder (older devices kept it on the share instead — see +//! [`Device::has_deprecated_name`] handling in `ProtonDriveClient::rename_device`). + +use proton_sdk::error::{ProtonError, Result}; +use proton_sdk::ids::{DeviceUid, NodeUid, ShareId}; + +/// The platform a device runs on. Mirrors C# `DeviceType`; the discriminant is +/// the wire value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum DeviceType { + Windows = 1, + MacOs = 2, + Linux = 3, +} + +impl DeviceType { + pub fn as_i32(self) -> i32 { + self as i32 + } + + pub(crate) fn from_raw(value: i32) -> Result { + match value { + 1 => Ok(Self::Windows), + 2 => Ok(Self::MacOs), + 3 => Ok(Self::Linux), + other => Err(ProtonError::invalid_operation(format!( + "unsupported device type {other}" + ))), + } + } +} + +/// A registered device. Mirrors C# `Device`. +/// +/// `name` is a `Result` because it is decrypted with the device's share key and +/// may fail independently of the rest of the device (C# models it as +/// `Result`): the device is still returned. +#[derive(Debug)] +pub struct Device { + pub uid: DeviceUid, + pub device_type: DeviceType, + /// The decrypted device name, i.e. the name of its root folder. + pub name: Result, + /// The device's root folder — the sync root. + pub root_folder_uid: NodeUid, + /// Creation time, epoch seconds. + pub creation_time: i64, + /// Last time the device synced, epoch seconds; `None` if it never did. + pub last_sync_time: Option, + /// The device's own share. Kept because a device root is not reachable from + /// the My Files share key (C# `Device.ShareId`, deprecated there pending + /// volume-based navigation, but still the only way to unlock the root). + pub share_id: ShareId, +} + +/// A device without its (decrypted) name. Mirrors C# `DeviceMetadata`. +#[derive(Debug, Clone)] +pub(crate) struct DeviceMetadata { + pub uid: DeviceUid, + pub device_type: DeviceType, + pub root_folder_uid: NodeUid, + pub creation_time: i64, + pub last_sync_time: Option, + /// The device name used to live on the share. Old devices still carry it + /// there, and it must be removed when renaming. + pub has_deprecated_name: bool, + pub share_id: ShareId, +} + +impl DeviceMetadata { + pub(crate) fn into_device(self, name: Result) -> Device { + Device { + uid: self.uid, + device_type: self.device_type, + name, + root_folder_uid: self.root_folder_uid, + creation_time: self.creation_time, + last_sync_time: self.last_sync_time, + share_id: self.share_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_type_round_trips_through_the_wire_value() { + for device_type in [DeviceType::Windows, DeviceType::MacOs, DeviceType::Linux] { + assert_eq!( + DeviceType::from_raw(device_type.as_i32()).expect("known device type"), + device_type + ); + } + assert!(DeviceType::from_raw(0).is_err()); + assert!(DeviceType::from_raw(4).is_err()); + } +} diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 0399f9b..8409475 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -6,7 +6,9 @@ use serde::{Deserialize, Serialize}; -use proton_sdk::ids::{AddressId, AddressKeyId, DriveEventId, LinkId, ShareId, VolumeId}; +use proton_sdk::ids::{ + AddressId, AddressKeyId, DeviceUid, DriveEventId, LinkId, ShareId, ShareMembershipId, VolumeId, +}; /// `GET v2/shares/my-files` #[derive(Debug, Deserialize)] @@ -105,6 +107,34 @@ pub struct LinkDetailsDto { /// photo fields are ignored. C# `linkDetailsDto.File ?? linkDetailsDto.Photo`. #[serde(rename = "Photo")] pub photo: Option, + /// Present when the node is shared (with members and/or via a public link). + #[serde(rename = "Sharing", default)] + pub sharing: Option, + /// Present when the node is shared *with us*: our membership in the sharer's + /// share. Carries the ids needed to leave the share. + #[serde(rename = "Membership", default)] + pub membership: Option, +} + +/// The sharing state of a node (C# `LinkSharingDto`). Its mere presence means +/// the node is shared; a `ShareURLID` means it is also shared by public link. +#[derive(Debug, Deserialize)] +pub struct LinkSharingDto { + #[serde(rename = "ShareID")] + pub share_id: ShareId, + #[serde(rename = "ShareURLID", default)] + pub share_url_id: Option, +} + +/// Our membership in a share someone else owns (C# `ShareMembershipSummaryDto`). +#[derive(Debug, Deserialize)] +pub struct ShareMembershipSummaryDto { + #[serde(rename = "ShareID")] + pub share_id: ShareId, + #[serde(rename = "MembershipID")] + pub membership_id: ShareMembershipId, + #[serde(rename = "Permissions", default)] + pub permissions: i32, } impl LinkDetailsDto { @@ -190,6 +220,11 @@ pub struct ActiveRevisionDto { pub id: String, #[serde(rename = "CreateTime")] pub creation_time: i64, + /// Wire revision state (C# `ApiRevisionState`): 0 draft, 1 active, 2 obsolete. + /// Absent on older responses; a link's active revision is Active by + /// definition, which is what C# `DtoToMetadataConverter` records. + #[serde(rename = "State", default)] + pub state: Option, #[serde(rename = "EncryptedSize")] pub encrypted_size: i64, /// Email of the revision signer; empty/absent means the node key signed. @@ -863,3 +898,224 @@ pub struct VolumeEventLinkDto { #[serde(rename = "IsTrashed", default)] pub is_trashed: bool, } + +/// `GET shares/{sid}` — a share and the material needed to unlock its key. +/// C# `ShareResponse`; the share fields sit at the top level of the envelope, so +/// they are flattened into the same [`ShareDto`] the my-files lookup returns. +#[derive(Debug, Deserialize)] +pub struct ShareResponse { + #[serde(flatten)] + pub share: ShareDto, + #[serde(rename = "VolumeID")] + pub volume_id: VolumeId, + #[serde(rename = "LinkID")] + pub root_link_id: LinkId, +} + +/// `GET v2/sharedwithme` — one page of the items other users share with us. +/// C# `SharedWithMeResponse`. +#[derive(Debug, Deserialize)] +pub struct SharedWithMeResponse { + #[serde(rename = "Links", default)] + pub links: Vec, + /// Cursor for the next page. + #[serde(rename = "AnchorID", default)] + pub anchor_id: Option, + #[serde(rename = "More", default)] + pub more: bool, +} + +/// One shared-with-me item. C# `SharedWithMeLinkDto`. +#[derive(Debug, Deserialize)] +pub struct SharedWithMeLinkDto { + #[serde(rename = "VolumeID")] + pub volume_id: VolumeId, + #[serde(rename = "ShareID")] + pub share_id: ShareId, + #[serde(rename = "LinkID")] + pub link_id: LinkId, + /// What kind of item is shared. See [`ShareTargetType`]. + #[serde(rename = "ShareTargetType", default)] + pub share_target_type: i32, +} + +/// The kind of item a share points at. C# `ShareTargetType`. +/// +/// The Drive client exposes folders, files and vendor items; albums and photos +/// belong to the Photos client (C# `SharingOperations.DriveShareTargetTypes`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum ShareTargetType { + Root = 0, + Folder = 1, + File = 2, + Album = 3, + Photo = 4, + ProtonVendor = 5, +} + +impl ShareTargetType { + pub fn from_raw(value: i32) -> Option { + match value { + 0 => Some(Self::Root), + 1 => Some(Self::Folder), + 2 => Some(Self::File), + 3 => Some(Self::Album), + 4 => Some(Self::Photo), + 5 => Some(Self::ProtonVendor), + _ => None, + } + } + + /// Whether the Drive client (rather than the Photos client) owns this kind. + pub fn is_drive_item(self) -> bool { + matches!(self, Self::Folder | Self::File | Self::ProtonVendor) + } +} + +/// `GET devices` — the account's registered devices. C# `DeviceListResponse`. +#[derive(Debug, Deserialize)] +pub struct DeviceListResponse { + #[serde(rename = "Devices", default)] + pub devices: Vec, +} + +/// C# `DeviceListItemDto`: a device and the share holding its root folder. +#[derive(Debug, Deserialize)] +pub struct DeviceListItemDto { + #[serde(rename = "Device")] + pub device: DeviceDataDto, + #[serde(rename = "Share")] + pub share: DeviceShareDataDto, +} + +/// C# `DeviceDataDto`. +#[derive(Debug, Deserialize)] +pub struct DeviceDataDto { + #[serde(rename = "DeviceID")] + pub id: DeviceUid, + #[serde(rename = "VolumeID")] + pub volume_id: VolumeId, + /// `DeviceType`: 1 = Windows, 2 = macOS, 3 = Linux. + #[serde(rename = "Type")] + pub device_type: i32, + #[serde(rename = "CreateTime")] + pub creation_time: i64, + #[serde(rename = "LastSyncTime", default)] + pub last_sync_time: Option, +} + +/// C# `DeviceShareDataDto`. `Name` is the *deprecated* device name: it used to +/// live on the share and must be cleared when renaming an old device. +#[derive(Debug, Deserialize)] +pub struct DeviceShareDataDto { + #[serde(rename = "ShareID")] + pub id: ShareId, + #[serde(rename = "LinkID")] + pub root_link_id: LinkId, + #[serde(rename = "Name", default)] + pub name: Option, +} + +/// `POST devices` — register a device with its own share and root folder. +/// C# `DeviceCreationRequest`. +#[derive(Debug, Serialize)] +pub struct DeviceCreationRequest { + #[serde(rename = "Device")] + pub device: DeviceCreationDeviceDto, + #[serde(rename = "Share")] + pub share: DeviceCreationShareDto, + #[serde(rename = "Link")] + pub link: DeviceCreationLinkDto, +} + +#[derive(Debug, Serialize)] +pub struct DeviceCreationDeviceDto { + #[serde(rename = "Type")] + pub device_type: i32, + /// Synchronisation state; 0 (off) when registering a new device. + #[serde(rename = "SyncState")] + pub sync_state: i32, +} + +#[derive(Debug, Serialize)] +pub struct DeviceCreationShareDto { + #[serde(rename = "AddressID")] + pub address_id: AddressId, + #[serde(rename = "AddressKeyID")] + pub address_key_id: AddressKeyId, + #[serde(rename = "Key")] + pub key: String, + #[serde(rename = "Passphrase")] + pub passphrase: String, + #[serde(rename = "PassphraseSignature")] + pub passphrase_signature: String, +} + +#[derive(Debug, Serialize)] +pub struct DeviceCreationLinkDto { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NodeKey")] + pub key: String, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "NodePassphraseSignature")] + pub passphrase_signature: String, + #[serde(rename = "NodeHashKey")] + pub node_hash_key: String, +} + +/// `POST devices` response. C# `DeviceCreationResponse`. +#[derive(Debug, Deserialize)] +pub struct DeviceCreationResponse { + #[serde(rename = "Device")] + pub device: DeviceCreationResultDto, +} + +#[derive(Debug, Deserialize)] +pub struct DeviceCreationResultDto { + #[serde(rename = "DeviceID")] + pub id: DeviceUid, + #[serde(rename = "ShareID")] + pub share_id: ShareId, + #[serde(rename = "LinkID")] + pub root_link_id: LinkId, +} + +/// `PUT devices/{uid}` — only ever used to clear the deprecated share-held name. +/// C# `DeviceUpdateRequest`. +#[derive(Debug, Serialize)] +pub struct DeviceUpdateRequest { + #[serde(rename = "Share")] + pub share: DeviceUpdateShareDto, +} + +#[derive(Debug, Serialize)] +pub struct DeviceUpdateShareDto { + #[serde(rename = "Name")] + pub name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_drive_share_targets_are_drive_items() { + // C# `SharingOperations.DriveShareTargetTypes`; albums and photos belong + // to the Photos client, and a root share is not a shared item. + for (raw, expected) in [ + (0, false), + (1, true), + (2, true), + (3, false), + (4, false), + (5, true), + ] { + let kind = ShareTargetType::from_raw(raw).expect("known target type"); + assert_eq!(kind.is_drive_item(), expected, "target type {raw}"); + } + assert!(ShareTargetType::from_raw(6).is_none()); + } +} diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index a4ab834..eb699b3 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -36,6 +36,7 @@ mod cache; mod client; mod crypto; +mod devices; mod dtos; mod events; mod node; @@ -43,8 +44,9 @@ mod photos; pub use cache::{CachedNodeInfo, DriveEntityCache}; pub use client::ProtonDriveClient; +pub use devices::{Device, DeviceType}; pub use events::{DriveEvent, DriveEventScopeId}; -pub use node::{Node, NodeKind, Thumbnail, ThumbnailType}; +pub use node::{Node, NodeKind, RevisionState, Thumbnail, ThumbnailType}; pub use photos::{PhotoTag, PhotoUploadMetadata, PhotosTimelineItem, ProtonPhotosClient}; pub use proton_sdk::account::KeySalt; pub use proton_sdk::cache::{CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository}; diff --git a/crates/proton-drive-rs/src/node.rs b/crates/proton-drive-rs/src/node.rs index afba0d6..6b9ad38 100644 --- a/crates/proton-drive-rs/src/node.rs +++ b/crates/proton-drive-rs/src/node.rs @@ -17,6 +17,14 @@ pub struct Node { /// Last modification time, epoch seconds. pub modification_time: i64, pub trashed: bool, + /// Whether the node is shared at all — with members, by public link, or both + /// (C# `Node.IsShared`: the link details carry a `Sharing` block). + #[serde(default)] + pub is_shared: bool, + /// Whether the node is shared by *public link* (C# `Node.IsSharedPublicly`: + /// the `Sharing` block carries a `ShareURLID`). Implies [`Node::is_shared`]. + #[serde(default)] + pub is_shared_publicly: bool, /// Email address that signed the node, if present. pub signature_email: Option, /// Per-field signature-verification results gathered while decrypting the @@ -70,6 +78,27 @@ impl NodeVerification { } } +/// The state of a file revision. Mirrors C# `Proton.Drive.Sdk.Nodes.RevisionState` +/// (the wire `ApiRevisionState` also has a `Draft = 0` that never surfaces on a +/// node's *active* revision). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RevisionState { + Active, + Superseded, +} + +impl RevisionState { + /// Map the wire `ApiRevisionState`. A link's active revision is Active by + /// definition, so an absent/draft state reads as [`RevisionState::Active`] + /// (C# `DtoToMetadataConverter` hardcodes it). + pub(crate) fn from_raw(value: Option) -> Self { + match value { + Some(2) => Self::Superseded, + _ => Self::Active, + } + } +} + /// Folder- or file-specific node data. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum NodeKind { @@ -78,6 +107,10 @@ pub enum NodeKind { media_type: String, /// Total encrypted size on cloud storage, in bytes. total_size_on_storage: i64, + /// State of the file's active revision (C# `Revision.State`); `None` when + /// the file has no active revision (an unsealed draft). + #[serde(default)] + active_revision_state: Option, /// Authoritative plaintext size from the active revision's decrypted /// extended attributes (C# `ClaimedSize`). `None` when the revision has /// no `XAttr` or it failed to decrypt. @@ -184,3 +217,18 @@ impl FileThumbnail { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn revision_state_maps_the_wire_value() { + assert_eq!(RevisionState::from_raw(Some(2)), RevisionState::Superseded); + assert_eq!(RevisionState::from_raw(Some(1)), RevisionState::Active); + // A link's active revision is Active even when the server omits the + // state (or still calls it a draft, as it does mid-upload). + assert_eq!(RevisionState::from_raw(Some(0)), RevisionState::Active); + assert_eq!(RevisionState::from_raw(None), RevisionState::Active); + } +} diff --git a/crates/proton-sdk/src/ids.rs b/crates/proton-sdk/src/ids.rs index d87f612..db086ee 100644 --- a/crates/proton-sdk/src/ids.rs +++ b/crates/proton-sdk/src/ids.rs @@ -86,6 +86,18 @@ string_id!( /// Identifies a Drive volume event; doubles as the enumeration cursor. DriveEventId ); +string_id!( + /// Identifies a membership of an address in a Drive share. + ShareMembershipId +); +string_id!( + /// Identifies a Drive device (a synced folder registered by a desktop client). + /// + /// Opaque and volume-free: the C# `DeviceUid` dropped its volume component + /// (upstream `chore: remove volume id from device uid`), so it is a plain id + /// rather than a `{volume}~{link}` pair like [`NodeUid`]. + DeviceUid +); /// Globally addresses a Drive node: a [`LinkId`] qualified by its [`VolumeId`]. #[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] diff --git a/scripts/upstream-sync.sh b/scripts/upstream-sync.sh index ffd8f42..0e44ae0 100755 --- a/scripts/upstream-sync.sh +++ b/scripts/upstream-sync.sh @@ -16,7 +16,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SDK="$ROOT/sdk" PIN_FILE="$ROOT/UPSTREAM_SYNC.md" -SUBTREE="client/cs/sdk/src" +# The C# sources moved from client/cs/sdk/src to client/cs/src in the mid-2026 +# projects reorg; watch both so ranges spanning the move stay complete. +SUBTREE=(client/cs/src client/cs/sdk/src) REMOTE_REF="origin/main" # noise = conventional-commit prefixes that never carry portable behavior @@ -40,14 +42,22 @@ PIN="$(grep -oE '[0-9a-f]{40}' "$PIN_FILE" | head -1)" echo "fetching upstream..." >&2 git -C "$SDK" fetch --quiet origin +# Upstream force-pushes: a pin whose object is gone would make the log below +# fail, which used to read as "up to date". Fail loudly instead. +if ! git -C "$SDK" cat-file -e "${PIN}^{commit}" 2>/dev/null; then + echo "error: pinned commit $PIN is not in the upstream history." >&2 + echo " upstream rewrote its history; re-pin from a commit date in the log." >&2 + exit 1 +fi + HEAD_SHA="$(git -C "$SDK" rev-parse --short "$REMOTE_REF")" echo "pinned: ${PIN:0:8}" echo "head: $HEAD_SHA ($REMOTE_REF)" -echo "subtree: $SUBTREE" +echo "subtree: ${SUBTREE[*]}" echo RANGE="$PIN..$REMOTE_REF" -commits="$(git -C "$SDK" log --oneline --no-decorate "$RANGE" -- "$SUBTREE" || true)" +commits="$(git -C "$SDK" log --oneline --no-decorate "$RANGE" -- "${SUBTREE[@]}")" if [ -z "$commits" ]; then echo "up to date — no cs commits since pin." @@ -74,11 +84,11 @@ echo "$relevant" if $show_diffs; then echo - echo "=== diffs (scoped to $SUBTREE) ===" + echo "=== diffs (scoped to ${SUBTREE[*]}) ===" echo "$relevant" | awk '{print $1}' | while read -r sha; do echo echo "----- $sha -----" - git -C "$SDK" show --stat --format="%H%n%an %ci%n%n %s%n" "$sha" -- "$SUBTREE" + git -C "$SDK" show --stat --format="%H%n%an %ci%n%n %s%n" "$sha" -- "${SUBTREE[@]}" done fi