added device support + sharing

This commit is contained in:
nvrl committed 2026-07-14 15:03:06 +02:00
1 parent a24785d1a9
commit 7c9d16b405
9 files changed
+771 -25

No files matched your search

+8 -3
View File
@@ -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). |
+2
View File
@@ -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(),
}
+322 -15
View File
@@ -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<Vec<NodeUid>> {
let mut timer = self.telemetry.start("enumerate_shared_with_me_node_uids");
let mut uids = Vec::new();
let mut anchor: Option<String> = 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<Vec<Device>> {
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<Device> {
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<Device> {
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<proton_sdk::api::ApiResponse> =
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<Vec<DeviceMetadata>> {
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<DeviceMetadata> {
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<String> {
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<AddressKeyId> {
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())),
+104
View File
@@ -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<Self> {
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<string, ProtonDriveError>`): 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<String>,
/// 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<i64>,
/// 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<i64>,
/// 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<String>) -> 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());
}
}
+257 -1
View File
@@ -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<FileDto>,
/// Present when the node is shared (with members and/or via a public link).
#[serde(rename = "Sharing", default)]
pub sharing: Option<LinkSharingDto>,
/// 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<ShareMembershipSummaryDto>,
}
/// 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<String>,
}
/// 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<i32>,
#[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<SharedWithMeLinkDto>,
/// Cursor for the next page.
#[serde(rename = "AnchorID", default)]
pub anchor_id: Option<String>,
#[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<Self> {
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<DeviceListItemDto>,
}
/// 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<i64>,
}
/// 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<String>,
}
/// `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());
}
}
+3 -1
View File
@@ -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};
+48
View File
@@ -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<String>,
/// 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<i32>) -> 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<RevisionState>,
/// 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);
}
}
+12
View File
@@ -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)]
+15 -5
View File
@@ -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