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.
This commit is contained in:
nvrl committed 2026-10-25 20:33:29 +01:00
1 parent ee16f4326e
commit 8611989364
5 files changed
+119 -3

No files matched your search

+24 -2
View File
@@ -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<FolderSize> {
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
+21
View File
@@ -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 {
+1 -1
View File
@@ -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,
};
+14
View File
@@ -42,6 +42,20 @@ pub struct RecentlyAccessedReportItem {
pub access_time: Option<i64>,
}
/// 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 {
@@ -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;
}