From 8611989364ae9bca262e5e72fec9291bcc2740cc Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 25 Oct 2026 20:33:29 +0100 Subject: [PATCH] feat(drive): report a folder's size from the server ProtonDriveClient::get_folder_size returns the total size and number of a folder's descendants without walking the tree, ported from the TS SDK's getFolderSize (GET volumes/{vid}/folders/{lid}/calculate- descendents-size). Trashed descendants and folders count, and the size is encrypted bytes on storage; the live test pins both, which the TS docs leave open. --- crates/proton-drive-rs/src/client.rs | 26 +++++++- crates/proton-drive-rs/src/dtos.rs | 21 +++++++ crates/proton-drive-rs/src/lib.rs | 2 +- crates/proton-drive-rs/src/node.rs | 14 +++++ .../proton-drive-rs/tests/live_drive_ops.rs | 59 +++++++++++++++++++ 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 1a3e667..637723f 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -59,7 +59,7 @@ use crate::dtos::{ ExternalInvitationResponseDto, ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest, - FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse, + FolderCreationResponse, FolderSizeResponse, InvitationDetailsResponse, InvitationsListResponse, InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse, InviteProtonUserInvitationDto, InviteProtonUserRequest, InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, @@ -80,7 +80,7 @@ use crate::dtos::{ }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ - AlbumProperties, AvailableNameTarget, FileThumbnail, Node, NodeKind, NodeMoveItem, + AlbumProperties, AvailableNameTarget, FileThumbnail, FolderSize, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties, RecentlyAccessedReportItem, RevisionState, Thumbnail, ThumbnailType, }; @@ -1137,6 +1137,28 @@ impl ProtonDriveClient { } } + /// The total size and number of a folder's descendants, computed by the + /// server, so the caller does not walk the tree. + /// + /// Mirrors TS `ProtonDriveClient.getFolderSize` + /// (`GET volumes/{vid}/folders/{lid}/calculate-descendents-size`). Trashed + /// descendants count too, so this is the space the folder holds rather + /// than what a listing shows. Folders count as descendants, and the size is + /// encrypted bytes on storage, a little above the plaintext. + pub async fn get_folder_size(&self, uid: &NodeUid) -> Result { + let mut timer = self.telemetry.start("get_folder_size"); + let path = format!( + "volumes/{}/folders/{}/calculate-descendents-size", + uid.volume_id, uid.link_id + ); + let response: FolderSizeResponse = self.http.get(&path).await?; + timer.success(); + Ok(FolderSize { + size: response.descendents_size, + descendants: response.descendents_count, + }) + } + /// Enumerate the [`NodeUid`]s of the main volume's trashed nodes. /// /// Mirrors C# `VolumeOperations.EnumerateTrashAsync` (renamed to diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 6f01dae..99f6725 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -1759,10 +1759,31 @@ pub struct DeviceUpdateShareDto { pub name: String, } +/// `GET volumes/{vid}/folders/{lid}/calculate-descendents-size` response (TS +/// `FolderSizeResponseDto`). The server spells it "descendents". +#[derive(Debug, Deserialize)] +pub struct FolderSizeResponse { + #[serde(rename = "DescendentsSize")] + pub descendents_size: u64, + #[serde(rename = "DescendentsCount")] + pub descendents_count: u64, +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn folder_size_response_reads_the_wire_names() { + let raw = r#"{ + "Code": 1000, "VolumeID": "vol", "LinkID": "folder", + "DescendentsSize": 5368709120, "DescendentsCount": 42 + }"#; + let response: FolderSizeResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(response.descendents_size, 5_368_709_120); + assert_eq!(response.descendents_count, 42); + } + #[test] fn recently_accessed_request_uses_the_wire_names() { let request = ReportRecentlyAccessedRequest { diff --git a/crates/proton-drive-rs/src/lib.rs b/crates/proton-drive-rs/src/lib.rs index aa43290..5e4b9e6 100644 --- a/crates/proton-drive-rs/src/lib.rs +++ b/crates/proton-drive-rs/src/lib.rs @@ -56,7 +56,7 @@ pub use event_manager::{ }; pub use events::{DriveEvent, DriveEventScopeId}; pub use node::{ - AlbumProperties, AvailableNameTarget, FileThumbnail, Node, NodeKind, NodeMoveItem, + AlbumProperties, AvailableNameTarget, FileThumbnail, FolderSize, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties, RecentlyAccessedReportItem, RevisionState, Thumbnail, ThumbnailType, }; diff --git a/crates/proton-drive-rs/src/node.rs b/crates/proton-drive-rs/src/node.rs index de362fe..390376d 100644 --- a/crates/proton-drive-rs/src/node.rs +++ b/crates/proton-drive-rs/src/node.rs @@ -42,6 +42,20 @@ pub struct RecentlyAccessedReportItem { pub access_time: Option, } +/// What [`get_folder_size`](crate::ProtonDriveClient::get_folder_size) reports +/// about a folder. +/// +/// Mirrors TS `FolderSizeInfo`. Both numbers count every descendant the user +/// can see, in the trash as well as active. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FolderSize { + /// Sum of the descendants' sizes in encrypted bytes on storage, which is a + /// little more than the plaintext. + pub size: u64, + /// Number of descendants, files and folders. + pub descendants: u64, +} + /// A decrypted Drive node (folder or file). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { diff --git a/crates/proton-drive-rs/tests/live_drive_ops.rs b/crates/proton-drive-rs/tests/live_drive_ops.rs index 995c880..f0e6a16 100644 --- a/crates/proton-drive-rs/tests/live_drive_ops.rs +++ b/crates/proton-drive-rs/tests/live_drive_ops.rs @@ -511,3 +511,62 @@ async fn folder_path_helpers_round_trip() { .uid; cleanup(client, &[top_uid]).await; } + +/// `get_folder_size` counts every descendant, nested folders included, and +/// sums their encrypted sizes. +/// +/// The TS docs leave both points open; the first live run answered them with +/// `size: 301112, descendants: 3` — two single-block files, 56 bytes of +/// encryption overhead each. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn folder_size_counts_nested_descendants() { + let Some(live) = common::live_client().await else { + return; + }; + let client = &live.client; + let root = client + .get_my_files_folder() + .await + .expect("get my-files root"); + + let top_name = format!("size-ops-{}", common::unique_suffix()); + let top = client + .create_folder(&root.uid, &top_name, None) + .await + .expect("create top folder"); + let nested = client + .create_folder(&top, "nested", None) + .await + .expect("create nested folder"); + let small = vec![b'a'; 1000]; + let large = vec![b'b'; 300_000]; + client + .upload_file(&top, "small.bin", "application/octet-stream", &small) + .await + .expect("upload small file"); + client + .upload_file(&nested, "large.bin", "application/octet-stream", &large) + .await + .expect("upload nested file"); + + let size = client.get_folder_size(&top).await.expect("get_folder_size"); + eprintln!("[folder size] {size:?} for 1 folder + 2 files of 301000 plaintext bytes"); + assert_eq!( + size.descendants, 3, + "two files and the nested folder: {size:?}" + ); + let plaintext = (small.len() + large.len()) as u64; + assert!( + (plaintext..plaintext + 1024).contains(&size.size), + "encrypted size, a little above the plaintext: {size:?}" + ); + + let empty = client + .get_folder_size(&nested) + .await + .expect("get_folder_size(nested)"); + assert_eq!(empty.descendants, 1, "nested holds one file: {empty:?}"); + + cleanup(client, &[top]).await; +}