diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index e8ef5f9..2a1347c 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -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` 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` 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. | diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 637723f..325afee 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -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 { + 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. diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 99f6725..12db213 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -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, + #[serde(rename = "SignatureEmail", skip_serializing_if = "Option::is_none")] + pub signature_email: Option, + #[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 { diff --git a/crates/proton-drive-rs/tests/live_drive_ops.rs b/crates/proton-drive-rs/tests/live_drive_ops.rs index f0e6a16..1de6540 100644 --- a/crates/proton-drive-rs/tests/live_drive_ops.rs +++ b/crates/proton-drive-rs/tests/live_drive_ops.rs @@ -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 = (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, ©, "file copy").await; + assert_eq!(node.name, "data.bin"); + assert_eq!(node.parent_uid.as_ref(), Some(&target)); + assert_eq!( + client.download_file(©).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 = 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; +}