diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index d97e910..6d42977 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -7,8 +7,8 @@ gitignored, so this file is the only durable record of what we last reviewed. - **Upstream**: https://github.com/ProtonDriveApps/sdk - **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the mid-2026 projects reorg; the script watches both) -- **Pinned**: `2219a42f018cce42d156379fbda2405034c7779a` -- **Date**: 2026-07-14 +- **Pinned**: `1b6ff83566d73b331e6842f683f1c9afe2d5b4ee` +- **Date**: 2026-07-22 > **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer > exist in its history. Triage by commit subject/date when a pin goes stale. @@ -29,3 +29,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 2026-06-26 | `fb4173ab` → `36430318` | `36430318` | ported: enumeration returns `NodeUid`s (`enumerate_folder_children_node_uids` / `enumerate_trash_node_uids`), caller materializes via `enumerate_nodes`. noise dropped: BOM/deps/kt-enum-order/cs-account-refactor | | 2026-07-10 | `36430318` → `f2496161` | `9a1c39b3` | ported: validation check in `Thumbnail::new` returning `Result` instead of panicking. noise dropped: projects reorg / C# Account client move to incubating. deferred: device support (`32a8eed0`). | | 2026-07-14 | `f2496161` → `2219a42f` | `d15fa9b9`, `bead8f65`, `f6b12911`, `c0940f52`, `b88b8041`, `2eb69c46` | upstream force-pushed + moved the cs subtree, so the old pin was dangling and the script silently reported "up to date" (both fixed). ported: **devices** (`devices.rs` + `enumerate_devices` / `create_device` / `rename_device` / `delete_device`; volume-free `DeviceUid`), **node sharing flags** (`Node::is_shared` / `is_shared_publicly` from the link `Sharing` block), **revision state** (`NodeKind::File::active_revision_state`), **shared-with-me** (`enumerate_shared_with_me_node_uids`, `GET v2/sharedwithme`), **leave shared node** (`leave_shared_node`, `DELETE v2/shares/{sid}/members/{mid}`). no-op: `15fa845e` (non-JSON error bodies — `http.rs` already falls back to a status-only error when the envelope won't parse). diverged deliberately: `9a7f1d02` + `b4dadf02` removed the C# entity cache and its tags; ours stays. cosmetic, not ported: `30ab32c1` / `388d3900` (C# field renames), `1a781ade` (buffer lengths). | +| 2026-07-22 | `2219a42f` → `1b6ff835` | `ded1b716`, `071f976f` | ported: **atomic small uploads** for buffered new files and revisions (`DriveSmallFileUpload` is an explicit opt-in because this crate has no remote feature-flag provider), and **photo duplicate detection** using name/content HMAC matching. already covered/no-op: `ee0b3fba` (Rust rename accepts `Option<&str>`), `193c26f1` (Rust in-memory cache already has bounded LRU eviction), `29d359e0` (digest representation refactor retains hex JSON), `5c370289` (C# disposal lifetime only), `95752055` (Photos cache construction only). deliberately not ported: `2bf49aae` / `2590d227` cache storage-format changes; Rust exposes a distinct string repository contract and ships no persistent backend to migrate/reset. | diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index c466778..49d4b42 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::io::{Cursor, Read}; use std::num::NonZeroUsize; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; @@ -49,18 +50,20 @@ use crate::dtos::{ DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, - FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse, - InvitationDetailsResponse, InvitationsListResponse, InviteEmailDetailsDto, - InviteExternalUserRequest, InviteExternalUserResponse, InviteProtonUserInvitationDto, - InviteProtonUserRequest, InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, - LinkDetailsRequest, LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, + FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, + FolderCreationRequest, FolderCreationResponse, InvitationDetailsResponse, + InvitationsListResponse, InviteEmailDetailsDto, InviteExternalUserRequest, + InviteExternalUserResponse, InviteProtonUserInvitationDto, InviteProtonUserRequest, + InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, + LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, - SharedByMeResponse, SharedWithMeResponse, ThumbnailBlockListRequest, + SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest, + SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse, @@ -79,6 +82,9 @@ use crate::sharing::{ /// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`). const DEFAULT_BLOCK_SIZE: usize = 1 << 22; +/// Maximum encrypted multipart size accepted by the atomic upload endpoint. +const SMALL_UPLOAD_SIZE_LIMIT: usize = 128 * 1024; + /// Maximum links per batch trash/restore/delete request (C# /// `NodeOperations.MaximumBatchCount`). const MAX_BATCH_COUNT: usize = 150; @@ -152,6 +158,8 @@ pub struct ProtonDriveClient { /// Shared across clones, which is the point: the bound is per host, not per /// download. block_slots: Arc, + /// Explicit substitute for upstream's `DriveSmallFileUpload` remote flag. + small_file_upload: Arc, /// Serializes the My Files and Photos bootstraps against themselves. /// /// [`ensure_my_files`](ProtonDriveClient::ensure_my_files) and @@ -302,6 +310,7 @@ impl ProtonDriveClient { entities: DriveEntityCache::new(entity_repository), telemetry: NoopTelemetry::shared(), block_slots: Arc::new(Semaphore::new(DEFAULT_MAX_INFLIGHT_BLOCKS)), + small_file_upload: Arc::new(AtomicBool::new(false)), bootstrap: Arc::new(Bootstrap::default()), } } @@ -327,6 +336,7 @@ impl ProtonDriveClient { entities: DriveEntityCache::new(InMemoryCacheRepository::shared()), telemetry: NoopTelemetry::shared(), block_slots: Arc::new(Semaphore::new(DEFAULT_MAX_INFLIGHT_BLOCKS)), + small_file_upload: Arc::new(AtomicBool::new(false)), bootstrap: Arc::new(Bootstrap::default()), } } @@ -383,6 +393,16 @@ impl ProtonDriveClient { self } + /// Enable or disable the atomic small-file upload endpoint. + /// + /// Disabled by default because upstream protects the endpoint with the + /// `DriveSmallFileUpload` remote feature flag, which this crate does not + /// currently fetch. The setting is shared by all clones. + pub fn with_small_file_upload(self, enabled: bool) -> Self { + self.small_file_upload.store(enabled, Ordering::Relaxed); + self + } + /// The global in-flight block permits, for [`RevisionReader`] and the /// whole-file download path to acquire against. pub(crate) fn block_slots(&self) -> Arc { @@ -1690,6 +1710,16 @@ impl ProtonDriveClient { media_type: &str, contents: &[u8], ) -> Result { + // Upstream budgets roughly 10% encryption overhead. This convenience + // method already owns a seekable in-memory slice, so it is the safe + // place to select the one-request backend without changing streaming + // reader bounds or buffering arbitrary inputs. + if self.small_file_upload.load(Ordering::Relaxed) && small_upload_applicable(contents.len()) + { + return self + .upload_small_file(parent_uid, name, media_type, contents) + .await; + } self.upload_file_from( parent_uid, name, @@ -1703,6 +1733,91 @@ impl ProtonDriveClient { .await } + async fn upload_small_file( + &self, + parent_uid: &NodeUid, + name: &str, + media_type: &str, + contents: &[u8], + ) -> Result { + let parent_key = self.folder_node_key(parent_uid).await?; + let parent_hash_key = self.parent_hash_key(parent_uid, &parent_key).await?; + let (_address_id, email, signing_key) = self.membership_address().await?; + let node = generate_node_key()?; + let content_key = ContentKey::generate(); + + let encrypted_name = + parent_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?; + let name_hash = hex::encode(hmac_sha256(&parent_hash_key, name.as_bytes())); + let passphrase = parent_key.encrypt(&node.passphrase)?; + let passphrase_signature = signing_key.sign_detached(&node.passphrase)?; + let content_key_packet = content_key.to_packet(&node.key)?; + let content_key_signature = node.key.sign_detached(&content_key.export()?)?; + + let mut manifest = Vec::new(); + let mut binary_parts = Vec::new(); + let (block_sizes, encrypted_signature, verification_token) = if contents.is_empty() { + (Vec::new(), None, None) + } else { + let ciphertext = content_key.encrypt_block(contents)?; + manifest.extend_from_slice(&Sha256::digest(&ciphertext)); + let encrypted_signature = node + .key + .encrypt(signing_key.sign_detached(contents)?.as_bytes())?; + let code_start = content_key_packet.len().checked_sub(32).ok_or_else(|| { + ProtonError::invalid_operation("content key packet has no verification code") + })?; + let token = verification_token(&content_key_packet[code_start..], &ciphertext); + binary_parts.push(("ContentBlock".to_owned(), ciphertext)); + ( + vec![contents.len() as i32], + Some(encrypted_signature), + Some(BASE64.encode(token)), + ) + }; + + let sha1_hex = hex::encode(Sha1::digest(contents)); + let manifest_signature = signing_key.sign_detached(&manifest)?; + let extended_attributes = ExtendedAttributes { + common: CommonExtendedAttributes { + size: Some(contents.len() as i64), + modification_time: None, + block_sizes: Some(block_sizes), + digests: Some(FileContentDigests { sha1: sha1_hex }), + }, + }; + let encrypted_xattr = node.key.encrypt_and_sign( + &signing_key, + &serde_json::to_vec(&extended_attributes)?, + false, + true, + )?; + let metadata = SmallFileUploadMetadataRequest { + name: encrypted_name, + name_hash, + parent_link_id: parent_uid.link_id.clone(), + passphrase, + passphrase_signature, + key: node.locked_armored, + media_type: media_type.to_owned(), + content_key_packet: BASE64.encode(content_key_packet), + content_key_signature, + manifest_signature, + checksum_verified: false, + signature_email: email, + content_block_verification_token: verification_token, + extended_attributes: encrypted_xattr, + photo: None, + content_block_encrypted_signature: encrypted_signature, + }; + let path = format!("v2/volumes/{}/files/small", parent_uid.volume_id); + let response: SmallUploadResponse = self + .http + .post_multipart(&path, &metadata, &binary_parts) + .await?; + Ok(NodeUid::new(parent_uid.volume_id.clone(), response.link_id)) + } + /// Streaming variant of [`upload_file`]: read the plaintext from `reader` /// block by block instead of buffering it all in memory. `intended_size` is /// the draft-creation size hint (C# `IntendedUploadSize`); the authoritative @@ -1826,6 +1941,10 @@ impl ProtonDriveClient { /// revision based on the active one, then write blocks and seal exactly as a /// fresh upload. The new revision becomes active once sealed. pub async fn upload_new_revision(&self, file_uid: &NodeUid, contents: &[u8]) -> Result<()> { + if self.small_file_upload.load(Ordering::Relaxed) && small_upload_applicable(contents.len()) + { + return self.upload_small_revision(file_uid, contents, None).await; + } self.upload_new_revision_from( file_uid, Cursor::new(contents), @@ -1836,6 +1955,100 @@ impl ProtonDriveClient { .await } + async fn upload_small_revision( + &self, + file_uid: &NodeUid, + contents: &[u8], + modification_time: Option, + ) -> Result<()> { + let details = self + .get_link_details(&file_uid.volume_id, std::slice::from_ref(&file_uid.link_id)) + .await?; + let detail = + details.links.into_iter().next().ok_or_else(|| { + ProtonError::invalid_operation(format!("file {file_uid} not found")) + })?; + let file = detail.file.ok_or_else(|| { + ProtonError::invalid_operation(format!("node {file_uid} is not a file")) + })?; + let packet = BASE64 + .decode( + file.content_key_packet + .ok_or_else(|| { + ProtonError::invalid_operation("file is missing its content key packet") + })? + .trim(), + ) + .map_err(|e| { + ProtonError::invalid_operation(format!("decode content key packet: {e}")) + })?; + let current_revision_id = file + .active_revision + .map(|revision| revision.id) + .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?; + let parent_key = self + .resolve_parent_key(&file_uid.volume_id, &detail.link) + .await?; + let node_key = decrypt_link(&parent_key, &detail.link)?.node_key; + let content_key = node_key.decrypt_content_key(&packet)?; + let (_address_id, email, signing_key) = self.membership_address().await?; + + let mut manifest = Vec::new(); + let mut binary_parts = Vec::new(); + let (block_sizes, encrypted_signature, verification_token) = if contents.is_empty() { + (Vec::new(), None, None) + } else { + let ciphertext = content_key.encrypt_block(contents)?; + manifest.extend_from_slice(&Sha256::digest(&ciphertext)); + let encrypted_signature = + node_key.encrypt(signing_key.sign_detached(contents)?.as_bytes())?; + let code_start = packet.len().checked_sub(32).ok_or_else(|| { + ProtonError::invalid_operation("content key packet has no verification code") + })?; + let token = verification_token(&packet[code_start..], &ciphertext); + binary_parts.push(("ContentBlock".to_owned(), ciphertext)); + ( + vec![contents.len() as i32], + Some(encrypted_signature), + Some(BASE64.encode(token)), + ) + }; + let extended_attributes = ExtendedAttributes { + common: CommonExtendedAttributes { + size: Some(contents.len() as i64), + modification_time: modification_time.map(epoch_to_iso8601), + block_sizes: Some(block_sizes), + digests: Some(FileContentDigests { + sha1: hex::encode(Sha1::digest(contents)), + }), + }, + }; + let encrypted_xattr = node_key.encrypt_and_sign( + &signing_key, + &serde_json::to_vec(&extended_attributes)?, + false, + true, + )?; + let metadata = SmallRevisionUploadMetadataRequest { + current_revision_id, + manifest_signature: signing_key.sign_detached(&manifest)?, + checksum_verified: false, + signature_email: email, + content_block_encrypted_signature: encrypted_signature, + content_block_verification_token: verification_token, + extended_attributes: encrypted_xattr, + }; + let path = format!( + "v2/volumes/{}/files/{}/revisions/small", + file_uid.volume_id, file_uid.link_id + ); + let _: SmallUploadResponse = self + .http + .post_multipart(&path, &metadata, &binary_parts) + .await?; + Ok(()) + } + /// Streaming variant of [`upload_new_revision`]: read the plaintext from /// `reader` block by block. See [`upload_file_from`] for the `intended_size`, /// `thumbnails` and reader semantics. @@ -4597,6 +4810,74 @@ impl ProtonDriveClient { Ok(items) } + /// Find active photos whose encrypted name and plaintext content both match. + pub(crate) async fn find_photo_duplicates( + &self, + name: &str, + contents: &[u8], + ) -> Result> { + const ACTIVE_LINK_STATE: i32 = 1; + + if !self.ensure_photos().await? { + return Err(ProtonError::invalid_operation( + "account has no photos volume", + )); + } + let photos_root = self + .cache + .lock() + .await + .photos_root + .clone() + .expect("ensure_photos populated the photos root"); + let root_key = self.folder_node_key(&photos_root).await?; + let hash_key = self + .parent_hash_key_ctx(&photos_root, &root_key, true) + .await?; + let name_hash = hex::encode(hmac_sha256(&hash_key, name.as_bytes())); + + let path = format!("volumes/{}/photos/duplicates", photos_root.volume_id); + let response: FindPhotoDuplicatesResponse = self + .http + .post( + &path, + &FindPhotoDuplicatesRequest { + name_hashes: vec![name_hash.clone()], + }, + ) + .await?; + + let candidates: Vec<_> = response + .duplicate_hashes + .into_iter() + .filter(|duplicate| { + duplicate.link_id.is_some() + && duplicate.link_state == Some(ACTIVE_LINK_STATE) + && !duplicate.name_hash.is_empty() + && !duplicate.content_hash.is_empty() + }) + .collect(); + if candidates.is_empty() { + return Ok(Vec::new()); + } + + let sha1_hex = hex::encode(Sha1::digest(contents)); + let content_hash = hex::encode(hmac_sha256(&hash_key, sha1_hex.as_bytes())); + Ok(candidates + .into_iter() + .filter(|duplicate| { + duplicate.name_hash.eq_ignore_ascii_case(&name_hash) + && duplicate.content_hash.eq_ignore_ascii_case(&content_hash) + }) + .map(|duplicate| { + NodeUid::new( + photos_root.volume_id.clone(), + duplicate.link_id.expect("candidate link id checked above"), + ) + }) + .collect()) + } + /// Download and decrypt a photo's active revision into `output` (photos /// routing). C# `PhotosFileDownloader`: the node is resolved via the photos /// endpoint; blocks are fetched from their absolute storage URLs exactly as @@ -5346,6 +5627,11 @@ fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { mac.finalize().into_bytes().to_vec() } +/// Upstream's 10% encryption-overhead budget for the 128 KiB endpoint limit. +fn small_upload_applicable(plaintext_size: usize) -> bool { + plaintext_size.saturating_mul(11) < SMALL_UPLOAD_SIZE_LIMIT * 10 +} + /// Block verification token: `verificationCode XOR ciphertextPrefix`, with the /// ciphertext prefix zero-padded or truncated to the code length. Mirrors C# /// `VerificationToken.Create`. @@ -5360,7 +5646,8 @@ fn verification_token(code: &[u8], ciphertext: &[u8]) -> Vec { mod tests { use super::{ DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, alternate_names, epoch_to_iso8601, - is_listable_revision_state, join_node_path, path_segments, to_drive_event, + is_listable_revision_state, join_node_path, path_segments, small_upload_applicable, + to_drive_event, }; use crate::dtos::{VolumeEventDto, VolumeEventLinkDto}; use crate::node::RevisionState; @@ -5378,6 +5665,14 @@ mod tests { assert_eq!(RevisionState::from_raw(Some(0)), RevisionState::Active); } + #[test] + fn small_upload_respects_encryption_overhead_budget() { + assert!(small_upload_applicable(0)); + assert!(small_upload_applicable(119_156)); + assert!(!small_upload_applicable(119_157)); + assert!(!small_upload_applicable(usize::MAX)); + } + #[test] fn path_segments_ignores_separator_noise() { let split = |p| path_segments(p).collect::>(); diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index b5f05e2..13945c8 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -62,6 +62,35 @@ pub struct TimelinePhotoListResponse { pub photos: Vec, } +/// `POST volumes/{vid}/photos/duplicates` request. +#[derive(Debug, Serialize)] +pub struct FindPhotoDuplicatesRequest { + #[serde(rename = "NameHashes")] + pub name_hashes: Vec, +} + +/// `POST volumes/{vid}/photos/duplicates` response. +#[derive(Debug, Deserialize)] +pub struct FindPhotoDuplicatesResponse { + #[serde(rename = "DuplicateHashes", default)] + pub duplicate_hashes: Vec, +} + +/// One server-side photo duplicate candidate. +#[derive(Debug, Deserialize)] +pub struct FoundPhotoDuplicateDto { + #[serde(rename = "Hash", default)] + pub name_hash: String, + #[serde(rename = "ContentHash", default)] + pub content_hash: String, + #[serde(rename = "LinkState")] + pub link_state: Option, + #[serde(rename = "ClientUID")] + pub client_uid: Option, + #[serde(rename = "LinkID")] + pub link_id: Option, +} + /// One timeline entry (C# `TimelinePhotoDto`). Only the id + capture time are /// consumed; the remaining fields are kept for wire fidelity. #[derive(Debug, Deserialize)] @@ -451,6 +480,72 @@ pub struct FileCreationIdentifiers { pub revision_id: String, } +/// JSON metadata part of `POST v2/volumes/{vid}/files/small`. +#[derive(Debug, Serialize)] +pub struct SmallFileUploadMetadataRequest { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "NameHash")] + pub name_hash: String, + #[serde(rename = "ParentLinkID")] + pub parent_link_id: LinkId, + #[serde(rename = "NodePassphrase")] + pub passphrase: String, + #[serde(rename = "NodePassphraseSignature")] + pub passphrase_signature: String, + #[serde(rename = "NodeKey")] + pub key: String, + #[serde(rename = "MIMEType")] + pub media_type: String, + #[serde(rename = "ContentKeyPacket")] + pub content_key_packet: String, + #[serde(rename = "ContentKeyPacketSignature")] + pub content_key_signature: String, + #[serde(rename = "ManifestSignature")] + pub manifest_signature: String, + #[serde(rename = "ChecksumVerified")] + pub checksum_verified: bool, + #[serde(rename = "SignatureEmail")] + pub signature_email: String, + #[serde(rename = "ContentBlockVerificationToken")] + pub content_block_verification_token: Option, + #[serde(rename = "XAttr")] + pub extended_attributes: String, + #[serde(rename = "Photo")] + pub photo: Option, + #[serde(rename = "ContentBlockEncSignature")] + pub content_block_encrypted_signature: Option, +} + +/// JSON metadata part of +/// `POST v2/volumes/{vid}/files/{lid}/revisions/small`. +#[derive(Debug, Serialize)] +pub struct SmallRevisionUploadMetadataRequest { + #[serde(rename = "CurrentRevisionID")] + pub current_revision_id: String, + #[serde(rename = "ManifestSignature")] + pub manifest_signature: String, + #[serde(rename = "ChecksumVerified")] + pub checksum_verified: bool, + #[serde(rename = "SignatureEmail")] + pub signature_email: String, + #[serde(rename = "ContentBlockEncSignature")] + pub content_block_encrypted_signature: Option, + #[serde(rename = "ContentBlockVerificationToken")] + pub content_block_verification_token: Option, + #[serde(rename = "XAttr")] + pub extended_attributes: String, +} + +/// Response from either atomic small-upload endpoint. +#[derive(Debug, Deserialize)] +pub struct SmallUploadResponse { + #[serde(rename = "LinkID")] + pub link_id: LinkId, + #[serde(rename = "RevisionID")] + pub revision_id: String, +} + /// `POST v2/volumes/{vid}/folders` — create a new folder. /// /// Mirrors C# `FolderCreationRequest` (+ its `NodeCreationRequest` base). Like diff --git a/crates/proton-drive-rs/src/photos.rs b/crates/proton-drive-rs/src/photos.rs index 8908770..d3e18ea 100644 --- a/crates/proton-drive-rs/src/photos.rs +++ b/crates/proton-drive-rs/src/photos.rs @@ -7,14 +7,14 @@ //! downloading photo content and **uploading** photos //! ([`upload_photo`](ProtonPhotosClient::upload_photo)). Photos-volume //! **creation** reuses the (large) volume-create crypto and is not yet ported; -//! [`find_duplicates`](ProtonPhotosClient::find_duplicates) is unimplemented -//! upstream (C# throws `NotImplementedException`) and mirrors that here. +//! Duplicate detection compares both the encrypted-name digest and the +//! content SHA-1 through [`find_duplicates`](ProtonPhotosClient::find_duplicates). use std::io::{Cursor, Read}; use serde::{Deserialize, Serialize}; -use proton_sdk::error::{ProtonError, Result}; +use proton_sdk::error::Result; use proton_sdk::ids::NodeUid; use proton_sdk::session::ProtonApiSession; @@ -207,22 +207,16 @@ impl ProtonPhotosClient { .await } - /// Find existing photos that duplicate `name` (server-side name-hash match). - /// - /// **Unimplemented**, mirroring the upstream C# `FindDuplicatesAsync`, which - /// throws `NotImplementedException`. Kept on the public surface so callers - /// can compile against it once the duplicate-find endpoint is ported. - pub async fn find_duplicates(&self, _name: &str) -> Result> { - Err(ProtonError::invalid_operation( - "find_duplicates is not implemented (parity with C# FindDuplicatesAsync)", - )) + /// Find active photos with the same name and plaintext SHA-1 digest. + pub async fn find_duplicates(&self, name: &str, contents: &[u8]) -> Result> { + self.drive.find_photo_duplicates(name, contents).await } } #[cfg(test)] mod tests { use super::*; - use crate::dtos::TimelinePhotoListResponse; + use crate::dtos::{FindPhotoDuplicatesResponse, TimelinePhotoListResponse}; use proton_sdk::ids::VolumeId; #[test] @@ -259,4 +253,23 @@ mod tests { assert_eq!(parsed.photos[0].capture_time, 1_700_000_000); assert_eq!(parsed.photos[1].content_hash.as_deref(), Some("ff")); } + + #[test] + fn duplicate_response_deserializes_server_shape() { + let raw = r#"{ + "DuplicateHashes": [{ + "Hash": "deadbeef", + "ContentHash": "cafe", + "LinkState": 1, + "ClientUID": "client", + "LinkID": "photo-1" + }] + }"#; + let parsed: FindPhotoDuplicatesResponse = serde_json::from_str(raw).unwrap(); + let duplicate = &parsed.duplicate_hashes[0]; + assert_eq!(duplicate.name_hash, "deadbeef"); + assert_eq!(duplicate.content_hash, "cafe"); + assert_eq!(duplicate.link_state, Some(1)); + assert_eq!(duplicate.link_id.as_ref().unwrap().to_string(), "photo-1"); + } } diff --git a/crates/proton-sdk/src/http.rs b/crates/proton-sdk/src/http.rs index 3a40b2c..a391b0b 100644 --- a/crates/proton-sdk/src/http.rs +++ b/crates/proton-sdk/src/http.rs @@ -265,6 +265,43 @@ impl ApiHttpClient { self.send::(Method::POST, path, Some(body)).await } + /// `POST {path}` as multipart form data with a JSON `Metadata` part and + /// zero or more binary parts. Used by Drive's atomic small-upload endpoints. + pub async fn post_multipart( + &self, + path: &str, + metadata: &B, + binary_parts: &[(String, Vec)], + ) -> Result { + let metadata = serde_json::to_vec(metadata)?; + let mut timer = self.telemetry().start("http_request"); + timer.attr("method", "POST"); + let rejected_token = self.inner.tokens.lock().await.access_token.clone(); + let response = self + .send_multipart_with_token(path, &metadata, binary_parts, &rejected_token) + .await?; + let response = if response.status() == StatusCode::UNAUTHORIZED { + let bytes = response.bytes().await?; + if let Ok(envelope) = serde_json::from_slice::(&bytes) + && matches!( + envelope.code, + ResponseCode::AccountDeleted | ResponseCode::AccountDisabled + ) + { + return Err(api_error(StatusCode::UNAUTHORIZED, &bytes)); + } + let token = self.refresh_access_token(&rejected_token).await?; + self.send_multipart_with_token(path, &metadata, binary_parts, &token) + .await? + } else { + response + }; + timer.attr("status", response.status().as_u16()); + let parsed = parse_response(response).await?; + timer.success(); + Ok(parsed) + } + /// `PUT {path}` with a JSON body, returning a typed success body. pub async fn put(&self, path: &str, body: &B) -> Result { self.send::(Method::PUT, path, Some(body)).await @@ -365,6 +402,51 @@ impl ApiHttpClient { .await } + async fn send_multipart_with_token( + &self, + path: &str, + metadata: &[u8], + binary_parts: &[(String, Vec)], + access_token: &str, + ) -> Result { + let url = format!( + "{}{}{}", + self.inner.base_url, + self.route_prefix, + path.trim_start_matches('/') + ); + send_retrying(&self.inner.config.retry_policy, || { + let metadata_part = reqwest::multipart::Part::bytes(metadata.to_vec()) + .file_name("Metadata") + .mime_str("application/json") + .expect("application/json is a valid MIME type"); + let mut form = reqwest::multipart::Form::new().part("Metadata", metadata_part); + for (name, bytes) in binary_parts { + let part = reqwest::multipart::Part::bytes(bytes.clone()) + .file_name(name.clone()) + .mime_str("application/octet-stream") + .expect("octet-stream is a valid MIME type"); + form = form.part(name.clone(), part); + } + let mut request = self + .inner + .http + .post(&url) + .timeout(self.inner.config.storage_timeout) + .header(SESSION_ID_HEADER, self.inner.session_id.as_str()) + .header(APP_VERSION_HEADER, &self.inner.config.app_version) + .header(reqwest::header::ACCEPT, API_CONTENT_TYPE) + .bearer_auth(access_token) + .multipart(form); + if !self.inner.config.user_agent.is_empty() { + request = + request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent); + } + request + }) + .await + } + /// Refresh the session tokens, deduplicating concurrent refreshes: if the /// in-memory access token already differs from the rejected one, another /// task refreshed first and we reuse its result.