feat(drive): copy a node on the server

ProtonDriveClient::copy_node copies a file with its content, or a folder
with its subtree, under a new parent without downloading it, ported from
the TS SDK's copyNode (POST volumes/{vid}/links/{lid}/copy). The crypto
is the move's: the passphrase is rewrapped to the new parent, so the
node and content keys stay the same, and the name is encrypted and
signed to it. An optional name renames the copy.

The new parent may be on another volume, and the returned uid takes the
target's volume, where TS uses the source's. CopyPhotoResponse is now
the shared CopyLinkResponse.

UPSTREAM_SYNC.md logs this, get_folder_size and the read retries.
This commit is contained in:
nvrl committed 2026-10-25 20:37:51 +01:00
1 parent 8611989364
commit c30b107382
4 files changed
+204 -13

No files matched your search

+1
View File
@@ -42,3 +42,4 @@ gitignored, so this file is the only durable record of what we last reviewed.
| 2026-09-27 | `6cbf2f44` → `f28a93ce` | — | no behavioral changes. not ported: `0c054f0c` (`DriveClientEnumerateNodes` / `DrivePhotosClientEnumerateNodes` interop messages exposing C# `EnumerateNodesAsync` to the Kotlin bindings — CExports/proto only; `ProtonDriveClient::enumerate_nodes` and `ProtonPhotosClient::enumerate_nodes` already exist). |
| 2026-10-06 | `f28a93ce` → `df3a717b` | `0aabea59` | ported: **recently accessed report** (`ProtonDriveClient::report_recently_accessed` → `POST recently-accessed-items`, `ProtonPhotosClient::report_recently_accessed` → `POST photos/recently-accessed-items`; batches of 50, missing `AccessTime` defaults to now, epoch seconds; `RecentlyAccessedReportItem` takes `Option<i64>` rather than `DateTimeOffset`). Also ported the DTO half of `f257c20e`: `LinkDto` / `ActiveRevisionDto` read `ThirdParty` and `Sdk` (default `false`). not ported: rest of `f257c20e` (`DecryptionErrorEvent` / `VerificationErrorEvent` with `Recency` and `CreatedBy` — telemetry plumbing for C#'s interop metric callback, we emit no such events; same reasoning as `29e58378`). Live validation pending: the recently-accessed endpoints. |
| 2026-10-15 | `df3a717b` → `8ffae007` | `af391cbb` | ported: **folder-aware available names** (`af391cbb`, breaking upstream and here): `get_available_name` takes an `AvailableNameTarget` (`File` / `Folder`). A file keeps its extension last (`name (1).ext`, as before); a folder appends to the whole name (`name (1)`), so a folder named `v1.2` no longer resolves to `v1 (1).2`. `alternate_names` split into `alternate_file_names` / `alternate_folder_names` (C# `AlternateFileNameGenerator.GetFileNames` / `GetFolderNames`). no-op: `c6b45188` (C# transfer buffers optionally backed by an unlinked `mkstemp` file instead of `RecyclableMemoryStream`, behind a compile-time flag) — memory plumbing only; our blocks are `Vec<u8>` bounded by the client-wide in-flight block semaphore, and the verifier prefix it now reads from the stream is the same `min(prefix, len)` bytes our `verification_token` already zero-pads. **Downstream:** no `get_available_name` caller in `proton-drive-linux` or `proton-stream`. |
| 2026-10-25 | — (no C# delta) | — | **not an upstream port of C#** — two ports from the **TypeScript** SDK, plus one change of ours. **Folder size** (TS `getFolderSize`, `243f11f4`): `ProtonDriveClient::get_folder_size` → `GET volumes/{vid}/folders/{lid}/calculate-descendents-size`, returning `FolderSize { size, descendants }`. Live-verified, which settled what the TS docs leave open: folders count as descendants, and the size is encrypted bytes on storage (two single-block files of 301 000 plaintext bytes report 301 112). **Copy** (TS `NodesManagement.copyNode`): `ProtonDriveClient::copy_node(uid, new_parent, name)` → `POST volumes/{vid}/links/{lid}/copy`, a server-side copy of a file with its content or a folder with its subtree. The crypto is the move's (`build_move_parts`: passphrase rewrapped to the new parent, name encrypted and signed by the My Files address, signature fields only for an anonymously signed node); `CopyPhotoResponse` became the shared `CopyLinkResponse`. Diverged deliberately: the returned uid takes the **target** parent's volume, where TS uses the source's, which only holds within one volume; no `copyNodes` batch, which in TS is a sequential loop over `copyNode`. Live-verified: file content, rename-on-copy, folder subtree, and `AlreadyExists` for a taken name. **Ours**: reads are retried after a dropped connection — `send_retrying` reads the body inside its loop and takes a `Replay` mode; a `GET`, a block download and the new `ApiHttpClient::post_query` (link details, thumbnail URLs, photo duplicates, `checkAvailableHashes`) are sent again when the connection drops mid-request or mid-body, while writes keep the old rule. |
+77 -12
View File
@@ -51,13 +51,13 @@ use crate::dtos::{
AlbumUpdateRequest, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest,
BlockUploadPreparationResponse, BlockUploadTarget, BlockVerificationInputResponse,
BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes,
ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest,
CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse,
CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto,
DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse,
DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto,
ExternalInvitationResponseDto, ExternalInvitationsResponse, FileContentDigests,
FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest,
ContextShareResponse, CopyLinkRequest, CopyLinkResponse, CopyPhotoContent,
CopyPhotoRelatedItem, CopyPhotoRequest, CreateBookmarkRequest, CreatePublicLinkRequest,
CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto,
DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto,
DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes,
ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse,
FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest,
FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest,
FolderCreationResponse, FolderSizeResponse, InvitationDetailsResponse, InvitationsListResponse,
InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse,
@@ -2812,6 +2812,75 @@ impl ProtonDriveClient {
Ok(())
}
/// Copy `uid` under `new_parent` on the server, without downloading it, and
/// return the copy's uid. A file is copied with its content and a folder
/// with its subtree. `name` renames the copy; `None` keeps the name.
///
/// Mirrors TS `NodesManagement.copyNode`
/// (`POST volumes/{vid}/links/{lid}/copy`). The crypto is the move's, from
/// `build_move_parts`: the passphrase is rewrapped
/// to the new parent, so the node key and its content key stay the same,
/// and the name is encrypted and signed by the My Files address. Unlike
/// [`move_node`](Self::move_node), the new parent may be on another volume.
///
/// A name already taken in `new_parent` fails with
/// [`ResponseCode::AlreadyExists`](proton_sdk::api::ResponseCode::AlreadyExists);
/// [`get_available_name`](Self::get_available_name) finds a free one.
pub async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid> {
let mut timer = self.telemetry.start("copy_node");
if let Some(name) = name {
validate_node_name(name)?;
}
let details = self
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
.await?;
let link = details
.links
.into_iter()
.next()
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} not found")))?
.link;
let dest_parent_key = self.folder_node_key(new_parent).await?;
let dest_hash_key = self.parent_hash_key(new_parent, &dest_parent_key).await?;
let (_address_id, email, signing_key) = self.membership_address().await?;
let parts = self
.build_move_parts(
uid,
&link,
name,
&dest_parent_key,
&dest_hash_key,
&signing_key,
)
.await?;
let request = CopyLinkRequest {
target_volume_id: new_parent.volume_id.clone(),
target_parent_link_id: new_parent.link_id.clone(),
passphrase: parts.passphrase,
signature_email: parts.anonymous_signature.as_ref().map(|_| email.clone()),
passphrase_signature: parts.anonymous_signature,
name: parts.encrypted_name,
name_signature_email: email,
name_hash: parts.name_hash,
};
let path = format!("volumes/{}/links/{}/copy", uid.volume_id, uid.link_id);
let response: CopyLinkResponse = self.http.post(&path, &request).await?;
timer.success();
// The copy lives with its new parent, whichever volume the source was
// on. (TS builds the uid from the source volume, which only holds for
// a copy within one volume.)
Ok(NodeUid::new(new_parent.volume_id.clone(), response.link_id))
}
/// Move several nodes under a single destination parent, keeping their
/// names. Collects [`move_nodes_streaming`](Self::move_nodes_streaming) —
/// see it for batching, retries and anonymously signed nodes. Mirrors C#
@@ -6880,11 +6949,7 @@ impl ProtonDriveClient {
},
};
let failure = match self
.http
.post::<_, CopyPhotoResponse>(&path, &request)
.await
{
let failure = match self.http.post::<_, CopyLinkResponse>(&path, &request).await {
Ok(_) => None,
// The incomplete-group answer arrives as an ordinary API error whose
// `Details.Missing` names the related photos on the *source* volume.
+33 -1
View File
@@ -623,12 +623,44 @@ pub struct CopyPhotoRelatedItem {
pub content_hash: String,
}
/// `POST volumes/{vid}/links/{lid}/copy` response, for a photo or any other
/// node: the new link.
#[derive(Debug, Deserialize)]
pub struct CopyPhotoResponse {
pub struct CopyLinkResponse {
#[serde(rename = "LinkID")]
pub link_id: LinkId,
}
/// `POST volumes/{vid}/links/{lid}/copy` — copy a node, with its content or
/// subtree, under a new parent (TS `PostCopyNodeRequest`).
///
/// The crypto is the move's: the passphrase rewrapped to the target parent
/// key, the name encrypted and signed to it, `Hash` under its hash key.
/// `NodePassphraseSignature` and `SignatureEmail` are sent only for an
/// anonymously signed node, like [`MoveLinkRequest`].
#[derive(Debug, Serialize)]
pub struct CopyLinkRequest {
#[serde(rename = "TargetVolumeID")]
pub target_volume_id: VolumeId,
#[serde(rename = "TargetParentLinkID")]
pub target_parent_link_id: LinkId,
#[serde(rename = "NodePassphrase")]
pub passphrase: String,
#[serde(
rename = "NodePassphraseSignature",
skip_serializing_if = "Option::is_none"
)]
pub passphrase_signature: Option<String>,
#[serde(rename = "SignatureEmail", skip_serializing_if = "Option::is_none")]
pub signature_email: Option<String>,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "NameSignatureEmail")]
pub name_signature_email: String,
#[serde(rename = "Hash")]
pub name_hash: String,
}
/// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`).
#[derive(Debug, Serialize)]
pub struct PhotoTagsRequest {
@@ -9,7 +9,9 @@
mod common;
use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::{Node, NodeKind};
use proton_sdk::ids::{LinkId, NodeUid};
@@ -570,3 +572,94 @@ async fn folder_size_counts_nested_descendants() {
cleanup(client, &[top]).await;
}
/// `copy_node` copies a file with its content and a folder with its subtree,
/// keeps or replaces the name, and refuses a name already taken.
#[tokio::test]
#[ignore = "live: needs test-account credentials"]
async fn copy_node_copies_content_and_subtrees() {
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!("copy-ops-{}", common::unique_suffix());
let top = client
.create_folder(&root.uid, &top_name, None)
.await
.expect("create top folder");
let source = client
.create_folder(&top, "source", None)
.await
.expect("create source folder");
let target = client
.create_folder(&top, "target", None)
.await
.expect("create target folder");
let payload: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let file = client
.upload_file(&source, "data.bin", "application/octet-stream", &payload)
.await
.expect("upload file");
// A file, keeping its name, into another folder.
let copy = client
.copy_node(&file, &target, None)
.await
.expect("copy file");
assert_ne!(copy, file, "a copy is a new node");
let node = get(client, &copy, "file copy").await;
assert_eq!(node.name, "data.bin");
assert_eq!(node.parent_uid.as_ref(), Some(&target));
assert_eq!(
client.download_file(&copy).await.expect("download copy"),
payload,
"the copy carries the content"
);
// A file, renamed, next to itself.
let renamed = client
.copy_node(&file, &source, Some("data (copy).bin"))
.await
.expect("copy file with a new name");
assert_eq!(
get(client, &renamed, "renamed copy").await.name,
"data (copy).bin"
);
assert_eq!(get(client, &file, "source file").await.name, "data.bin");
// The same name twice in one folder is refused.
let taken = client
.copy_node(&file, &source, None)
.await
.expect_err("the name is taken");
match &taken {
ProtonError::Api(e) => assert_eq!(e.code, ResponseCode::AlreadyExists, "{taken:?}"),
other => panic!("expected an api error, got {other:?}"),
}
// A folder, with its subtree.
let folder_copy = client
.copy_node(&source, &target, Some("source copy"))
.await
.expect("copy folder");
let children = client
.enumerate_folder_children_node_uids(&folder_copy)
.await
.expect("list folder copy");
let mut names: Vec<String> = client
.enumerate_nodes(&children)
.await
.expect("read folder copy children")
.into_iter()
.map(|n| n.name)
.collect();
names.sort();
assert_eq!(names, ["data (copy).bin", "data.bin"]);
cleanup(client, &[top]).await;
}