mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
8611989364
commit
c30b107382
4 files changed
+204
-13
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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, ©, "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<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;
|
||||
}
|
||||
Reference in new issue
Block a user