//! The high-level Drive client and its read operations. use std::collections::{HashMap, HashSet, VecDeque}; use std::future::Future; 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; use bytes::Bytes; use futures::stream::{self, FuturesOrdered, StreamExt, TryStreamExt}; use lru::LruCache; use sha2::{Digest, Sha256}; use tokio::sync::{Mutex, OwnedSemaphorePermit, RwLock, Semaphore, TryAcquireError, mpsc}; use proton_sdk::account::{AccountClient, KeyPassphrases, KeySalt}; use proton_sdk::api::ResponseCode; use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository}; use proton_sdk::crypto::PrivateKey; use proton_sdk::crypto::{ ContentKey, DEFAULT_BIT_LENGTH, SHARING_INVITER_CONTEXT, VerificationKeyRing, VerificationStatus, accept_invitation, build_standard_share_material, build_volume_creation_material, decrypt_armored_with_keys, derive_key_passphrase, encrypt_external_invitation, encrypt_invitation, generate_node_hash_key, generate_node_key, generate_node_key_aead, generate_verifier, verify_detached, verify_detached_with_context, }; use proton_sdk::error::{ProtonApiError, ProtonError, Result}; use proton_sdk::http::ApiHttpClient; use proton_sdk::ids::{ AddressId, AddressKeyId, DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, ShareMembershipId, VolumeId, }; use proton_sdk::session::ProtonApiSession; use proton_sdk::telemetry::{NoopTelemetry, Telemetry, TelemetryExt}; use hmac::{Hmac, KeyInit, Mac}; use sha1::Sha1; use crate::cache::DriveEntityCache; use crate::crypto::{ decrypt_content_key_verified, decrypt_extended_attributes_verified, decrypt_link, decrypt_link_name_verified, decrypt_link_verified, decrypt_share_key, }; use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::dtos::{ AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest, AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, AlbumUpdateLink, AlbumUpdateRequest, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget, BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes, 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, InviteProtonUserInvitationDto, InviteProtonUserRequest, InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto, RemovePhotosFromAlbumRequest, RenameLinkRequest, ReportRecentlyAccessedItem, ReportRecentlyAccessedRequest, RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, TransferPhotoLinkItem, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeListResponse, VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ AlbumProperties, AvailableNameTarget, FileThumbnail, FolderSize, Node, NodeKind, NodeMoveItem, PhotoLocation, PhotoProperties, RecentlyAccessedReportItem, RevisionState, Thumbnail, ThumbnailType, }; use crate::photos::{ AlbumItem, PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata, PhotosTimelineItem, }; use crate::revision::{ BlockTargets, MAX_CONCURRENT_BLOCK_DOWNLOADS, Revision, RevisionReader, digest_and_decrypt_block_blocking, }; use crate::sharing::{ Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole, PublicLink, ShareInvitation, ShareMember, ShareMembership, SharedWithMeItem, }; use crate::single_flight::SingleFlight; use crate::transport::{DEFAULT_BLOCK_SIZE, RevisionTransport, rank_block_sizes}; /// 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`). pub(crate) const MAX_BATCH_COUNT: usize = 150; /// Thumbnail block ids resolved per `POST volumes/{vid}/thumbnails` request /// (C# `FileOperations.MaxThumbnailIdsPerRequest`). pub(crate) const MAX_THUMBNAIL_IDS_PER_REQUEST: usize = 30; /// Link-detail batches fetched at once, per volume, when enumerating nodes. pub(crate) const MAX_CONCURRENT_DETAIL_FETCHES: usize = 4; /// Nodes decrypted at once within one batch of link details. /// /// Each build runs its PGP work on the blocking pool (`crypto::decrypt_link_*`), /// so this is what spreads a folder listing's per-node S2K derivations over /// several cores; the author-key lookups it also overlaps are the smaller half. const MAX_CONCURRENT_NODE_BUILDS: usize = 8; /// Content blocks a client keeps in memory at once, across every transfer it is /// running — downloads and uploads alike. /// /// [`MAX_CONCURRENT_BLOCK_DOWNLOADS`] and [`MAX_CONCURRENT_BLOCK_UPLOADS`] bound /// one file; nothing bounded the host, so N concurrent transfers cost N times /// that — a mount pinning a handful of large files could hold hundreds of MiB of /// block buffers. This is the missing global ceiling (TypeScript caps concurrent /// *files* instead, in `internal/download/queue.ts`; capping blocks bounds the /// memory directly). /// /// Sized a little above one file's pipeline (an upload holds up to /// `MAX_BUFFERED_UPLOAD_BLOCKS + MAX_CONCURRENT_BLOCK_UPLOADS + 1` permits) so a /// lone transfer still saturates it and a second one is not starved. pub(crate) const DEFAULT_MAX_INFLIGHT_BLOCKS: usize = 16; /// Content blocks uploaded concurrently within a single file. /// /// Matches the TypeScript SDK's `MAX_UPLOADING_BLOCKS` /// (`internal/upload/streamUploader.ts`); C#'s shared transfer queue depth is 6 /// (`ProtonDriveClient.DefaultDegreeOfBlockTransferParallelism`). const MAX_CONCURRENT_BLOCK_UPLOADS: usize = 5; /// Content blocks encrypted concurrently within a single file. /// /// Reading, SHA-1 and the size bookkeeping stay strictly in block order; only /// the per-block PGP work fans out, onto the blocking pool. TS encrypts one /// block at a time (`internal/upload/encryptBlocks.ts`) and relies on buffering /// alone, which is enough there because the crypto is native; here a single /// core's rPGP throughput is comparable to a fast link, so one in-flight encrypt /// becomes the ceiling on exactly the machines that could otherwise saturate it. /// /// Kept below the client-wide permit pool alongside the buffer and upload /// windows (`4 + 4 + 5 < 16`) so the encryptor cannot starve its own uploader. const MAX_CONCURRENT_BLOCK_ENCRYPTS: usize = 4; /// Encrypted blocks allowed to wait between the encryptor and the uploader. /// /// TypeScript buffers 15 (`MAX_BUFFERED_BLOCKS`); ours is much lower because a /// buffered block also holds one of the client-wide in-flight block permits, /// which are shared with downloads. const MAX_BUFFERED_UPLOAD_BLOCKS: usize = 4; /// Attempts per block upload before the whole upload fails (TS /// `MAX_BLOCK_UPLOAD_RETRIES`). Retries here are *above* the HTTP client's own /// retry policy: they cover an expired upload token, which can only be fixed by /// re-preparing the block. const MAX_BLOCK_UPLOAD_ATTEMPTS: usize = 3; /// Trashed links requested per page when enumerating the trash (C# /// `VolumeOperations.TrashPageSize`). const TRASH_PAGE_SIZE: usize = 500; /// Photos returned per timeline page (C# `PhotosNodeOperations.TimelinePageSize`). const TIMELINE_PAGE_SIZE: usize = 500; /// How much of a node to decrypt when building it. /// /// Unlocking a node's own key costs an S2K derivation (tens of milliseconds), so /// it is worth avoiding for callers that do not need what it protects. #[derive(Clone, Copy, PartialEq, Eq)] enum NodeDetail { /// Decrypt everything: the node's key, and for a file the content key and /// extended attributes it protects (so `claimed_size` and /// `claimed_modification_time` are populated). Full, /// Decrypt only what the *parent* key can read: name, and signature /// statuses. A file's key is left locked, leaving its claimed metadata and /// content-key/xattr verification absent. Folders are unaffected — their key /// is needed to reach their children. Light, } /// Candidate name hashes checked per `checkAvailableHashes` request (C# /// `NodeOperations.GetAvailableNameAsync` `batchSize`). const NAME_AVAILABILITY_BATCH: usize = 10; /// Longest node name the API accepts (C# `NodeOperations.MaxNodeNameLength`). const MAX_NODE_NAME_LENGTH: usize = 255; /// Reject a caller-supplied node name before it is encrypted and sent. /// /// C# `NodeOperations.ValidateNodeName` (itself mirroring the JS SDK's /// `validateNodeName`), applied on create, rename and rename-during-move. The /// length is counted in `char`s: C# counts UTF-16 code units, which differs only /// for astral-plane characters, and neither matches the server's own byte /// accounting — this is a client-side guard against obviously bad input, not the /// authority. fn validate_node_name(name: &str) -> Result<()> { if name.is_empty() { return Err(ProtonError::invalid_operation("Name must not be empty")); } if name.chars().count() > MAX_NODE_NAME_LENGTH { return Err(ProtonError::invalid_operation(format!( "Name must be {MAX_NODE_NAME_LENGTH} characters long at most" ))); } Ok(()) } /// High-level Proton Drive client. /// /// Holds an authenticated session plus an [`AccountClient`]. Because Proton's /// key model requires the mailbox password for decryption, the client is /// constructed with it (see [`ProtonDriveClient::new`]). #[derive(Clone)] pub struct ProtonDriveClient { http: ApiHttpClient, account: AccountClient, /// In-memory session/secret cache: the resolved My Files share key, root, /// volume id and per-folder decrypted node keys. Mirrors the C# /// `DriveSecretCache` (kept in-memory; PGP keys are not serialized here). cache: Arc>, /// Persistable entity cache (node metadata, ids). Mirrors C# /// `DriveEntityCache`; defaults to an in-memory repository but accepts any /// [`CacheRepository`] (e.g. an encrypted/on-disk one) via /// [`ProtonDriveClient::with_entity_cache`]. entities: DriveEntityCache, /// Telemetry sink for instrumented operations. Defaults to /// [`NoopTelemetry`]; supply one via /// [`ProtonDriveClient::with_telemetry`]. telemetry: Arc, /// Global cap on content blocks resident in memory across *all* downloads /// this client is running. See /// [`with_max_inflight_blocks`](ProtonDriveClient::with_max_inflight_blocks). /// 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 /// [`ensure_photos`](ProtonDriveClient::ensure_photos) are check-then-act: /// they read `cache`, drop its guard, fetch and decrypt a share over the /// network, then re-acquire and store. Concurrent first calls would each run /// the whole bootstrap. Holding this across the network is safe precisely /// because it guards nothing else — `cache` stays free for every other /// caller — and the loser re-checks and finds the work already done. bootstrap: Arc, /// In-flight [`get_node`](ProtonDriveClient::get_node) loads. Concurrent /// lookups of the same node used to issue one link-details request and one /// S2K each; now the first runs and the rest wait on it. node_loads: Arc>>, /// In-flight parent-key resolutions, keyed by the volume, the key being /// resolved, and the photos routing flag. Siblings enumerated concurrently /// all want the same folder key, and resolving it walks (and decrypts) the /// whole ancestor chain. parent_key_loads: Arc>, /// Serializes context-share lookups against moves and remote invalidations. /// /// Lookups hold a read guard through cache/network/cache; context-changing /// operations hold a write guard through pre/post invalidation and the /// request. This makes stale cache reinsertion impossible without changing /// the normal HTTP error path. context_share_gate: Arc>, } /// The one-time-per-account setup that [`ProtonDriveClient`] single-flights. /// Separate locks: a Photos bootstrap has no reason to wait behind a My Files /// one, and neither ever waits on the other. #[derive(Default)] struct Bootstrap { my_files: Mutex<()>, photos: Mutex<()>, } /// Upper bound on decrypted folder node keys held in memory. Reached only by a /// daemon that has navigated into more than this many distinct folders; eviction /// is always safe (a missing key is re-derived from the parent chain or, for a /// root, the share key), so this caps memory rather than changing behavior. See /// SDK plan #9. const FOLDER_KEY_CACHE_CAP: usize = 512; /// Upper bound on node-to-context-share mappings held in memory. A miss is safe: /// the context endpoint is read-only and repopulates the entry. const CONTEXT_SHARE_CACHE_CAP: usize = 512; struct DriveCache { main_volume_id: Option, my_files_share: Option, my_files_root: Option, /// Resolved Photos share + root, populated on first photos access. `None` /// until [`ProtonDriveClient::ensure_photos`] runs; the share fields stay /// `None` when the account has no photos volume yet. photos_share: Option, photos_root: Option, /// `Some(false)` records a confirmed-absent photos volume so we don't re-hit /// `v2/shares/photos` on every timeline page; `None` means "not yet checked". photos_volume_exists: Option, /// Decrypted node key per folder, used as the parent key for its children. /// Bounded LRU ([`FOLDER_KEY_CACHE_CAP`]): a long-running daemon would /// otherwise accumulate one entry per folder ever visited and never release /// them. Entries are also dropped on the matching remote event /// ([`ProtonDriveClient::invalidate_caches_for_event`]). folder_keys: LruCache, /// The membership share behind each node another user shares with us, keyed /// by the shared node itself (which is that share's root link). `None` until /// [`ProtonDriveClient::shared_with_me_shares`] pages `v2/sharedwithme`. shared_with_me_shares: Option>, /// The highest (closest-to-root) share through which each node is reached. /// Move, subtree deletion, and access-change invalidations clear the cache /// because one event can affect mappings beyond the node it names. context_share_ids: LruCache, } impl Default for DriveCache { fn default() -> Self { Self { main_volume_id: None, my_files_share: None, my_files_root: None, photos_share: None, photos_root: None, photos_volume_exists: None, folder_keys: LruCache::new( NonZeroUsize::new(FOLDER_KEY_CACHE_CAP).expect("cap is non-zero"), ), shared_with_me_shares: None, context_share_ids: LruCache::new( NonZeroUsize::new(CONTEXT_SHARE_CACHE_CAP).expect("cap is non-zero"), ), } } } /// What a fresh-file draft should describe: where it goes, what it claims to /// be, and which of the upload variants it takes. Inputs to /// [`ProtonDriveClient::create_file_draft`]. struct FileDraftSpec<'a> { parent_uid: &'a NodeUid, name: &'a str, media_type: &'a str, intended_upload_size: i64, /// Encrypt blocks as SEIPDv2/AEAD rather than legacy SEIPDv1. aead: bool, /// Route the draft through the photos volume/share instead of My Files. for_photos: bool, /// Replace an existing draft at the same name instead of failing on it. override_existing_draft: bool, } /// An open revision draft ready to receive content blocks: the target revision /// plus the keys and signing identity needed to encrypt, sign and seal it. /// Produced by [`ProtonDriveClient::create_file_draft`] (new file) or /// [`ProtonDriveClient::create_revision_draft`] (new revision on an existing /// file); consumed by `write_blocks` + `seal_revision`. struct RevisionDraft { volume_id: VolumeId, link_id: LinkId, revision_id: String, node_key: PrivateKey, content_key: ContentKey, address_id: AddressId, email: String, signing_key: PrivateKey, /// The parent folder's hash key (HMAC key). Carried from draft creation so a /// photo seal can compute its `ContentHash` without re-fetching it. Empty for /// new-revision drafts (which don't recompute it). parent_hash_key: Vec, } /// Outcome of writing every content block of a revision: the content manifest /// (concatenated ciphertext SHA-256 digests, in index order) plus the metadata /// the sealing step records in the revision's extended attributes. struct BlockWriteResult { manifest: Vec, block_sizes: Vec, total_size: i64, sha1_hex: String, } /// One encrypted content block on its way to storage, as handed from the /// encryptor to the uploader. /// /// It carries its own [`BlockCreationRequest`] because that is what re-minting /// an expired upload token needs, and the client-wide in-flight block permit, /// which is released only once the ciphertext has been stored. struct PreparedBlock { ciphertext: Bytes, /// SHA-256 of the ciphertext — this block's manifest entry. digest: [u8; 32], request: BlockCreationRequest, permit: OwnedSemaphorePermit, } /// A thumbnail encrypted and ready to ride the first block-token request. struct EncryptedThumbnail { thumbnail_type: i32, ciphertext: Bytes, /// SHA-256 of the ciphertext — this thumbnail's manifest entry. digest: [u8; 32], request: ThumbnailCreationRequest, } /// One blob to store: a content block or a thumbnail, with the target the /// server issued for it. struct UploadJob { /// Block index, or `None` for a thumbnail (thumbnails contribute their /// digest to the manifest up front, not through the upload result). index: Option, ciphertext: Bytes, digest: [u8; 32], /// The block's creation request, for re-minting an expired token. `None` for /// thumbnails: the API only issues their tokens with the first block /// request, so there is nothing to re-request them with (TS says the same). request: Option, target: BlockUploadTarget, permit: Option, } /// The ids a block upload needs to (re-)prepare a block. /// /// Owned rather than borrowed from the [`RevisionDraft`] so an upload future /// never carries a lifetime — a borrowed one would make callers of /// `upload_file_from` unspawnable (see `tests/spawnable.rs`). #[derive(Clone)] struct UploadContext { address_id: AddressId, volume_id: VolumeId, link_id: LinkId, revision_id: String, } impl UploadContext { fn from_draft(draft: &RevisionDraft) -> Self { Self { address_id: draft.address_id.clone(), volume_id: draft.volume_id.clone(), link_id: draft.link_id.clone(), revision_id: draft.revision_id.clone(), } } } #[derive(Clone)] struct ShareKey { share_id: ShareId, /// The address that owns the share — the membership/signing address used /// when creating nodes under it. address_id: AddressId, key: PrivateKey, } impl ProtonDriveClient { /// Build a Drive client from a resumed session and the mailbox password. /// /// The entity cache defaults to an in-memory store; use /// [`with_entity_cache`](Self::with_entity_cache) to supply a persistent /// (e.g. encrypted/on-disk) [`CacheRepository`]. pub fn new(session: &ProtonApiSession, mailbox_password: impl Into>) -> Self { Self::with_entity_cache(session, mailbox_password, InMemoryCacheRepository::shared()) } /// Build a Drive client backed by a caller-supplied entity-cache /// repository. Wrap it in an /// [`EncryptedCacheRepository`](proton_sdk::cache::EncryptedCacheRepository) /// and/or an on-disk implementation to persist node metadata across runs. pub fn with_entity_cache( session: &ProtonApiSession, mailbox_password: impl Into>, entity_repository: Arc, ) -> Self { Self { http: session.http().with_base_route("drive/"), account: AccountClient::new(session, mailbox_password), cache: Arc::new(Mutex::new(DriveCache::default())), 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()), node_loads: Arc::new(SingleFlight::default()), parent_key_loads: Arc::new(SingleFlight::default()), context_share_gate: Arc::new(RwLock::new(())), } } /// Build a Drive client whose key chain unlocks from already-known key /// salts, so no `core/v4/keys/salts` call is made. /// /// That endpoint needs the `locked` scope, which a token from /// `auth/v4/refresh` does not carry — a daemon that resumes a persisted /// session would otherwise fail every start with a 403 once its original /// login token has been refreshed. Capture the salts right after login via /// [`account`](Self::account) + [`AccountClient::key_salts`], persist them /// with the session, and pass them here on resume. pub fn with_key_salts( session: &ProtonApiSession, mailbox_password: impl Into>, key_salts: Vec, ) -> Self { Self::with_account( session, AccountClient::with_key_salts(session, mailbox_password, key_salts), ) } /// Build a Drive client whose key chain unlocks from per-key passphrases /// already derived, with no mailbox password at all. /// /// For a client that persists a session: storing the derived passphrases /// ([`AccountClient::key_passphrases`], captured right after login) in /// place of the mailbox password means the store never holds the /// password itself — which, on a single-password account, is the login /// password. They unlock the keys all the same, so keep them as secret. pub fn with_key_passphrases( session: &ProtonApiSession, key_passphrases: KeyPassphrases, ) -> Self { Self::with_account( session, AccountClient::with_key_passphrases(session, key_passphrases), ) } fn with_account(session: &ProtonApiSession, account: AccountClient) -> Self { Self { http: session.http().with_base_route("drive/"), account, cache: Arc::new(Mutex::new(DriveCache::default())), 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()), node_loads: Arc::new(SingleFlight::default()), parent_key_loads: Arc::new(SingleFlight::default()), context_share_gate: Arc::new(RwLock::new(())), } } /// The account client backing this Drive client's key chain. pub fn account(&self) -> &AccountClient { &self.account } /// The address the My Files share belongs to, and the keys its passphrase /// is encrypted to (see [`proton_sdk::crypto::recipients`]). Decrypts /// nothing and creates nothing, so it answers even when the share key does /// not open: matched against /// [`AccountClient::key_report`](proton_sdk::account::AccountClient::key_report), /// it names the address key My Files needs. pub async fn my_files_share_recipients(&self) -> Result<(AddressId, Vec>)> { let response: MyFilesShareResponse = self.http.get("v2/shares/my-files").await?; let recipients = proton_sdk::crypto::recipients(&response.share.passphrase)?; Ok((response.share.address_id, recipients)) } /// Total account storage usage (`MaxSpace`/`UsedSpace`), account-wide across /// all Proton products. Convenience passthrough to /// [`AccountClient::quota`](proton_sdk::account::AccountClient::quota). pub async fn quota(&self) -> Result { self.account.quota().await } /// Swap in a caller-supplied entity-cache repository on an already-built /// client. /// /// [`with_entity_cache`](Self::with_entity_cache) is a constructor, so it /// cannot be combined with [`with_key_salts`](Self::with_key_salts) — and a /// client that resumes a persisted session needs the salts *and* wants a /// persistent cache. This is the chainable form, so both are reachable: /// /// ```ignore /// let client = ProtonDriveClient::with_key_salts(&session, password, salts) /// .with_entity_repository(my_repo); /// ``` /// /// The repository holds *decrypted* node metadata (names, sizes, parents) — /// never key material, which stays in the in-memory secret cache — so a /// persistent implementation should be encrypted at rest unless the caller /// already persists the same metadata itself. pub fn with_entity_repository(mut self, entity_repository: Arc) -> Self { self.entities = DriveEntityCache::new(entity_repository); self } /// Attach a telemetry observer to receive a /// [`TelemetryEvent`](proton_sdk::telemetry::TelemetryEvent) for each /// instrumented operation (transfers, navigation, mutations) plus a /// per-request event from the shared HTTP client (`http_request` for API /// calls, `storage_download` / `storage_upload` for block storage). /// Defaults to a no-op sink; pass /// [`TracingTelemetry::shared`](proton_sdk::telemetry::TracingTelemetry::shared) /// to bridge into `tracing`, or any custom [`Telemetry`] implementation. pub fn with_telemetry(mut self, telemetry: Arc) -> Self { // Also feed the shared HTTP client so per-request events (the // `http_request` / `storage_download` / `storage_upload` ops) reach the // same sink as the high-level Drive ops. self.http.set_telemetry(telemetry.clone()); self.telemetry = telemetry; self } /// Cap the content blocks this client holds in memory at once, across every /// transfer it is running — downloads *and* uploads. Defaults to /// [`DEFAULT_MAX_INFLIGHT_BLOCKS`]. /// /// A permit is held from the moment a block's fetch starts until its /// plaintext is consumed (download), or from the moment its plaintext is /// read until its ciphertext has been stored (upload), so /// `blocks * BLOCK_SIZE` is roughly the ceiling on resident block memory — /// the knob to turn down on a memory-constrained host, at the cost of /// transfer throughput. Must be non-zero; a zero cap would deadlock every /// transfer, so it is clamped to 1. Setting it to 1 serializes block /// transfers entirely, which is what the throughput comparison in /// `PERF_PLAN.md` uses to reproduce the pre-pipeline behavior. /// /// Applies to this client and every clone made from it *afterwards*; clones /// already taken keep the previous cap (they share the previous semaphore). pub fn with_max_inflight_blocks(mut self, blocks: usize) -> Self { self.block_slots = Arc::new(Semaphore::new(blocks.max(1))); 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`], the /// whole-file download path and the upload pipeline to acquire against. pub(crate) fn block_slots(&self) -> Arc { self.block_slots.clone() } /// What a [`RevisionReader`] needs from this client, and nothing else. /// /// The session is `Static` because an ordinary bearer session refreshes its /// own tokens inside [`ApiHttpClient`] on a 401; by the time an error /// reaches the transport there is nothing left to retry. The visitor path /// supplies a session that can genuinely be renewed. pub(crate) fn revision_transport(&self) -> RevisionTransport { RevisionTransport::authenticated(self.http.clone(), self.block_slots()) } /// Resolve (and cache) the user's "My Files" root folder. pub async fn get_my_files_folder(&self) -> Result { self.ensure_my_files().await?; let root_uid = self .cache .lock() .await .my_files_root .clone() .expect("ensure_my_files populates the root uid"); // The root folder was decrypted and cached during ensure_my_files; fetch // it back as a public node. self.get_node(&root_uid) .await? .ok_or_else(|| ProtonError::invalid_operation("My Files root folder not found")) } /// Resolve the highest share through which `uid` is reached. /// /// Uses Proton's `GET volumes/{vid}/links/{lid}/context` endpoint and keeps /// a bounded in-memory LRU because sharing and membership-address operations /// commonly ask for the same node repeatedly. The read guard serializes the /// full lookup against context-changing moves and remote invalidations, so a /// completed request cannot reinsert data after an invalidation. pub async fn context_share_id(&self, uid: &NodeUid) -> Result { let mut timer = self.telemetry.start("context_share_id"); let _context_guard = self.context_share_gate.read().await; if let Some(share_id) = self.cache.lock().await.context_share_ids.get(uid).cloned() { timer.attr("cache", "hit"); timer.success(); return Ok(share_id); } let response: ContextShareResponse = self.http.get(&context_share_path(uid)).await?; let share_id = response.context_share_id; self.cache .lock() .await .context_share_ids .put(uid.clone(), share_id.clone()); timer.attr("cache", "miss"); timer.success(); Ok(share_id) } /// Fetch a single node's decrypted metadata, or `None` if it does not exist. /// /// Read-through: a node already in the entity cache is returned without the /// link-details round-trip or the S2K decryption the cold path pays (SDK plan /// #7). The cache is kept honest by event-driven invalidation /// ([`invalidate_caches_for_event`](Self::invalidate_caches_for_event) drops /// a node on its remote change), so a hit is as fresh as the last event poll /// — the same staleness window a consumer's own tree has. Callers that must /// bypass the cache to force a refresh use /// [`enumerate_nodes`](Self::enumerate_nodes), which always hits the network. pub async fn get_node(&self, uid: &NodeUid) -> Result> { let mut timer = self.telemetry.start("get_node"); if let Some(info) = self.entities.try_get_node(uid).await? { timer.attr("cache", "hit"); timer.success(); return Ok(Some(info.node)); } // Concurrent callers asking for the same node share one load: the fetch // *and* the node-key S2K behind it. let client = self.clone(); let target = uid.clone(); let node = self .node_loads .run(uid.clone(), async move { client.load_node(&target).await }) .await?; timer.success(); Ok(node) } /// The network half of [`get_node`](Self::get_node): link details, parent /// key, decrypt. Runs behind [`ProtonDriveClient::node_loads`]. async fn load_node(&self, uid: &NodeUid) -> Result> { let response = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let Some(details) = response.links.into_iter().next() else { // Not found is a successful lookup, not a failure. return Ok(None); }; let parent_key = self .resolve_parent_key(&uid.volume_id, &details.link) .await?; let node = self .build_node(&uid.volume_id, &details, &parent_key, NodeDetail::Full) .await?; Ok(Some(node)) } /// Enumerate the [`NodeUid`]s of a folder's (non-trashed) children. /// /// Mirrors C# `FolderOperations.EnumerateChildrenAsync` (renamed to /// `EnumerateFolderChildrenNodeUidsAsync` on the client): enumeration now /// only lists uids — it does not fetch the folder key, link details, or /// decrypt anything. Callers materialize the nodes they care about via /// [`enumerate_nodes`](Self::enumerate_nodes), avoiding per-child decryption /// of the whole listing. pub async fn enumerate_folder_children_node_uids( &self, folder_uid: &NodeUid, ) -> Result> { let mut timer = self.telemetry.start("enumerate_folder_children_node_uids"); let mut uids = Vec::new(); let mut anchor: Option = None; loop { let mut path = format!( "v2/volumes/{}/folders/{}/children", folder_uid.volume_id, folder_uid.link_id ); if let Some(anchor_id) = &anchor { path.push_str(&format!("?AnchorID={anchor_id}")); } let page: FolderChildrenResponse = self.http.get(&path).await?; if page.link_ids.is_empty() { break; } for link_id in page.link_ids { uids.push(NodeUid::new(folder_uid.volume_id.clone(), link_id)); } if !page.more_results_exist { break; } anchor = page.anchor_id; if anchor.is_none() { break; } } timer.attr("node_count", uids.len()); timer.success(); Ok(uids) } /// Tell the server which nodes the user recently opened. /// /// Mirrors upstream `ReportRecentlyAccessedAsync` (`POST recently-accessed-items`), /// in batches of 50. An item without an access time is stamped with the /// moment of the call. A failed batch ends the call; earlier batches stay /// reported. pub async fn report_recently_accessed( &self, items: &[RecentlyAccessedReportItem], ) -> Result<()> { self.report_recently_accessed_to("recently-accessed-items", items) .await } /// As [`report_recently_accessed`](Self::report_recently_accessed), for /// photos (`POST photos/recently-accessed-items`). pub(crate) async fn report_photos_recently_accessed( &self, items: &[RecentlyAccessedReportItem], ) -> Result<()> { self.report_recently_accessed_to("photos/recently-accessed-items", items) .await } async fn report_recently_accessed_to( &self, path: &str, items: &[RecentlyAccessedReportItem], ) -> Result<()> { /// Items per request (upstream `RecentlyAccessedOperations.BatchSize`). const REPORT_BATCH: usize = 50; let now = now_epoch_seconds(); for batch in items.chunks(REPORT_BATCH) { let request = ReportRecentlyAccessedRequest { items: batch .iter() .map(|item| ReportRecentlyAccessedItem { volume_id: item.uid.volume_id.clone(), link_id: item.uid.link_id.clone(), access_time: item.access_time.unwrap_or(now), }) .collect(), }; let _: proton_sdk::api::ApiResponse = self.http.post(path, &request).await?; } Ok(()) } /// Fetch decrypted metadata for many nodes in one pass. /// /// Mirrors C# `NodeOperations.EnumerateNodesAsync(uids)`: the uids are /// grouped by volume and their link details fetched in batches of /// [`MAX_BATCH_COUNT`], then each node is decrypted against its resolved /// parent key. A node that does not exist is simply omitted; one that fails /// to decrypt is logged and skipped (matching enumeration's partial-node /// behavior), so the result may be shorter than `uids`. pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result> { self.enumerate_nodes_detail(uids, NodeDetail::Full).await } /// As [`enumerate_nodes`](Self::enumerate_nodes), but skips the parts of a /// *file* that only its own node key can decrypt. The returned files carry /// no [`claimed_size`](NodeKind::File::claimed_size) or /// [`claimed_modification_time`](NodeKind::File::claimed_modification_time), /// and no content-key or extended-attribute verification status. Everything /// else — name, uid, parentage, timestamps, trashed/shared flags, and /// `total_size_on_storage` — is identical. Folders are returned in full. /// /// Unlocking a file's node key costs an S2K derivation of tens of /// milliseconds, which dominates a walk over a large tree. Use this to /// enumerate cheaply and find what changed, then call /// [`enumerate_nodes`](Self::enumerate_nodes) for the few nodes whose /// claimed metadata you actually need. pub async fn enumerate_nodes_light(&self, uids: &[NodeUid]) -> Result> { self.enumerate_nodes_detail(uids, NodeDetail::Light).await } /// The shared body of [`enumerate_nodes`](Self::enumerate_nodes) and /// [`enumerate_nodes_light`](Self::enumerate_nodes_light). /// /// Three things happen concurrently where they used to be strictly serial: /// the per-volume batches are fetched [`MAX_CONCURRENT_DETAIL_FETCHES`] at a /// time, and within a batch the nodes are decrypted /// [`MAX_CONCURRENT_NODE_BUILDS`] at a time — which, now that the link crypto /// runs on the blocking pool, spreads the per-node S2K over several cores. /// Parent keys are still resolved *before* that fan-out, one per distinct /// [`ParentKeyId`]: siblings share a parent, and resolving concurrently would /// walk and decrypt the same ancestor chain many times over. /// /// Order is preserved (`buffered`, not `buffer_unordered`), as is the /// skip-and-warn behavior for a node whose parent key or own crypto fails. async fn enumerate_nodes_detail( &self, uids: &[NodeUid], detail: NodeDetail, ) -> Result> { let mut nodes = Vec::new(); for (volume_id, link_ids) in group_by_volume(uids) { let chunks: Vec> = link_ids .chunks(MAX_BATCH_COUNT) .map(<[LinkId]>::to_vec) .collect(); let mut fetches = stream::iter(chunks.into_iter().map(|chunk| { let client = self.clone(); let volume_id = volume_id.clone(); async move { client.get_link_details(&volume_id, &chunk).await } })) .buffered(MAX_CONCURRENT_DETAIL_FETCHES); while let Some(details) = fetches.try_next().await? { let parent_keys = resolve_parent_keys(&details.links, |link| { self.resolve_parent_key(&volume_id, link) }) .await; let buildable = details.links.into_iter().filter_map(|link_details| { let parent_key = parent_keys .get(&ParentKeyId::of(&link_details.link))? .clone(); Some((link_details, parent_key)) }); let mut built = stream::iter(buildable.map(|(link_details, parent_key)| { let client = self.clone(); let volume_id = volume_id.clone(); async move { let link_id = link_details.link.id.clone(); match client .build_node(&volume_id, &link_details, &parent_key, detail) .await { Ok(node) => Some(node), Err(e) => { tracing::warn!(link_id = %link_id, error = %e, "skipping undecryptable node"); None } } } })) .buffered(MAX_CONCURRENT_NODE_BUILDS); while let Some(node) = built.next().await { nodes.extend(node); } } } Ok(nodes) } /// The chain of nodes from the tree root down to `uid`, inclusive. /// /// The first element is the root (the node with no parent — My Files, a /// device root, or a share root); the last is `uid` itself. Mirrors JS /// `nodesAccess.getNodeHierarchy` and C# `TraversalOperations.FindRootForNode`, /// which walks `ParentUid` upwards and rejects cycles. /// /// Returns `Ok(None)` when `uid` itself does not exist. A missing *ancestor* /// is an error, not a truncated chain: a node whose parent cannot be read is /// not placeable in the tree. pub async fn get_node_hierarchy(&self, uid: &NodeUid) -> Result>> { let mut timer = self.telemetry.start("get_node_hierarchy"); let Some(node) = self.get_node(uid).await? else { timer.success(); return Ok(None); }; // Leaf-first while walking, reversed to root-first before returning. let mut chain = vec![node]; let mut visited: HashSet = HashSet::new(); visited.insert(uid.clone()); while let Some(parent_uid) = chain.last().and_then(|n| n.parent_uid.clone()) { // Both upstream SDKs throw on a parent cycle rather than spinning. if !visited.insert(parent_uid.clone()) { return Err(ProtonError::invalid_operation(format!( "Folder structure loop detected at {parent_uid}" ))); } let parent = self.get_node(&parent_uid).await?.ok_or_else(|| { ProtonError::invalid_operation(format!( "Node hierarchy is broken: parent {parent_uid} does not exist" )) })?; chain.push(parent); } chain.reverse(); timer.attr("depth", chain.len()); timer.success(); Ok(Some(chain)) } /// The absolute path of `uid` within its tree, e.g. `/Documents/report.pdf`. /// /// Built from [`get_node_hierarchy`](Self::get_node_hierarchy) with the root /// dropped — the root's decrypted name is an internal placeholder ("root"), /// not something a user ever sees. The root itself is therefore `/`. /// /// Beyond upstream: neither the C# nor the TypeScript SDK exposes paths. /// Names are not path-safe — a Drive node name may legitimately contain any /// character except `/` — so the result round-trips through /// [`get_node_by_path`](Self::get_node_by_path) but is not a filesystem path. pub async fn get_node_path(&self, uid: &NodeUid) -> Result> { let Some(hierarchy) = self.get_node_hierarchy(uid).await? else { return Ok(None); }; Ok(Some(join_node_path( hierarchy.iter().skip(1).map(|node| node.name.as_str()), ))) } /// Resolve a slash-separated path under `root_uid` to a node. /// /// `path` is matched segment by segment against decrypted child names; /// leading, trailing, and repeated slashes are ignored, so `/a/b/`, `a/b`, /// and `//a//b` are the same lookup. An empty path resolves to the root. /// Returns `Ok(None)` as soon as a segment has no match. /// /// Beyond upstream. Node names are encrypted server-side and there is no /// name-query endpoint, so this necessarily lists and decrypts one directory /// level per segment. It uses /// [`enumerate_nodes_light`](Self::enumerate_nodes_light) — only the name and /// kind matter here, so the per-file node-key unlock is skipped — then /// re-reads the final node in full so the returned [`Node`] carries complete /// metadata. /// /// Matching is exact and case-sensitive (Drive names are case-sensitive). /// When a folder holds several children of the same name — which the API /// permits — the first in listing order wins. pub async fn get_node_by_path(&self, root_uid: &NodeUid, path: &str) -> Result> { let mut timer = self.telemetry.start("get_node_by_path"); let mut current = root_uid.clone(); let mut descended = false; for segment in path_segments(path) { let Some(child) = self.find_child_by_name(¤t, segment).await? else { timer.success(); return Ok(None); }; current = child; descended = true; } // The walk only ever knew names and uids; fetch the target in full. let node = if descended { self.get_node(¤t).await? } else { self.get_node(root_uid).await? }; timer.success(); Ok(node) } /// Create every missing folder along `path` under `root_uid` (`mkdir -p`). /// /// Returns the uid of the deepest folder. Segments that already exist are /// reused — including when they were created concurrently, in which case the /// losing `create_folder` is retried as a lookup. A segment that exists but /// is a *file* is an error: the path cannot be extended through it. /// /// Beyond upstream. pub async fn create_folder_path(&self, root_uid: &NodeUid, path: &str) -> Result { let mut timer = self.telemetry.start("create_folder_path"); let mut current = root_uid.clone(); for segment in path_segments(path) { if let Some(existing) = self.find_existing_folder(¤t, segment).await? { current = existing; continue; } match self.create_folder(¤t, segment, None).await { Ok(uid) => current = uid, // Lost a race against another writer (or the name was taken // between the lookup and the create) — adopt the winner. Err(err) => { let Some(existing) = self.find_existing_folder(¤t, segment).await? else { return Err(err); }; current = existing; } } } timer.success(); Ok(current) } /// Resolve one path segment to a child uid by decrypted name. async fn find_child_by_name( &self, parent_uid: &NodeUid, name: &str, ) -> Result> { let child_uids = self.enumerate_folder_children_node_uids(parent_uid).await?; if child_uids.is_empty() { return Ok(None); } let children = self.enumerate_nodes_light(&child_uids).await?; Ok(children .into_iter() .find(|child| child.name == name) .map(|child| child.uid)) } /// As [`find_child_by_name`](Self::find_child_by_name), but demands a folder. async fn find_existing_folder( &self, parent_uid: &NodeUid, name: &str, ) -> Result> { let child_uids = self.enumerate_folder_children_node_uids(parent_uid).await?; if child_uids.is_empty() { return Ok(None); } let children = self.enumerate_nodes_light(&child_uids).await?; match children.into_iter().find(|child| child.name == name) { Some(child) if child.is_folder() => Ok(Some(child.uid)), Some(child) => Err(ProtonError::invalid_operation(format!( "Cannot create folder path: {name} exists as a file ({})", child.uid ))), None => Ok(None), } } /// 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 { 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 /// `EnumerateTrashNodeUidsAsync` on the client): page the trash listing /// (`GET volumes/{vid}/trash`), which groups trashed links by share, and /// emit a [`NodeUid`] per link. Enumeration no longer fetches share keys or /// decrypts — callers materialize via [`enumerate_nodes`](Self::enumerate_nodes). pub async fn enumerate_trash_node_uids(&self) -> Result> { let volume_id = self.main_volume_id().await?; let mut uids = Vec::new(); let mut page = 0_usize; loop { let path = format!("volumes/{volume_id}/trash?pageSize={TRASH_PAGE_SIZE}&page={page}"); let response: VolumeTrashResponse = self.http.get(&path).await?; let mut count = 0_usize; for group in &response.trash_by_share { for link_id in &group.link_ids { count += 1; uids.push(NodeUid::new(volume_id.clone(), link_id.clone())); } } // A full page implies there may be more (C# `mustTryMoreResults`). if count < TRASH_PAGE_SIZE { break; } page += 1; } Ok(uids) } /// The main volume id, resolved via My Files. pub async fn main_volume_id(&self) -> Result { self.ensure_my_files().await?; Ok(self .cache .lock() .await .main_volume_id .clone() .expect("ensure_my_files populates the volume id")) } /// The latest event id for a volume — the cursor seed for incremental sync. /// /// C# `VolumesApiClient.GetLatestEventAsync` (`GET volumes/{vid}/events/latest`). pub async fn latest_event_id(&self, volume_id: &VolumeId) -> Result { let path = format!("volumes/{volume_id}/events/latest"); let response: LatestVolumeEventResponse = self.http.get(&path).await?; Ok(response.event_id) } /// Enumerate volume events from `cursor`, draining every page. /// /// Mirrors C# `VolumeOperations.EnumerateEventsAsync`: /// - `cursor == None` seeds the stream: returns a single /// [`DriveEvent::CursorAdvanced`] carrying the latest event id; the caller /// persists it and passes it as `cursor` next time. /// - otherwise pages `GET v2/volumes/{vid}/events/{cursor}` until `More` is /// false. A `Refresh` page yields a terminal [`DriveEvent::ContinuityLost`] /// (caller must resync). An empty page only emits /// [`DriveEvent::CursorAdvanced`] when the server cursor moved. /// /// `scope` identifies the event scope (a node's tree, via /// [`Node::tree_event_scope_id`](crate::Node::tree_event_scope_id)); C# takes /// the same `DriveEventScopeId`. The caller persists the last returned /// event's [`id`](DriveEvent::id) as the next cursor. pub async fn enumerate_events( &self, scope: &DriveEventScopeId, cursor: Option<&DriveEventId>, ) -> Result> { let volume_id = scope.volume_id(); let mut cursor = match cursor { Some(cursor) => cursor.clone(), None => { let id = self.latest_event_id(volume_id).await?; return Ok(vec![DriveEvent::CursorAdvanced { id }]); } }; let mut events = Vec::new(); loop { let path = format!("v2/volumes/{volume_id}/events/{cursor}"); let page: VolumeEventListResponse = self.http.get(&path).await?; if page.refresh_required { events.push(DriveEvent::ContinuityLost { id: page.last_event_id, }); break; } if page.events.is_empty() { if page.last_event_id != cursor { events.push(DriveEvent::CursorAdvanced { id: page.last_event_id, }); } break; } for event in &page.events { events.push(to_drive_event(volume_id, event)?); } if !page.more_entries_exist { break; } cursor = page.last_event_id; } Ok(events) } /// Drop any cached state that a remote event may have invalidated. /// /// The in-memory [`DriveCache::folder_keys`] and the persistable /// [`DriveEntityCache`] are populated on read and otherwise never expire, so /// a node moved, renamed or re-keyed by another client would be served from a /// stale entry forever (the leak/staleness called out as SDK plan #9). An /// event consumer — e.g. [`EventManager`](crate::EventManager) — calls this /// per event so those caches converge on the server. /// /// - `NodeUpdated`: forget the node's own folder key and entity-cache entry, /// and clear all context-share mappings. A move can change the context of /// the node's whole subtree, while the event names only the moved root. /// - `NodeDeleted`: forget the node's own folder key and entity-cache entry, /// and clear context-share mappings because descendants may also be gone. /// - `ContinuityLost` / `ScopeAccessLost`: the cursor gap means we cannot know /// what changed, so drop every folder key and clear the entity cache. /// - `SharedWithMeUpdated`: clear context mappings because foreign-share /// access may have changed. Cursor-only events invalidate nothing. pub async fn invalidate_caches_for_event(&self, event: &DriveEvent) -> Result<()> { match event { DriveEvent::NodeUpdated { node_uid, .. } => { self.cache.lock().await.folder_keys.pop(node_uid); { let _context_guard = self.context_share_gate.write().await; self.cache.lock().await.context_share_ids.clear(); } self.entities.remove_node(node_uid).await?; } DriveEvent::NodeDeleted { node_uid, .. } => { self.cache.lock().await.folder_keys.pop(node_uid); { let _context_guard = self.context_share_gate.write().await; self.cache.lock().await.context_share_ids.clear(); } self.entities.remove_node(node_uid).await?; } DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => { self.cache.lock().await.folder_keys.clear(); { let _context_guard = self.context_share_gate.write().await; self.cache.lock().await.context_share_ids.clear(); } self.entities.clear().await?; } DriveEvent::SharedWithMeUpdated { .. } => { let _context_guard = self.context_share_gate.write().await; self.cache.lock().await.context_share_ids.clear(); } DriveEvent::CursorAdvanced { .. } => {} } Ok(()) } /// Download and decrypt a file's active revision, returning its plaintext. pub async fn download_file(&self, uid: &NodeUid) -> Result> { let mut buf = Vec::new(); self.download_file_to(uid, &mut buf).await?; Ok(buf) } /// Download and decrypt a file's active revision into `output`. /// /// Mirrors C# `FileDownloader` + `RevisionReader`: resolve the node key, /// decrypt the content key, then for each block fetch its ciphertext from /// block storage, decrypt it with the content session key, and write the /// plaintext out — accumulating the content manifest (thumbnail digests /// followed by per-block SHA-256 digests) for an authenticity check. /// /// Manifest-signature verification is non-fatal metadata (see /// [`verify_manifest`]): anonymous signatures verify against the node key, /// and named signatures resolve the author's public keys via /// `core/v4/keys/all` ([`AccountClient::public_keys`]). The resulting /// [`VerificationStatus`] is logged; a failure does not abort the download. pub async fn download_file_to( &self, uid: &NodeUid, output: &mut W, ) -> Result<()> { let mut timer = self.telemetry.start("download_file"); let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let detail = details .links .into_iter() .next() .ok_or_else(|| ProtonError::invalid_operation(format!("file {uid} not found")))?; let link = detail.link; let file = detail .file .or(detail.photo.map(|photo| photo.file)) .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { ProtonError::invalid_operation("file is missing its content key packet") })?; let content_key_packet = BASE64.decode(content_key_packet_b64.trim()).map_err(|e| { ProtonError::invalid_operation(format!("decode content key packet: {e}")) })?; let revision_id = file .active_revision .map(|r| r.id) .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?; // Resolve the node key and the content (session) key. let parent_key = self.resolve_parent_key(&uid.volume_id, &link).await?; let node_key = decrypt_link(&parent_key, &link)?.node_key; let content_key = node_key.decrypt_content_key(&content_key_packet)?; let (revision, blocks) = self .fetch_revision_blocks(&uid.volume_id, &uid.link_id, &revision_id) .await?; timer.attr("block_count", blocks.len()); // Manifest: thumbnail digests (ordered by type) then block digests in order. let mut manifest = Vec::new(); let mut thumbnails: Vec<&ThumbnailDto> = revision.thumbnails.iter().collect(); thumbnails.sort_by_key(|t| t.thumbnail_type); for thumb in thumbnails { if let Some(hash_b64) = &thumb.hash_digest { let digest = BASE64.decode(hash_b64.trim()).map_err(|e| { ProtonError::invalid_operation(format!("decode thumbnail digest: {e}")) })?; manifest.extend_from_slice(&digest); } } let block_count = blocks.len(); let targets = Arc::new(BlockTargets::new( self.revision_transport(), uid.clone(), revision_id.clone(), blocks, block_count, )); self.write_content_blocks(targets, block_count, &content_key, &mut manifest, output) .await?; verify_manifest(&self.account, &revision, &node_key, &manifest).await; timer.success(); Ok(()) } /// Open a seekable reader on a file's active revision. /// /// Each content block decrypts independently under the revision's content /// key ([`ContentKey::decrypt_block`]), so a reader can fetch just the /// blocks that overlap a requested range instead of the whole file — the /// basis for a FUSE/placeholder mount that hydrates on access. /// /// This resolves everything a read needs — link details, the ancestor chain, /// the node key (an S2K unlock), the content key, the block table and the /// per-block plaintext sizes — **once**. Subsequent /// [`RevisionReader::read_at`] calls cost only the block bodies they /// overlap. A caller that reads a file more than once (any on-demand mount: /// a `read(2)` is far smaller than a 4 MiB block) should hold the reader for /// as long as the file is open rather than calling /// [`download_range`](Self::download_range) per read. /// /// Block plaintext sizes come from the revision's extended attributes /// (`Common.BlockSizes`); absent that, blocks are assumed to be /// [`DEFAULT_BLOCK_SIZE`] with a possibly-shorter final block inferred from /// the recorded total size. /// /// The reader is pinned to the revision that is active now; it does not /// follow later revisions of the same file. pub async fn open_revision(&self, uid: &NodeUid) -> Result { self.open_revision_inner(uid, None).await } /// Open a reader on `revision_id`, or on the file's active revision when it /// is `None`. async fn open_revision_inner( &self, uid: &NodeUid, revision_id: Option<&str>, ) -> Result { let mut timer = self.telemetry.start("open_revision"); let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let detail = details .links .into_iter() .next() .ok_or_else(|| ProtonError::invalid_operation(format!("file {uid} not found")))?; let link = detail.link; let file = detail .file .or(detail.photo.map(|photo| photo.file)) .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { ProtonError::invalid_operation("file is missing its content key packet") })?; let content_key_packet = BASE64.decode(content_key_packet_b64.trim()).map_err(|e| { ProtonError::invalid_operation(format!("decode content key packet: {e}")) })?; // An explicit revision wins; otherwise pin whatever is active now. let revision_id = match revision_id { Some(id) => id.to_string(), None => file .active_revision .map(|r| r.id) .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?, }; let parent_key = self.resolve_parent_key(&uid.volume_id, &link).await?; let node_key = decrypt_link(&parent_key, &link)?.node_key; let content_key = node_key.decrypt_content_key(&content_key_packet)?; let (revision, blocks) = self .fetch_revision_blocks(&uid.volume_id, &uid.link_id, &revision_id) .await?; timer.attr("block_count", blocks.len()); let block_sizes = self .resolve_block_sizes(&node_key, &revision, blocks.len()) .await?; timer.success(); Ok(RevisionReader::new( self.revision_transport(), uid.clone(), revision_id, content_key, blocks, block_sizes, )) } /// Download and decrypt only the plaintext byte range `[offset, offset + length)` /// of a file's active revision. /// /// A one-shot convenience over [`open_revision`](Self::open_revision): it /// resolves the revision's keys and block table, reads the range, and drops /// them. Callers that issue more than one read against the same file should /// hold a [`RevisionReader`] instead — this call repeats the whole /// resolution (two API round-trips and an S2K node-key unlock) every time. /// /// The range is clamped to the file's length, so a read at or past EOF /// yields fewer bytes (or none). /// /// Unlike [`download_file_to`](Self::download_file_to), a partial read /// cannot recompute the full content manifest, so manifest-signature /// verification is skipped. pub async fn download_range(&self, uid: &NodeUid, offset: u64, length: u64) -> Result> { let mut timer = self.telemetry.start("download_range"); let reader = self.open_revision(uid).await?; let out = reader.read_at(offset, length).await?; timer.attr("byte_count", out.len()); timer.success(); Ok(out) } /// List a file's revision history, newest state first as the server orders it. /// /// Mirrors TS `NodesRevisons.iterateRevisions` /// (`GET v2/volumes/{vid}/files/{lid}/revisions`). Only **active** and /// **superseded** revisions are returned: the listing also carries drafts — /// in-flight uploads that have no readable content — and TS filters them out /// the same way. A file always has exactly one active revision unless an /// upload is mid-flight. /// /// Each revision's extended attributes are decrypted with the file's node /// key to fill the `claimed_*` fields; a revision whose `XAttr` is absent or /// unreadable is still returned, with those fields `None`. pub async fn enumerate_revisions(&self, file_uid: &NodeUid) -> Result> { let mut timer = self.telemetry.start("enumerate_revisions"); let node_key = self.file_node_key(file_uid).await?; let path = format!( "v2/volumes/{}/files/{}/revisions", file_uid.volume_id, file_uid.link_id ); let response: RevisionListResponse = self.http.get(&path).await?; let mut revisions = Vec::new(); for item in response.revisions { if !is_listable_revision_state(item.state) { continue; } revisions.push(self.build_revision(file_uid, &node_key, item).await); } timer.attr("revision_count", revisions.len()); timer.success(); Ok(revisions) } /// Fetch one revision's metadata by id. /// /// Mirrors TS `NodesRevisons.getRevision`. `NoBlockUrls=true`: this is a /// metadata read, and asking for block URLs would make the server mint /// short-lived storage tokens nothing here uses. pub async fn get_revision( &self, file_uid: &NodeUid, revision_id: &str, ) -> Result> { let node_key = self.file_node_key(file_uid).await?; let path = format!( "v2/volumes/{}/files/{}/revisions/{}?NoBlockUrls=true", file_uid.volume_id, file_uid.link_id, revision_id ); let response: RevisionMetadataResponse = match self.http.get(&path).await { Ok(response) => response, // A revision that is not there is a successful lookup, not an error. Err(e) if is_not_found(&e) => return Ok(None), Err(e) => return Err(e), }; Ok(Some( self.build_revision(file_uid, &node_key, response.revision) .await, )) } /// Make an older revision current again. /// /// Mirrors TS `NodesRevisons.restoreRevision` /// (`POST …/revisions/{rid}/restore`). The server does **not** move the /// pointer in place: restoring creates a *new* active revision with the old /// content, so the revision that was active before stays in the history as /// superseded and `revision_id` keeps identifying the one restored *from*. /// /// **The restore is asynchronous.** The server answers HTTP 202 and applies /// it in the background, so a read issued straight afterwards may still see /// the previous active revision. Poll /// [`enumerate_revisions`](Self::enumerate_revisions) (or re-read the file) /// until the new revision appears rather than assuming it is live on return. /// /// The file's cached entry is dropped, since its active revision — and with /// it the size and modification time a caller just read — has changed. pub async fn restore_revision(&self, file_uid: &NodeUid, revision_id: &str) -> Result<()> { let mut timer = self.telemetry.start("restore_revision"); let path = format!( "v2/volumes/{}/files/{}/revisions/{}/restore", file_uid.volume_id, file_uid.link_id, revision_id ); // An empty *object*, not `()`: serde renders the unit type as `null`, // which the API rejects with "JSON parsing of request body failed". let result: Result = self.http.post(&path, &serde_json::json!({})).await; match result { Ok(_) => {} // Restore is processed asynchronously: the server answers HTTP 202 // with an envelope whose code is not `1000`, which the generic // response parser reports as an error. The request was accepted, so // treat it as success — the new active revision appears once the // server finishes, which a subsequent read observes. Err(ProtonError::Api(e)) if e.http_status == 202 => {} Err(e) => return Err(e), } self.entities.remove_node(file_uid).await?; timer.success(); Ok(()) } /// Permanently delete one revision from a file's history. /// /// Mirrors TS `NodesRevisons.deleteRevision`. The content is unrecoverable. /// Deleting the *active* revision is rejected by the server — restore a /// different one first, or delete the file itself. pub async fn delete_revision(&self, file_uid: &NodeUid, revision_id: &str) -> Result<()> { let mut timer = self.telemetry.start("delete_revision"); self.delete_revision_by_ids(&file_uid.volume_id, &file_uid.link_id, revision_id) .await?; timer.success(); Ok(()) } /// Open a reader on a *specific* revision rather than the active one. /// /// The counterpart of [`open_revision`](Self::open_revision), which always /// pins the active revision. Use it to read a superseded revision's content /// — previewing a version before deciding whether to /// [`restore_revision`](Self::restore_revision) it. pub async fn open_revision_at( &self, file_uid: &NodeUid, revision_id: &str, ) -> Result { self.open_revision_inner(file_uid, Some(revision_id)).await } /// Download a specific revision's plaintext. /// /// As with [`download_range`](Self::download_range), the content manifest is /// not verified — see [`download_file_to`](Self::download_file_to) for the /// verified whole-file path on the active revision. pub async fn download_revision( &self, file_uid: &NodeUid, revision_id: &str, ) -> Result> { let mut out = Vec::new(); self.download_revision_to(file_uid, revision_id, &mut out) .await?; Ok(out) } /// As [`download_revision`](Self::download_revision), streaming into `writer`. pub async fn download_revision_to( &self, file_uid: &NodeUid, revision_id: &str, writer: &mut W, ) -> Result<()> { let mut timer = self.telemetry.start("download_revision"); let reader = self.open_revision_at(file_uid, revision_id).await?; // Read block-aligned so each block is fetched and decrypted exactly once. let mut offset = 0_u64; let size = reader.size(); while offset < size { let chunk = reader.read_at(offset, DEFAULT_BLOCK_SIZE as u64).await?; if chunk.is_empty() { break; } offset += chunk.len() as u64; writer .write_all(&chunk) .map_err(|e| ProtonError::invalid_operation(format!("write revision: {e}")))?; } timer.success(); Ok(()) } /// Turn a listing entry into a public [`Revision`], decrypting its extended /// attributes for the `claimed_*` fields. /// /// Infallible by construction: unreadable attributes are logged and leave /// the claimed fields `None`, because a revision the caller cannot describe /// in full is still one they may want to restore or delete. async fn build_revision( &self, file_uid: &NodeUid, node_key: &PrivateKey, item: RevisionListItemDto, ) -> Revision { let common = match &item.extended_attributes { Some(xattr) => match decrypt_extended_attributes_verified( &self.account, node_key, item.signature_email.as_deref(), xattr, ) .await { Ok((attrs, _status)) => attrs.common, Err(e) => { tracing::debug!( revision_id = %item.id, error = %e, "revision extended attributes did not decrypt" ); None } }, None => None, }; Revision { file_uid: file_uid.clone(), revision_id: item.id, state: RevisionState::from_raw(item.state), creation_time: item.creation_time, size_on_storage: item.size, claimed_size: common.as_ref().and_then(|c| c.size), claimed_modification_time: common.as_ref().and_then(|c| c.modification_time.clone()), claimed_sha1: common .as_ref() .and_then(|c| c.digests.as_ref()) .and_then(|d| d.sha1.clone()), signature_email: item.signature_email.filter(|email| !email.is_empty()), has_thumbnails: !item.thumbnails.is_empty(), } } /// Resolve a file's own node key, unlocking it via its parent. async fn file_node_key(&self, file_uid: &NodeUid) -> 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 parent_key = self .resolve_parent_key(&file_uid.volume_id, &detail.link) .await?; Ok(decrypt_link(&parent_key, &detail.link)?.node_key) } /// Plaintext size of each content block, in block order. /// /// Decrypts the revision's extended attributes — *verified*, which is what /// needs an account client and is therefore why this lives here rather than /// in `transport` — and hands them to /// [`rank_block_sizes`](crate::transport::rank_block_sizes), which owns the /// ranking rules and the refusal. async fn resolve_block_sizes( &self, node_key: &PrivateKey, revision: &RevisionDto, block_count: usize, ) -> Result> { let common = match &revision.extended_attributes { Some(xattr) => match decrypt_extended_attributes_verified( &self.account, node_key, revision.signature_email.as_deref(), xattr, ) .await { Ok((attrs, _status)) => attrs.common, // Distinguished from "absent" on purpose: xattrs that are // present but unreadable mean either a key/signature problem or // a schema we do not know, and both are worth seeing in a log // before the size falls back to inference. Err(e) => { tracing::debug!(error = %e, "revision extended attributes did not decrypt"); None } }, None => None, }; rank_block_sizes(common.as_ref(), &revision.id, block_count) } /// Download and decrypt a file's thumbnail of the given type, if it has one. /// /// Mirrors C# `FileOperations.EnumerateThumbnailsAsync` (single-file): pick /// the active revision's thumbnail header of `thumbnail_type`, resolve its /// block to a download URL (`POST volumes/{vid}/thumbnails`), fetch the /// ciphertext from block storage and decrypt it with the content key — the /// same session key and block format as content blocks. Returns `Ok(None)` /// when the file has no thumbnail of that type. pub async fn download_thumbnail( &self, uid: &NodeUid, thumbnail_type: ThumbnailType, ) -> Result>> { self.download_thumbnail_ctx(uid, thumbnail_type, false) .await } /// As [`download_thumbnail`](Self::download_thumbnail), but routes node and /// ancestor lookups to the photos endpoint when `for_photos`. pub(crate) async fn download_thumbnail_ctx( &self, uid: &NodeUid, thumbnail_type: ThumbnailType, for_photos: bool, ) -> Result>> { let (content_key, thumbnail_id) = self .file_thumbnail_target(uid, thumbnail_type, for_photos) .await?; let thumbnail_id = match thumbnail_id { Some(id) => id, None => return Ok(None), }; let response: ThumbnailBlockListResponse = self .http .post_query( &format!("volumes/{}/thumbnails", uid.volume_id), &ThumbnailBlockListRequest { thumbnail_ids: vec![thumbnail_id.clone()], }, ) .await?; let block = response .blocks .into_iter() .find(|b| b.thumbnail_id == thumbnail_id) .ok_or_else(|| { let reason = response .errors .iter() .find(|e| e.thumbnail_id == thumbnail_id) .map(|e| e.error.clone()) .unwrap_or_else(|| "thumbnail block not returned".to_string()); ProtonError::invalid_operation(format!("resolve thumbnail block: {reason}")) })?; let ciphertext = self .http .get_storage_blob(&block.bare_url, &block.token) .await?; let plaintext = content_key.decrypt_thumbnail(&ciphertext)?; Ok(Some(plaintext)) } /// Batch-download the thumbnails of `uids` of the given `thumbnail_type`. /// /// Mirrors C# `FileOperations.EnumerateThumbnailsAsync`: groups files by /// volume, resolves each file's content key + thumbnail block id, resolves /// block ids to download URLs in batches of up to 30 /// (`MaxThumbnailIdsPerRequest`), then fetches + decrypts each. Per-file /// failures (node missing, not a file, no thumbnail of the requested type, /// download/decrypt error) are reported in the returned [`FileThumbnail`] /// rather than aborting the batch. Returned order is not guaranteed to match /// the input order. pub async fn enumerate_thumbnails( &self, uids: &[NodeUid], thumbnail_type: ThumbnailType, ) -> Result> { self.enumerate_thumbnails_ctx(uids, thumbnail_type, false) .await } /// As [`enumerate_thumbnails`](Self::enumerate_thumbnails), but routes /// lookups to the photos endpoint when `for_photos`. pub(crate) async fn enumerate_thumbnails_ctx( &self, uids: &[NodeUid], thumbnail_type: ThumbnailType, for_photos: bool, ) -> Result> { let mut results: Vec = Vec::new(); // Group link ids by volume, preserving first-seen volume order. let mut volume_order: Vec = Vec::new(); let mut by_volume: HashMap> = HashMap::new(); for uid in uids { by_volume .entry(uid.volume_id.clone()) .or_insert_with(|| { volume_order.push(uid.volume_id.clone()); Vec::new() }) .push(uid.link_id.clone()); } for volume_id in volume_order { let link_ids = by_volume.remove(&volume_id).unwrap_or_default(); // thumbnail_id -> (file uid, content key) for files that have one. let mut targets: HashMap = HashMap::new(); for link_id in link_ids { let uid = NodeUid::new(volume_id.clone(), link_id); match self .file_thumbnail_target(&uid, thumbnail_type, for_photos) .await { Ok((content_key, Some(thumbnail_id))) => { targets.insert(thumbnail_id, (uid, content_key)); } Ok((_, None)) => { let msg = format!("node {uid} has no thumbnail of the requested type"); results.push(FileThumbnail::err(uid, ProtonError::invalid_operation(msg))); } Err(e) => results.push(FileThumbnail::err(uid, e)), } } let thumbnail_ids: Vec = targets.keys().cloned().collect(); for chunk in thumbnail_ids.chunks(MAX_THUMBNAIL_IDS_PER_REQUEST) { let response: ThumbnailBlockListResponse = match self .http .post_query( &format!("volumes/{volume_id}/thumbnails"), &ThumbnailBlockListRequest { thumbnail_ids: chunk.to_vec(), }, ) .await { Ok(response) => response, Err(e) => { // The whole chunk request failed; report each file in it. for id in chunk { if let Some((uid, _)) = targets.remove(id) { let msg = format!("resolve thumbnail blocks: {e}"); results.push(FileThumbnail::err( uid, ProtonError::invalid_operation(msg), )); } } continue; } }; let mut processed: std::collections::HashSet = std::collections::HashSet::new(); for block in response.blocks { processed.insert(block.thumbnail_id.clone()); let Some((uid, content_key)) = targets.remove(&block.thumbnail_id) else { continue; }; let downloaded = match self .http .get_storage_blob(&block.bare_url, &block.token) .await { Ok(ciphertext) => content_key .decrypt_thumbnail(&ciphertext) .map_err(ProtonError::from), Err(e) => Err(e), }; results.push(match downloaded { Ok(bytes) => FileThumbnail::ok(uid, bytes), Err(e) => FileThumbnail::err(uid, e), }); } for err in response.errors { if let Some((uid, _)) = targets.remove(&err.thumbnail_id) { processed.insert(err.thumbnail_id); results.push(FileThumbnail::err( uid, ProtonError::invalid_operation(err.error), )); } } for id in chunk { if processed.contains(id) { continue; } if let Some((uid, _)) = targets.remove(id) { results.push(FileThumbnail::err( uid, ProtonError::invalid_operation("thumbnail not found".to_string()), )); } } } } Ok(results) } /// Resolve a file's content key and the block id of its thumbnail of /// `thumbnail_type` (if any), routing lookups to the photos endpoint when /// `for_photos`. The content key decrypts the thumbnail block (same session /// key / block format as content blocks); the id resolves to a download URL /// via `POST volumes/{vid}/thumbnails`. async fn file_thumbnail_target( &self, uid: &NodeUid, thumbnail_type: ThumbnailType, for_photos: bool, ) -> Result<(ContentKey, Option)> { let details = self .get_link_details_ctx( &uid.volume_id, std::slice::from_ref(&uid.link_id), for_photos, ) .await?; let detail = details .links .into_iter() .next() .ok_or_else(|| ProtonError::invalid_operation(format!("file {uid} not found")))?; let link = detail.link; let file = detail .file .or(detail.photo.map(|photo| photo.file)) .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { ProtonError::invalid_operation("file is missing its content key packet") })?; let content_key_packet = BASE64.decode(content_key_packet_b64.trim()).map_err(|e| { ProtonError::invalid_operation(format!("decode content key packet: {e}")) })?; let revision_id = file .active_revision .map(|r| r.id) .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?; let parent_key = self .resolve_parent_key_ctx(&uid.volume_id, &link, for_photos) .await?; let node_key = decrypt_link(&parent_key, &link)?.node_key; let content_key = node_key.decrypt_content_key(&content_key_packet)?; // The revision's thumbnail headers carry the block id we resolve below. let (revision, _blocks) = self .fetch_revision_blocks(&uid.volume_id, &uid.link_id, &revision_id) .await?; let wanted = thumbnail_type.as_i32(); let thumbnail_id = revision .thumbnails .iter() .find(|t| t.thumbnail_type == wanted) .and_then(|t| t.id.clone()); Ok((content_key, thumbnail_id)) } /// Upload a new file under `parent_uid` with the given plaintext `contents`. /// /// Core single-file path (legacy SEIPDv1, no thumbnails, buffered). Mirrors /// C# `NewFileDraftProvider` + `RevisionWriter`: create a draft (new node /// key + content key), encrypt/sign/verify/upload each 4 MiB block, then /// seal the revision with a signed manifest and encrypted extended /// attributes. Returns the new file's [`NodeUid`]. pub async fn upload_file( &self, parent_uid: &NodeUid, name: &str, 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()) { match self .upload_small_file(parent_uid, name, media_type, contents) .await { Ok(uid) => return Ok(uid), Err(error) if small_upload_should_fall_back(&error) => { tracing::debug!(%error, "small file upload conflicted; falling back to blocks"); } Err(error) => return Err(error), } } self.upload_file_from( parent_uid, name, media_type, Cursor::new(contents), contents.len() as i64, Vec::new(), None, false, ) .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 /// size recorded in the revision's extended attributes is the actual number /// of bytes streamed. /// /// `thumbnails` are caller-rendered preview images attached to the revision; /// pass an empty `Vec` for none. They are uploaded before the content blocks /// and their ciphertext digests lead the content manifest. /// /// When `aead` is set, the file's content key and blocks use PGP AEAD /// (SEIPDv2 / AES-256-GCM) instead of the legacy SEIPDv1 path; this mirrors /// the C# `DriveCryptoEncryptBlocksWithPgpAead` feature flag. New revisions /// of an existing file inherit its content key's mode regardless. /// /// `reader` is a blocking [`Read`]; reads happen between block uploads, so a /// slow reader stalls the upload but never buffers more than one block. #[allow(clippy::too_many_arguments)] pub async fn upload_file_from( &self, parent_uid: &NodeUid, name: &str, media_type: &str, reader: R, intended_size: i64, thumbnails: Vec, last_modification_time: Option, aead: bool, ) -> Result { self.upload_file_from_inner( parent_uid, name, media_type, reader, intended_size, thumbnails, last_modification_time, aead, false, ) .await } /// Like [`upload_file_from`](Self::upload_file_from) but recovers a name /// collision with *any* client's unsealed draft, not only our own: if a draft /// of this name is already open, delete it and retry. Use only where the local /// copy is authoritative and a leftover draft is expected to be a stale /// interrupted upload — e.g. a mirror-sync push resuming across a daemon /// restart, which rotates the client uid so our own prior draft would otherwise /// look like a stranger's. A committed file of the same name is still a hard /// conflict and is never overwritten. Mirrors C# `NewFileDraftProvider` with /// `overrideExistingDraftByOtherClient: true`. #[allow(clippy::too_many_arguments)] pub async fn upload_file_replacing_draft_from( &self, parent_uid: &NodeUid, name: &str, media_type: &str, reader: R, intended_size: i64, thumbnails: Vec, last_modification_time: Option, aead: bool, ) -> Result { self.upload_file_from_inner( parent_uid, name, media_type, reader, intended_size, thumbnails, last_modification_time, aead, true, ) .await } #[allow(clippy::too_many_arguments)] async fn upload_file_from_inner( &self, parent_uid: &NodeUid, name: &str, media_type: &str, reader: R, intended_size: i64, thumbnails: Vec, last_modification_time: Option, aead: bool, override_existing_draft: bool, ) -> Result { let mut timer = self.telemetry.start("upload_file"); timer.attr("aead", aead); let draft = self .create_file_draft(FileDraftSpec { parent_uid, name, media_type, intended_upload_size: intended_size, aead, for_photos: false, override_existing_draft, }) .await?; let file_uid = NodeUid::new(draft.volume_id.clone(), draft.link_id.clone()); let written = self.write_blocks(&draft, reader, thumbnails).await?; timer.attr("size", written.total_size); self.seal_revision(&draft, &written, last_modification_time, None) .await?; timer.success(); Ok(file_uid) } /// Upload a new revision of an existing file with the given plaintext /// `contents`, superseding its currently active revision. /// /// Mirrors C# `NewRevisionDraftProvider`: reuse the file's existing node and /// content keys (no new key generation, no name/passphrase), open a draft /// 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()) { match self.upload_small_revision(file_uid, contents, None).await { Ok(()) => return Ok(()), Err(error) if small_upload_should_fall_back(&error) => { tracing::debug!(%error, "small revision upload conflicted; falling back to blocks"); } Err(error) => return Err(error), } } self.upload_new_revision_from( file_uid, Cursor::new(contents), contents.len() as i64, Vec::new(), None, ) .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. pub async fn upload_new_revision_from( &self, file_uid: &NodeUid, reader: R, intended_size: i64, thumbnails: Vec, last_modification_time: Option, ) -> Result<()> { let mut timer = self.telemetry.start("upload_new_revision"); let draft = self.create_revision_draft(file_uid, intended_size).await?; let written = self.write_blocks(&draft, reader, thumbnails).await?; timer.attr("size", written.total_size); self.seal_revision(&draft, &written, last_modification_time, None) .await?; timer.success(); Ok(()) } /// Upload a photo under the Photos root, sealing the revision with photo /// metadata (capture time, content hash, tags). /// /// Mirrors C# `ProtonPhotosClient.GetFileUploaderAsync` + /// `RevisionWriter.CreatePhotosRevisionUpdateRequest`: the draft is a normal /// file under the photos root (photos-routed key/hash-key resolution and the /// photos share's membership address), and the seal adds a `Photo` attribute /// block. Errors when the account has no photos volume. Live validation /// pending. #[allow(clippy::too_many_arguments)] pub(crate) async fn upload_photo_from( &self, name: &str, media_type: &str, reader: R, intended_size: i64, thumbnails: Vec, metadata: &PhotoUploadMetadata, aead: bool, ) -> Result { let mut timer = self.telemetry.start("upload_photo"); timer.attr("aead", aead); if !self.ensure_photos().await? { return Err(ProtonError::invalid_operation( "account has no photos volume", )); } let parent_uid = self .cache .lock() .await .photos_root .clone() .expect("ensure_photos populated the photos root"); let draft = self .create_file_draft(FileDraftSpec { parent_uid: &parent_uid, name, media_type, intended_upload_size: intended_size, aead, for_photos: true, override_existing_draft: false, }) .await?; let file_uid = NodeUid::new(draft.volume_id.clone(), draft.link_id.clone()); let written = self.write_blocks(&draft, reader, thumbnails).await?; timer.attr("size", written.total_size); let photos_attributes = build_photos_attributes(&draft.parent_hash_key, &written, metadata); self.seal_revision( &draft, &written, metadata.capture_time, Some(photos_attributes), ) .await?; timer.success(); Ok(file_uid) } /// Create a new (empty) folder named `name` under `parent_uid`, returning /// its [`NodeUid`]. /// /// Mirrors C# `NodeOperations.CreateFolderAsync` / `FolderCreationRequest`: /// generate a node key plus the folder's own child-name hash key, encrypt /// and sign the name/passphrase/hash-key to the parent (the hash key to the /// folder's own node key), then POST the folder. Live validation pending. pub async fn create_folder( &self, parent_uid: &NodeUid, name: &str, last_modification_time: Option, ) -> Result { let mut timer = self.telemetry.start("create_folder"); validate_node_name(name)?; let volume_id = parent_uid.volume_id.clone(); // Resolve the parent folder key + hash key and the membership address. 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?; // Generate the folder's node key and its own child-name hash key (the // hash key is encrypted to and signed by the folder's own node key). let node = generate_node_key()?; let node_hash_key = generate_node_hash_key(&node.key)?; 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 encrypted_passphrase = parent_key.encrypt(&node.passphrase)?; let passphrase_signature = signing_key.sign_detached(&node.passphrase)?; // C# always writes an `ExtendedAttributes` payload carrying the optional // modification time, encrypted to the folder's own node key and signed by // the address key (`key.EncryptAndSign(.., signingKey, compress)`). let extended_attributes = match last_modification_time { Some(_) => { let xattr = ExtendedAttributes { common: CommonExtendedAttributes { size: None, modification_time: last_modification_time.map(epoch_to_iso8601), block_sizes: None, digests: None, }, }; let xattr_json = serde_json::to_vec(&xattr).map_err(|e| { ProtonError::invalid_operation(format!("serialize folder xattr: {e}")) })?; Some( node.key .encrypt_and_sign(&signing_key, &xattr_json, false, true)?, ) } None => None, }; let request = FolderCreationRequest { name: encrypted_name, name_hash, parent_link_id: parent_uid.link_id.clone(), passphrase: encrypted_passphrase, passphrase_signature, key: node.locked_armored, node_hash_key, signature_email: email, extended_attributes, }; let path = format!("v2/volumes/{volume_id}/folders"); let created: FolderCreationResponse = self.http.post(&path, &request).await?; timer.success(); Ok(NodeUid::new(volume_id, created.folder.link_id)) } /// Resolve a free name for a new child of `parent_uid`, starting from /// `name`. /// /// Mirrors C# `NodeOperations.GetAvailableNameAsync`: hash `name` and a /// stream of alternates under the parent folder's hash key and ask the /// server which hashes are free (`checkAvailableHashes`), a batch of /// [`NAME_AVAILABILITY_BATCH`] at a time, returning the first available /// candidate. `target` picks the alternate spelling: a file keeps its /// extension last (`name (1).ext`), a folder appends to the whole name /// (`name (1)`), so `v1.2` does not become `v1 (1).2`. Call before /// create/upload to dodge `NameHashDigest` collisions. Returns `name` /// unchanged when it is already free. pub async fn get_available_name( &self, parent_uid: &NodeUid, name: &str, target: AvailableNameTarget, ) -> 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 client_uid = self.http.session_id().to_string(); // Candidate stream: the original name first, then its alternates. let alternates: Box + Send + '_> = match target { AvailableNameTarget::File => Box::new(alternate_file_names(name)), AvailableNameTarget::Folder => Box::new(alternate_folder_names(name)), }; let mut candidates = std::iter::once(name.to_string()).chain(alternates); loop { // Build one batch of candidates and their hex name-hash digests. let mut by_digest: HashMap = HashMap::new(); let mut order: Vec = Vec::new(); for candidate in candidates.by_ref().take(NAME_AVAILABILITY_BATCH) { let digest = hex::encode(hmac_sha256(&parent_hash_key, candidate.as_bytes())); order.push(digest.clone()); by_digest.insert(digest, candidate); } if order.is_empty() { // Unreachable in practice (the alternate stream is unbounded). return Err(ProtonError::invalid_operation( "exhausted candidate names without finding a free one", )); } let request = NodeNameAvailabilityRequest { name_hashes: order, client_uid: vec![client_uid.clone()], }; let path = format!( "v2/volumes/{}/links/{}/checkAvailableHashes", parent_uid.volume_id, parent_uid.link_id ); let response: NodeNameAvailabilityResponse = self.http.post_query(&path, &request).await?; // Take the first hash the server reports free and map it back to its // name (C# returns `AvailableHashes[0]`). if let Some(digest) = response.available_hashes.into_iter().next() { return by_digest.remove(&digest).ok_or_else(|| { ProtonError::invalid_operation("server returned an unknown name hash digest") }); } // Whole batch taken — try the next batch of alternates. } } /// Rename `uid` to `new_name`, re-encrypting the name to its parent folder. /// /// Mirrors C# `NodeOperations.RenameAsync` / `RenameLinkRequest`: encrypt and /// sign the new name to the parent, recompute its name hash, and send the /// node's *current* name hash as `OriginalHash`. `new_media_type` is sent as /// the link's `MIMEType` verbatim (C# `RenameNodeAsync`'s `newMediaType`): /// pass the file's current media type to keep it, or `None` (e.g. for a /// folder) to send no media type. Live validation pending. pub async fn rename_node( &self, uid: &NodeUid, new_name: &str, new_media_type: Option<&str>, ) -> Result<()> { let mut timer = self.telemetry.start("rename_node"); validate_node_name(new_name)?; let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let detail = details .links .into_iter() .next() .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} not found")))?; let link = detail.link; let parent_id = link .parent_id .clone() .ok_or_else(|| ProtonError::invalid_operation("cannot rename the root node"))?; let parent_uid = NodeUid::new(uid.volume_id.clone(), parent_id); 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?; // The original (current) name hash: from cache if the node was read // earlier (C# reads it from `CachedNodeInfo`), else from the link DTO, // else recomputed from the decrypted name. let original_hash = self .original_name_hash(uid, &link, &parent_key, &parent_hash_key) .await?; let encrypted_name = parent_key.encrypt_and_sign(&signing_key, new_name.as_bytes(), true, false)?; let name_hash = hex::encode(hmac_sha256(&parent_hash_key, new_name.as_bytes())); let media_type = new_media_type.map(str::to_owned); let request = RenameLinkRequest { name: encrypted_name, name_hash, name_signature_email: email, media_type, original_hash, }; let path = format!("v2/volumes/{}/links/{}/rename", uid.volume_id, uid.link_id); let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; timer.success(); Ok(()) } /// Move `uid` under `new_parent`, re-encrypting its passphrase and name to /// the destination folder. /// /// Mirrors C# `NodeOperations.MoveAsync` / `MoveLinkRequest`: the node /// passphrase is *rewrapped* to the destination parent key /// (`destinationKey.EncryptSessionKey(currentKey.DecryptSessionKey(...))`) so /// the secret — and thus the locked node key and its /// `NodePassphraseSignature` — is unchanged; the name is re-encrypted + signed /// to the destination; `Hash` is the new name hash under the destination's /// hash key, `OriginalHash` the current hash under the source parent's. Only /// same-volume moves are supported — cross-volume is rejected here, mirroring /// C# `NodeOperations.MoveSingleAsync`, which throws for differing volumes /// too (there is no cross-volume move in the C# public API). Live validation /// pending. pub async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()> { let mut timer = self.telemetry.start("move_node"); if uid.volume_id != new_parent.volume_id { return Err(ProtonError::invalid_operation( "cross-volume move is not supported", )); } 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, None, &dest_parent_key, &dest_hash_key, &signing_key, ) .await?; let request = MoveLinkRequest { parent_link_id: new_parent.link_id.clone(), passphrase: parts.passphrase, // The rewrap preserves the plaintext, so the existing detached // signature stays valid and is not re-sent (C# `MoveSingleAsync` // sends `PassphraseSignature = null` for signed nodes). Passing the // link's own value back is wrong: the API returns it as an empty // string for these nodes, and a serialized empty // `NodePassphraseSignature` is rejected 400 "should not be empty". // An anonymously signed node has no signer to keep, so the mover // signs its passphrase and names itself as `SignatureEmail`. 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, original_hash: parts.original_hash, }; let path = format!("v2/volumes/{}/links/{}/move", uid.volume_id, uid.link_id); let client = self.clone(); let _: proton_sdk::api::ApiResponse = run_context_share_mutation( self.context_share_gate.clone(), self.cache.clone(), async move { client.http.put(&path, &request).await }, ) .await?; timer.success(); 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# /// `ProtonDriveClient.MoveNodesAsync` (`PUT volumes/{vid}/links/move-multiple`, /// no `v2/` prefix). Same-volume only. Live validation pending. /// /// One outcome per input uid, in input order: a node that fails — /// cross-volume, unknown link, crypto, or a per-link error code in the batch /// envelope — does not stop the others, and a uid listed twice is moved once /// with both positions reporting that outcome. The outer `Err` is reserved /// for failures that make the whole call impossible (destination key, hash /// key, signing address, link lookup). pub async fn move_nodes( &self, uids: &[NodeUid], new_parent: &NodeUid, ) -> Result)>> { let mut timer = self.telemetry.start("move_nodes"); timer.attr("node_count", uids.len()); let mut seen: HashSet<&NodeUid> = HashSet::with_capacity(uids.len()); let items: Vec = uids .iter() .filter(|uid| seen.insert(*uid)) .map(|uid| NodeMoveItem { uid: uid.clone(), target_name: None, }) .collect(); let mut outcomes: HashMap> = self .move_nodes_streaming(items, new_parent.clone()) .try_collect() .await?; // A repeated uid cannot take the owned `Result` twice, so keep each // node's error text to rebuild the outcome for its later positions. let mut repeats: HashMap<&NodeUid, Option> = HashMap::new(); let results: Vec<(NodeUid, Result<()>)> = uids .iter() .map(|uid| { let outcome = match outcomes.remove(uid) { Some(outcome) => { repeats.insert(uid, outcome.as_ref().err().map(|e| e.to_string())); outcome } None => match repeats.get(uid) { Some(None) => Ok(()), Some(Some(message)) => Err(ProtonError::invalid_operation(message.clone())), None => Err(ProtonError::invalid_operation(format!( "move produced no outcome for node {uid}" ))), }, }; (uid.clone(), outcome) }) .collect(); timer.attr( "failed_count", results.iter().filter(|(_, r)| r.is_err()).count(), ); timer.success(); Ok(results) } /// Move nodes under one parent — renaming each on the way when its /// [`NodeMoveItem::target_name`] says so — yielding every node's outcome as /// the batch carrying it comes back. Mirrors C# /// `NodeMoveOperation.MoveMultipleAsync` (upstream `5cbbb0ad`, streamed since /// `e33712b9`). Live validation pending. /// /// - Batches hold at most [`MAX_BATCH_COUNT`] links and never mix /// anonymously signed nodes with signed ones: an anonymous node's /// passphrase is signed afresh by the mover, and the request's /// `SignatureEmail` then names the mover for every link in it. /// - A link rejected with `InvalidRequirements` (2000) — the `OriginalHash` /// sent no longer matches — is retried once with the name hash the server /// holds, provided the node still sits under the parent, with the name, the /// move was prepared against. Otherwise it fails as out of sync. /// - Per-node failures are outcomes: cross-volume, an invalid target name, a /// link that does not exist, crypto, a per-link error code (kept as /// [`ProtonError::Api`], so `AlreadyExists`, `TooManyChildren` and the rest /// stay matchable). A batch whose request fails outright charges that to /// every node it carried, and the stream carries on. /// - The outer `Err` ends the stream: the move could not start at all /// (destination key, hash key, signing address, link lookup). /// /// Outcome order follows completion, not input. A uid listed twice is moved /// once; its second entry gets an error of its own. pub fn move_nodes_streaming<'a>( &'a self, items: Vec, new_parent: NodeUid, ) -> impl futures::Stream)>> + 'a { stream::once(self.prepare_moves(items, new_parent)).flat_map(move |prepared| match prepared { Ok(run) => self.submit_moves(run).left_stream(), Err(e) => stream::iter([Err(e)]).right_stream(), }) } /// Validate, look up and encrypt every move up front; what fails here is /// already an outcome. async fn prepare_moves(&self, items: Vec, parent: NodeUid) -> Result { let mut outcomes = VecDeque::new(); let mut seen: HashSet = HashSet::with_capacity(items.len()); let mut targets = Vec::with_capacity(items.len()); for item in items { let rejected = if item.uid.volume_id != parent.volume_id { Some(ProtonError::invalid_operation( "cross-volume move is not supported", )) } else if let Some(Err(e)) = item.target_name.as_deref().map(validate_node_name) { Some(e) } else if !seen.insert(item.uid.link_id.clone()) { Some(ProtonError::invalid_operation(format!( "node {} is listed more than once in one move", item.uid ))) } else { None }; match rejected { Some(e) => outcomes.push_back((item.uid, Err(e))), None => targets.push(item), } } let mut run = MoveRun { parent, email: String::new(), queue: VecDeque::with_capacity(targets.len()), outcomes, }; if targets.is_empty() { return Ok(run); } let dest_parent_key = self.folder_node_key(&run.parent).await?; let dest_hash_key = self.parent_hash_key(&run.parent, &dest_parent_key).await?; let (_address_id, email, signing_key) = self.membership_address().await?; run.email = email; let link_ids: Vec = targets .iter() .map(|item| item.uid.link_id.clone()) .collect(); let mut links: HashMap = HashMap::with_capacity(link_ids.len()); for chunk in link_ids.chunks(MAX_BATCH_COUNT) { let details = self.get_link_details(&run.parent.volume_id, chunk).await?; for detail in details.links { links.insert(detail.link.id.clone(), detail.link); } } for item in targets { let Some(link) = links.get(&item.uid.link_id) else { let error = ProtonError::invalid_operation(format!("node {} not found", item.uid)); run.outcomes.push_back((item.uid, Err(error))); continue; }; let parts = match self .build_move_parts( &item.uid, link, item.target_name.as_deref(), &dest_parent_key, &dest_hash_key, &signing_key, ) .await { Ok(parts) => parts, Err(e) => { run.outcomes.push_back((item.uid, Err(e))); continue; } }; run.queue.push_back(PreparedMove { item: MoveMultipleLinksItem { link_id: item.uid.link_id.clone(), name: parts.encrypted_name, passphrase: parts.passphrase, name_hash: parts.name_hash, original_hash: parts.original_hash, passphrase_signature: parts.anonymous_signature.clone(), }, anonymous: parts.anonymous_signature.is_some(), source_parent: link.parent_id.clone(), current_name: parts.current_name, retried: false, uid: item.uid, }); } Ok(run) } /// Send the prepared moves batch by batch, reporting as each lands. fn submit_moves<'a>( &'a self, run: MoveRun, ) -> impl futures::Stream)>> + 'a { stream::unfold(run, move |mut run| async move { loop { if let Some(outcome) = run.outcomes.pop_front() { return Some((Ok(outcome), run)); } let batch = take_move_batch(&mut run.queue); if batch.is_empty() { return None; } self.submit_move_batch(&mut run, batch).await; } }) } async fn submit_move_batch(&self, run: &mut MoveRun, batch: Vec) { let anonymous = batch.first().is_some_and(|prepared| prepared.anonymous); let request = MoveMultipleLinksRequest { parent_link_id: run.parent.link_id.clone(), links: batch.iter().map(|prepared| prepared.item.clone()).collect(), name_signature_email: run.email.clone(), signature_email: anonymous.then(|| run.email.clone()), }; let path = format!("volumes/{}/links/move-multiple", run.parent.volume_id); let client = self.clone(); let result: Result = run_context_share_mutation( self.context_share_gate.clone(), self.cache.clone(), async move { client.http.put(&path, &request).await }, ) .await; let response = match result { Ok(response) => response, Err(e) => { // The batch failed as a whole; charge it to every node it // carried and keep going, so one bad batch cannot mask the rest. // An API error keeps its code on every node: a one-link batch // rejected as a whole is how a name collision usually arrives, // and callers branch on that code. for prepared in batch { let error = match &e { ProtonError::Api(api) => ProtonError::Api(api.clone()), other => ProtonError::invalid_operation(other.to_string()), }; run.outcomes.push_back((prepared.uid, Err(error))); } return; } }; let mut per_link: HashMap> = aggregate_outcomes(response).into_iter().collect(); for mut prepared in batch { match per_link.remove(&prepared.uid.link_id) { Some(Err(ProtonError::Api(error))) if error.code == ResponseCode::InvalidRequirements && !prepared.retried => { match self.remote_original_hash(&prepared).await { Ok(hash) => { prepared.item.original_hash = hash; prepared.retried = true; run.queue.push_back(prepared); } Err(e) => run.outcomes.push_back((prepared.uid, Err(e))), } } Some(outcome) => run.outcomes.push_back((prepared.uid, outcome)), None => { let error = ProtonError::invalid_operation(format!( "move returned no response for node {}", prepared.uid )); run.outcomes.push_back((prepared.uid, Err(error))); } } } } /// The name hash the server holds for a node whose move it rejected as out /// of date (C# `PrepareItemWithRemoteNameDigestAsync`). Only a node still /// under the parent, and with the name, the move was prepared against /// qualifies: anything else changed underneath the move, and retrying would /// undo that change. async fn remote_original_hash(&self, prepared: &PreparedMove) -> Result { let out_of_sync = || { ProtonError::invalid_operation(format!( "node {} changed while it was being moved", prepared.uid )) }; let details = self .get_link_details( &prepared.uid.volume_id, std::slice::from_ref(&prepared.uid.link_id), ) .await?; let link = details .links .into_iter() .next() .ok_or_else(|| { ProtonError::invalid_operation(format!("node {} not found", prepared.uid)) })? .link; let (Some(parent_id), Some(hash)) = (&link.parent_id, &link.name_hash) else { return Err(out_of_sync()); }; if Some(parent_id) != prepared.source_parent.as_ref() || hash.is_empty() { return Err(out_of_sync()); } let parent_uid = NodeUid::new(prepared.uid.volume_id.clone(), parent_id.clone()); let parent_key = self.folder_node_key(&parent_uid).await?; if parent_key.decrypt_armored_message(&link.name)? != prepared.current_name { return Err(out_of_sync()); } Ok(hash.clone()) } /// Build the per-node move crypto shared by [`move_node`] and /// [`move_nodes_streaming`]: resolve the source parent, rewrap the passphrase /// to `dest_parent_key`, encrypt + sign the name (`target_name`, else the /// current one) to the destination, and compute the new name hash (under /// `dest_hash_key`) and the original hash (under the source parent's hash /// key). An anonymously signed node also gets its passphrase signed by /// `signing_key`, as C# does from `PassphraseForAnonymousMove`. Mirrors the /// body of C# `MoveSingleAsync` / `NodeMoveOperation.TryPrepareItemAsync`. async fn build_move_parts( &self, uid: &NodeUid, link: &LinkDto, target_name: Option<&str>, dest_parent_key: &PrivateKey, dest_hash_key: &[u8], signing_key: &PrivateKey, ) -> Result { let parent_id = link .parent_id .clone() .ok_or_else(|| ProtonError::invalid_operation("cannot move the root node"))?; let source_parent_uid = NodeUid::new(uid.volume_id.clone(), parent_id); let source_parent_key = self.folder_node_key(&source_parent_uid).await?; let source_hash_key = self .parent_hash_key(&source_parent_uid, &source_parent_key) .await?; let current_name = source_parent_key.decrypt_armored_message(&link.name)?; let original_hash = self .original_name_hash(uid, link, &source_parent_key, &source_hash_key) .await?; let passphrase = source_parent_key.rewrap_message_to(&link.passphrase, dest_parent_key)?; let anonymous_signature = if link.signature_email.as_deref().is_none_or(str::is_empty) { let plaintext = source_parent_key.decrypt_armored_message(&link.passphrase)?; Some(signing_key.sign_detached(&plaintext)?) } else { None }; let name = target_name.map_or(current_name.as_slice(), str::as_bytes); let encrypted_name = dest_parent_key.encrypt_and_sign(signing_key, name, true, false)?; let name_hash = hex::encode(hmac_sha256(dest_hash_key, name)); Ok(MoveParts { passphrase, encrypted_name, name_hash, original_hash, anonymous_signature, current_name, }) } /// Move `uids` to the trash. Mirrors C# `NodeOperations.TrashAsync` /// (`POST v2/volumes/{vid}/trash_multiple`). Live validation pending. /// /// One outcome per node the server reported on, like [`move_nodes`](Self::move_nodes): /// a node the batch envelope rejects does not fail the others. The outer /// `Err` is reserved for a request that never produced an envelope. /// [`trash_nodes_streaming`](Self::trash_nodes_streaming) reports the same /// outcomes one at a time as each batch lands. pub async fn trash_nodes(&self, uids: &[NodeUid]) -> Result)>> { let mut timer = self.telemetry.start("trash_nodes"); timer.attr("node_count", uids.len()); let outcomes = self .node_action_stream(NodeAction::Trash, uids) .try_collect() .await?; timer.success(); Ok(outcomes) } /// [`trash_nodes`](Self::trash_nodes) as a stream: each node's outcome is /// yielded as soon as the batch carrying it comes back, instead of after /// every batch. Mirrors the C# `IAsyncEnumerable`. /// A failed request yields one `Err` and ends the stream. pub fn trash_nodes_streaming<'a>( &'a self, uids: &[NodeUid], ) -> impl futures::Stream)>> + 'a { self.node_action_stream(NodeAction::Trash, uids) } /// Restore `uids` from the trash. Mirrors C# /// `NodeOperations.RestoreFromTrashAsync` /// (`PUT v2/volumes/{vid}/trash/restore_multiple`). Live validation pending. /// Per-node outcomes, like [`trash_nodes`](Self::trash_nodes). pub async fn restore_nodes(&self, uids: &[NodeUid]) -> Result)>> { let mut timer = self.telemetry.start("restore_nodes"); timer.attr("node_count", uids.len()); let outcomes = self .node_action_stream(NodeAction::Restore, uids) .try_collect() .await?; timer.success(); Ok(outcomes) } /// [`restore_nodes`](Self::restore_nodes) as a stream of per-node outcomes. pub fn restore_nodes_streaming<'a>( &'a self, uids: &[NodeUid], ) -> impl futures::Stream)>> + 'a { self.node_action_stream(NodeAction::Restore, uids) } /// Permanently delete `uids` (which must already be in the trash). Mirrors /// C# `NodeOperations.DeleteFromTrashAsync` /// (`POST v2/volumes/{vid}/trash/delete_multiple`). Live validation pending. /// Per-node outcomes, like [`trash_nodes`](Self::trash_nodes). pub async fn delete_nodes(&self, uids: &[NodeUid]) -> Result)>> { let mut timer = self.telemetry.start("delete_nodes"); timer.attr("node_count", uids.len()); let outcomes = self .node_action_stream(NodeAction::Delete, uids) .try_collect() .await?; timer.success(); Ok(outcomes) } /// [`delete_nodes`](Self::delete_nodes) as a stream of per-node outcomes. pub fn delete_nodes_streaming<'a>( &'a self, uids: &[NodeUid], ) -> impl futures::Stream)>> + 'a { self.node_action_stream(NodeAction::Delete, uids) } /// The shared body of the trash-family batch operations: group by volume, /// chunk each group at [`MAX_BATCH_COUNT`], and yield the per-link outcomes /// of every batch as it returns. Mirrors the C# async iterators, including /// their mapping — only the links the aggregate envelope reports on produce /// an outcome. fn node_action_stream<'a>( &'a self, action: NodeAction, uids: &[NodeUid], ) -> impl futures::Stream)>> + 'a { let batches: VecDeque<(VolumeId, Vec)> = group_by_volume(uids) .into_iter() .flat_map(|(volume_id, link_ids)| { link_ids .chunks(MAX_BATCH_COUNT) .map(|chunk| (volume_id.clone(), chunk.to_vec())) .collect::>() }) .collect(); stream::unfold( (batches, VecDeque::new()), move |(mut batches, mut pending): (VecDeque<_>, VecDeque<_>)| async move { loop { if let Some(outcome) = pending.pop_front() { return Some((Ok(outcome), (batches, pending))); } let (volume_id, link_ids) = batches.pop_front()?; let path = action.path(&volume_id); let body = MultipleLinksRequest { link_ids: &link_ids, }; let response: Result = match action { NodeAction::Restore => self.http.put(&path, &body).await, NodeAction::Trash | NodeAction::Delete => { self.http.post(&path, &body).await } }; match response { Ok(response) => pending.extend( aggregate_outcomes(response) .into_iter() .map(|(link_id, o)| (NodeUid::new(volume_id.clone(), link_id), o)), ), // A request that never produced an envelope says nothing // about its nodes: report it and stop. Err(e) => return Some((Err(e), (VecDeque::new(), VecDeque::new()))), } } }, ) } /// Permanently empty the main volume's trash. Mirrors C# /// `TrashApiClient.EmptyAsync` (`DELETE volumes/{vid}/trash`). Live /// validation pending. pub async fn empty_trash(&self) -> Result<()> { let mut timer = self.telemetry.start("empty_trash"); let volume_id = self.main_volume_id().await?; let path = format!("volumes/{volume_id}/trash"); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; timer.success(); Ok(()) } /// The nodes other users share with us, as [`NodeUid`]s. /// /// Mirrors C# `SharingOperations.EnumerateSharedWithMeNodeUidsAsync`: page /// `GET v2/sharedwithme` on its `AnchorID` cursor and keep only the target /// types the Drive client owns (folder / file / vendor) — albums and photos /// belong to the Photos client. Materialize the uids with /// [`enumerate_nodes`](Self::enumerate_nodes). pub async fn enumerate_shared_with_me_node_uids(&self) -> Result> { let mut timer = self.telemetry.start("enumerate_shared_with_me_node_uids"); let items = self.page_shared_with_me().await?; timer.success(); Ok(items.into_iter().map(|item| item.uid).collect()) } /// The nodes shared *with* me, each with the share it was granted through. /// /// Same listing as /// [`enumerate_shared_with_me_node_uids`](Self::enumerate_shared_with_me_node_uids), /// but keeping the `ShareID` the wire carries: a shared node is the root of /// the sharer's share on *their* volume, so it comes back parentless and /// only that share's key unlocks it. A caller that mounts shared content /// needs the share id to reason about it without re-deriving it from link /// details. /// /// Order is the API's and is preserved. Materialize the uids with /// [`enumerate_nodes`](Self::enumerate_nodes) — the resulting /// [`Node::membership`](crate::Node::membership) carries our role. pub async fn enumerate_shared_with_me(&self) -> Result> { let mut timer = self.telemetry.start("enumerate_shared_with_me"); let items = self.page_shared_with_me().await?; timer.success(); Ok(items) } /// Page `v2/sharedwithme` in the order the API returns, recording each item's /// membership share on the way through. Returns the uids; the share index is /// left in the cache for [`shared_with_me_shares`](Self::shared_with_me_shares). /// /// Order is the API's and is preserved: it is what a front-end lists, and a /// set's iteration order would reshuffle the page on every refresh. async fn page_shared_with_me(&self) -> Result> { let mut items = Vec::new(); let mut shares = HashMap::new(); let mut anchor: Option = None; loop { let path = match &anchor { Some(anchor_id) => format!("v2/sharedwithme?AnchorID={anchor_id}"), None => "v2/sharedwithme".to_string(), }; let page: SharedWithMeResponse = self.http.get(&path).await?; for item in drive_items(&page) { shares.insert(item.uid.clone(), item.share_id.clone()); items.push(item); } anchor = page.anchor_id.filter(|id| !id.is_empty()); if !page.more || anchor.is_none() { break; } } self.cache.lock().await.shared_with_me_shares = Some(shares); Ok(items) } /// The membership share id behind each node shared with us, keyed by the /// shared node. Pages `v2/sharedwithme` on a miss; `refresh` re-pages even /// when the index is already cached. /// /// A shared node is the root of the share its owner granted us, so it comes /// back parentless on *their* volume and only that share's key unlocks it — /// see [`root_link_share_key`](Self::root_link_share_key). async fn shared_with_me_shares(&self, refresh: bool) -> Result> { if !refresh && let Some(shares) = self.cache.lock().await.shared_with_me_shares.clone() { return Ok(shares); } self.page_shared_with_me().await?; Ok(self .cache .lock() .await .shared_with_me_shares .clone() .unwrap_or_default()) } /// The nodes I have shared with others, as [`NodeUid`]s. /// /// Ported from the TypeScript SDK's `iterateSharedNodeUids`: page /// `GET drive/v2/volumes/{vid}/shares` on its `AnchorID` cursor (the `drive/` /// prefix comes from the client's base route). The endpoint lists only /// collaborative shares that are still live — those with members, pending /// invitations or a public URL — so an unshared or abandoned node never /// appears. Materialize the uids with [`enumerate_nodes`](Self::enumerate_nodes). pub async fn enumerate_shared_by_me_node_uids(&self) -> Result> { let mut timer = self.telemetry.start("enumerate_shared_by_me_node_uids"); let volume_id = self.main_volume_id().await?; let uids = self.page_shared_by_me(&volume_id).await?; timer.success(); Ok(uids) } /// The photos I have shared with others, as [`NodeUid`]s — the same listing /// as [`enumerate_shared_by_me_node_uids`](Self::enumerate_shared_by_me_node_uids) /// but resolved against the photos volume, mirroring C# /// `ProtonPhotosClient.EnumerateSharedNodeUidsAsync` (which passes /// `VolumeOperations.TryGetPhotosVolumeIdAsync` as the volume resolver). /// Empty when the account has no photos volume — C# yields nothing there /// rather than erroring. Exposed through /// [`ProtonPhotosClient`](crate::ProtonPhotosClient). pub(crate) async fn enumerate_photos_shared_by_me_node_uids(&self) -> Result> { let mut timer = self .telemetry .start("enumerate_photos_shared_by_me_node_uids"); if !self.ensure_photos().await? { timer.success(); return Ok(Vec::new()); } let volume_id = self .cache .lock() .await .photos_root .clone() .expect("ensure_photos populated the photos root") .volume_id; let uids = self.page_shared_by_me(&volume_id).await?; timer.success(); Ok(uids) } /// Page `GET drive/v2/volumes/{vid}/shares` on its `AnchorID` cursor for one /// volume. Mirrors the loop in C# `SharingOperations.EnumerateSharedNodeUidsAsync` /// once the volume resolver has run. async fn page_shared_by_me(&self, volume_id: &VolumeId) -> Result> { let mut uids = Vec::new(); let mut anchor: Option = None; loop { let path = match &anchor { Some(anchor_id) => { format!("v2/volumes/{volume_id}/shares?AnchorID={anchor_id}") } None => format!("v2/volumes/{volume_id}/shares"), }; let page: SharedByMeResponse = self.http.get(&path).await?; for link in &page.links { uids.push(NodeUid::new(volume_id.clone(), link.link_id.clone())); } anchor = page.anchor_id.filter(|id| !id.is_empty()); if !page.more || anchor.is_none() { break; } } Ok(uids) } /// Leave a node someone shared with us, giving up access to it. /// /// Mirrors C# `SharingOperations.LeaveSharedNodeAsync`: read the node's link /// details, take our membership in the sharer's share, and delete it /// (`DELETE v2/shares/{sid}/members/{mid}`). Errors when the node is not /// shared with us — there is nothing to leave (C# throws `ValidationException`). pub async fn leave_shared_node(&self, uid: &NodeUid) -> Result<()> { let mut timer = self.telemetry.start("leave_shared_node"); let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let membership = details .links .into_iter() .find(|detail| detail.link.id == uid.link_id) .and_then(|detail| detail.membership) .ok_or_else(|| { ProtonError::invalid_operation("you can leave only an item that is shared with you") })?; self.remove_share_member(&membership.share_id, &membership.membership_id) .await?; timer.success(); Ok(()) } /// Share a node with one or more Proton users by email. /// /// Creates a standard share on the node if it is not shared yet, then invites /// each user at the given [`MemberRole`]. Ported from the TypeScript SDK's /// `shareNode` (the C# public SDK has no share-creation surface): the share /// key is generated and bound to the node + owning address, and each invite /// carries the share session key encrypted to the invitee's public key plus a /// context-bound inviter signature over it. /// /// `email_message` is an optional note included in the invitation emails. /// Returns the freshly created invitations. Invitees already invited or /// already members are skipped (this does not update their role). pub async fn share_node( &self, uid: &NodeUid, invitees: &[(String, MemberRole)], email_message: Option<&str>, ) -> Result> { let mut timer = self.telemetry.start("share_node"); let (share_id, share_session_key, inviter_email, inviter_key) = self.ensure_node_share(uid).await?; // Skip anyone who is already invited or already a member. let existing_invites = self.list_share_invitations_inner(&share_id).await?; let existing_members = self.list_share_members_inner(&share_id).await?; let already: std::collections::HashSet = existing_invites .iter() .map(|i| i.invitee_email.to_lowercase()) .chain(existing_members.iter().map(|m| m.email.to_lowercase())) .collect(); let mut created = Vec::new(); for (email, role) in invitees { if already.contains(&email.to_lowercase()) { tracing::info!(%email, "skipping already-invited/member for share"); continue; } let permissions = role.to_permissions().ok_or_else(|| { ProtonError::invalid_operation("cannot invite a user with the inherited role") })?; let invitee_keys = self.account.public_keys(email).await; let invitee_pubkey = invitee_keys.first().ok_or_else(|| { ProtonError::invalid_operation(format!("no public key for invitee {email}")) })?; let (key_packet, key_packet_signature) = encrypt_invitation(&share_session_key, &inviter_key, invitee_pubkey)?; let request = InviteProtonUserRequest { invitation: InviteProtonUserInvitationDto { inviter_email: inviter_email.clone(), invitee_email: email.clone(), permissions, key_packet, key_packet_signature, external_invitation_id: None, }, email_details: InviteEmailDetailsDto { message: email_message.map(str::to_string), item_name: None, }, }; let path = format!("v2/shares/{share_id}/invitations"); let response: InviteProtonUserResponse = self.http.post(&path, &request).await?; created.push(invitation_from_dto(&share_id, response.invitation)); } timer.success(); Ok(created) } /// List the members (accepted invitations) of the share on a node. /// /// Returns an empty list when the node is not shared. pub async fn list_share_members(&self, uid: &NodeUid) -> Result> { let mut timer = self.telemetry.start("list_share_members"); let members = match self.node_share_id(uid).await? { Some(share_id) => self.list_share_members_inner(&share_id).await?, None => Vec::new(), }; timer.success(); Ok(members) } /// List the pending Proton-user invitations of the share on a node. /// /// Returns an empty list when the node is not shared. pub async fn list_share_invitations(&self, uid: &NodeUid) -> Result> { let mut timer = self.telemetry.start("list_share_invitations"); let invitations = match self.node_share_id(uid).await? { Some(share_id) => self.list_share_invitations_inner(&share_id).await?, None => Vec::new(), }; timer.success(); Ok(invitations) } /// Change a member's role (`PUT v2/shares/{sid}/members/{mid}`). pub async fn update_member_role(&self, member: &ShareMember, role: MemberRole) -> Result<()> { let permissions = role.to_permissions().ok_or_else(|| { ProtonError::invalid_operation("cannot set a member to the inherited role") })?; let path = format!( "v2/shares/{}/members/{}", member.share_id, member.membership_id ); let _: proton_sdk::api::ApiResponse = self .http .put(&path, &UpdatePermissionsRequest { permissions }) .await?; Ok(()) } /// Remove a member from a share, revoking their access /// (`DELETE v2/shares/{sid}/members/{mid}`). pub async fn remove_member(&self, member: &ShareMember) -> Result<()> { self.remove_share_member(&member.share_id, &member.membership_id) .await } /// Change a pending invitation's role /// (`PUT v2/shares/{sid}/invitations/{iid}`). pub async fn update_invitation_role( &self, invitation: &ShareInvitation, role: MemberRole, ) -> Result<()> { let permissions = role.to_permissions().ok_or_else(|| { ProtonError::invalid_operation("cannot set an invitation to the inherited role") })?; let path = format!( "v2/shares/{}/invitations/{}", invitation.share_id, invitation.invitation_id ); let _: proton_sdk::api::ApiResponse = self .http .put(&path, &UpdatePermissionsRequest { permissions }) .await?; Ok(()) } /// Revoke a pending invitation /// (`DELETE v2/shares/{sid}/invitations/{iid}`). pub async fn delete_invitation(&self, invitation: &ShareInvitation) -> Result<()> { let path = format!( "v2/shares/{}/invitations/{}", invitation.share_id, invitation.invitation_id ); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; Ok(()) } /// Create a public share link on a node, returning the shareable URL. /// /// Ported from the TypeScript SDK's public-link flow: a random 12-character /// link password (optionally suffixed with `custom_password`) protects the /// share. The share session key is wrapped with the SRP-salted passphrase /// (`SharePassphraseKeyPacket`), the password is stored encrypted to the /// owner's address key, and an SRP verifier lets the server authenticate /// visitors without learning the password. `role` must be Viewer or Editor /// (public links cannot grant Admin). `expiration_time` is an optional Unix /// timestamp. The returned [`PublicLink::url`] carries the secret fragment. pub async fn create_public_link( &self, uid: &NodeUid, role: MemberRole, custom_password: Option<&str>, expiration_time: Option, ) -> Result { let mut timer = self.telemetry.start("create_public_link"); let permissions = match role { MemberRole::Viewer | MemberRole::Editor => role.to_permissions().unwrap(), _ => { return Err(ProtonError::invalid_operation( "a public link can only grant the Viewer or Editor role", )); } }; let (share_id, share_session_key, creator_email, address_key) = self.ensure_node_share(uid).await?; // The URL fragment is the generated password; the encryption password is // the generated password with any custom password appended. let generated = generate_public_link_password(); let full_password = match custom_password { Some(custom) if !custom.is_empty() => format!("{generated}{custom}"), _ => generated.clone(), }; // The modulus is fetched first so that everything CPU-bound below can go // to the blocking pool in one hop. let modulus = self.fetch_srp_modulus().await?; // Wrap the share session key with the SRP-salted passphrase, store the // password encrypted to the owner's address key, and build the SRP // verifier over the link password. // // All of that is pure CPU and none of it is cheap: `derive_key_passphrase` // is a deliberately slow bcrypt, and `generate_verifier` a 2048-bit modular // exponentiation. Inline, they stalled every other task on the reactor // thread for the duration — so, like the rest of the crate's PGP work, they // run on the blocking pool. let key_salt = generate_key_salt(); let signed_modulus = modulus.modulus.clone(); let password_bytes = full_password.clone(); let salt_for_task = key_salt; let (share_passphrase_key_packet, armored_password, verifier) = tokio::task::spawn_blocking(move || { let salted_passphrase = derive_key_passphrase(password_bytes.as_bytes(), &salt_for_task)?; let key_packet = base64_encode(share_session_key.encrypt_with_password(&salted_passphrase)?); let armored_password = address_key.encrypt(password_bytes.as_bytes())?; let verifier = generate_verifier( password_bytes.as_bytes(), &signed_modulus, DEFAULT_BIT_LENGTH, )?; Ok::<_, ProtonError>((key_packet, armored_password, verifier)) }) .await .map_err(|e| { ProtonError::invalid_operation(format!("public link crypto task failed: {e}")) })??; let flags = if custom_password.map(|p| !p.is_empty()).unwrap_or(false) { 3 // random + custom password } else { 2 // random password only }; let request = CreatePublicLinkRequest { creator_email, permissions, flags, expiration_time, share_password_salt: base64_encode(key_salt), share_passphrase_key_packet, password: armored_password, url_password_salt: verifier.salt, srp_verifier: verifier.verifier, srp_modulus_id: modulus.modulus_id, max_accesses: 0, }; let path = format!("shares/{share_id}/urls"); let response: CreatePublicLinkResponse = self.http.post(&path, &request).await?; timer.success(); Ok(PublicLink { share_id, public_link_id: response.share_url.share_url_id, url: Some(format!("{}#{generated}", response.share_url.public_url)), role, creation_time: now_epoch_seconds(), expiration_time, has_custom_password: flags == 3, }) } /// The public link on a node, if one exists. The secret URL fragment is /// recovered by decrypting the stored link password with the owner's address /// key and taking the generated (non-custom) portion — the same recovery the /// web client does when re-displaying an existing link. pub async fn get_public_link(&self, uid: &NodeUid) -> Result> { let mut timer = self.telemetry.start("get_public_link"); let link = match self.node_share_id(uid).await? { Some(share_id) => { let path = format!("shares/{share_id}/urls"); let response: ShareUrlsResponse = self.http.get(&path).await?; match response.share_urls.into_iter().next() { Some(dto) => { let url = self.recover_public_link_url(&dto).await; Some(PublicLink { share_id, public_link_id: dto.share_url_id, url, role: MemberRole::from_permissions(dto.permissions), creation_time: dto.create_time, expiration_time: dto.expiration_time, has_custom_password: dto.flags == 3, }) } None => None, } } None => None, }; timer.success(); Ok(link) } /// Rebuild the full public URL (`{public_url}#{generated}`) for an existing /// link by decrypting its stored `Password` with the owner's address key. The /// stored secret is the generated password with any custom password appended; /// only the leading generated portion belongs in the URL fragment (the /// recipient supplies the custom password separately). Returns `None` if the /// link carries no password or it can't be decrypted, so the caller still gets /// the link's metadata. async fn recover_public_link_url(&self, dto: &ShareUrlDto) -> Option { if dto.public_url.is_empty() { return None; } let enc = dto.password.as_deref()?; let (_, email, _) = self.membership_address().await.ok()?; let (address_keys, _) = self.own_address_keys(&email).await.ok()?; let bytes = decrypt_armored_with_keys(enc, &address_keys).ok()?; let full = String::from_utf8_lossy(&bytes); let generated: String = full.chars().take(PUBLIC_LINK_PASSWORD_LEN).collect(); Some(format!("{}#{generated}", dto.public_url)) } /// Remove a public link, revoking access via the URL /// (`DELETE shares/{sid}/urls/{urlID}`). pub async fn remove_public_link(&self, link: &PublicLink) -> Result<()> { let path = format!("shares/{}/urls/{}", link.share_id, link.public_link_id); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; Ok(()) } /// List invitations addressed to the current user (shared *with* me), /// pending accept or reject. /// /// Pages `GET v2/shares/invitations`, then fetches and decrypts each /// invitation's detail so the caller has the shared item's name. A single /// invitation that fails to load is logged and skipped rather than failing /// the whole listing. Mirrors JS `iterateInvitations`. pub async fn list_incoming_invitations(&self) -> Result> { let mut timer = self.telemetry.start("list_incoming_invitations"); let mut ids = Vec::new(); let mut anchor: Option = None; loop { let path = match &anchor { Some(a) => format!("v2/shares/invitations?AnchorID={a}"), None => "v2/shares/invitations".to_string(), }; let page: InvitationsListResponse = self.http.get(&path).await?; for item in page.invitations { ids.push(item.invitation_id); } anchor = page.anchor_id.filter(|a| !a.is_empty()); if !page.more || anchor.is_none() { break; } } let mut invitations = Vec::with_capacity(ids.len()); for id in ids { match self.incoming_invitation_detail(&id).await { Ok(inv) => invitations.push(inv), Err(e) => tracing::warn!(invitation = %id, "failed to load invitation: {e}"), } } timer.success(); Ok(invitations) } /// Accept an invitation addressed to us, gaining access to the shared node. /// /// Fetches the invitation, decrypts its key packet with our address keys to /// recover the share session key, signs the key under the member context, and /// posts the signature. Mirrors JS `acceptInvitation`. pub async fn accept_invitation(&self, invitation_id: &str) -> Result<()> { let mut timer = self.telemetry.start("accept_invitation"); let detail = self.get_incoming_invitation(invitation_id).await?; let (keys, signing_key) = self .own_address_keys(&detail.invitation.invitee_email) .await?; let signature = accept_invitation(&detail.invitation.key_packet, &keys, &signing_key)?; let path = format!("v2/shares/invitations/{invitation_id}/accept"); let _: proton_sdk::api::ApiResponse = self .http .post( &path, &AcceptInvitationRequest { session_key_signature: signature, }, ) .await?; timer.success(); Ok(()) } /// Reject an invitation addressed to us, declining access. pub async fn reject_invitation(&self, invitation_id: &str) -> Result<()> { let path = format!("v2/shares/invitations/{invitation_id}/reject"); let _: proton_sdk::api::ApiResponse = self.http.post(&path, &serde_json::json!({})).await?; Ok(()) } /// Invite a mixed set of email addresses to a node's share, routing each to /// the right flow: addresses that resolve to a Proton account get a normal /// (encrypted-key-packet) invitation via [`share_node`](Self::share_node); /// the rest get an [external invitation](Self::invite_external_users). Returns /// `(proton_invited, external_invited)` counts. Already-invited or existing /// members are skipped by the underlying calls. pub async fn invite_users( &self, uid: &NodeUid, invitees: &[(String, MemberRole)], email_message: Option<&str>, ) -> Result<(usize, usize)> { let mut proton: Vec<(String, MemberRole)> = Vec::new(); let mut external: Vec<(String, MemberRole)> = Vec::new(); for (email, role) in invitees { // A Proton account exposes at least one public key; an address with // none is external and cannot receive an encrypted key packet. if self.account.public_keys(email).await.is_empty() { external.push((email.clone(), *role)); } else { proton.push((email.clone(), *role)); } } let proton_invited = if proton.is_empty() { 0 } else { self.share_node(uid, &proton, email_message).await?.len() }; let external_invited = if external.is_empty() { 0 } else { self.invite_external_users(uid, &external, email_message) .await? .len() }; Ok((proton_invited, external_invited)) } /// Invite non-Proton (external) email addresses to a node's share. /// /// The invitees have no Proton keys, so each gets an external invitation: the /// inviter signs `"{email}|{base64(share session key)}"` under the external /// context. When an invitee later signs up, the server converts the pending /// invitation to a normal membership. Skips anyone already invited (Proton or /// external) or already a member. Returns the freshly created invitations. pub async fn invite_external_users( &self, uid: &NodeUid, invitees: &[(String, MemberRole)], email_message: Option<&str>, ) -> Result> { let mut timer = self.telemetry.start("invite_external_users"); let (share_id, share_session_key, _inviter_email, inviter_key) = self.ensure_node_share(uid).await?; let (address_id, _, _) = self.membership_address().await?; let already: std::collections::HashSet = self .list_external_invitations_inner(&share_id) .await? .iter() .map(|i| i.invitee_email.to_lowercase()) .chain( self.list_share_invitations_inner(&share_id) .await? .iter() .map(|i| i.invitee_email.to_lowercase()), ) .chain( self.list_share_members_inner(&share_id) .await? .iter() .map(|m| m.email.to_lowercase()), ) .collect(); let mut created = Vec::new(); for (email, role) in invitees { if already.contains(&email.to_lowercase()) { tracing::info!(%email, "skipping already-invited/member for external share"); continue; } let permissions = role.to_permissions().ok_or_else(|| { ProtonError::invalid_operation("cannot invite a user with the inherited role") })?; let signature = encrypt_external_invitation(&share_session_key, &inviter_key, email)?; let request = InviteExternalUserRequest { external_invitation: ExternalInvitationDto { inviter_address_id: address_id.clone(), invitee_email: email.clone(), permissions, external_invitation_signature: signature, }, email_details: InviteEmailDetailsDto { message: email_message.map(str::to_string), item_name: None, }, }; let path = format!("v2/shares/{share_id}/external-invitations"); let response: InviteExternalUserResponse = self.http.post(&path, &request).await?; created.push(external_invitation_from_dto( &share_id, response.external_invitation, )); } timer.success(); Ok(created) } /// List the pending external (non-Proton) invitations on a node's share. /// Returns an empty list when the node is not shared. pub async fn list_external_invitations( &self, uid: &NodeUid, ) -> Result> { let mut timer = self.telemetry.start("list_external_invitations"); let invitations = match self.node_share_id(uid).await? { Some(share_id) => self.list_external_invitations_inner(&share_id).await?, None => Vec::new(), }; timer.success(); Ok(invitations) } /// Revoke a pending external invitation /// (`DELETE v2/shares/{sid}/external-invitations/{iid}`). pub async fn delete_external_invitation(&self, invitation: &ExternalInvitation) -> Result<()> { let path = format!( "v2/shares/{}/external-invitations/{}", invitation.share_id, invitation.invitation_id ); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; Ok(()) } // ---- bookmarks --------------------------------------------------------- /// List the public links the user has saved to their account /// (`GET v2/shared-bookmarks`). The URL is always recovered; the item name is /// only present when the SRP-derived share key could be decrypted (not yet). pub async fn list_bookmarks(&self) -> Result> { let mut timer = self.telemetry.start("list_bookmarks"); let response: BookmarksResponse = self.http.get("v2/shared-bookmarks").await?; // Bookmark URL passwords are encrypted to our My Files share address key. let (_, email, _) = self.membership_address().await?; let (address_keys, _) = self.own_address_keys(&email).await?; let mut bookmarks = Vec::with_capacity(response.bookmarks.len()); for dto in response.bookmarks { let Some(enc) = dto.encrypted_url_password.as_deref() else { continue; }; let Ok(bytes) = decrypt_armored_with_keys(enc, &address_keys) else { continue; }; // The stored secret is the URL password with any custom password // appended; the URL only needs the leading generated portion, but the // API does not tell us the split, so the fragment is the whole string. let url_password = String::from_utf8_lossy(&bytes).into_owned(); bookmarks.push(Bookmark { url: format!( "https://drive.proton.me/urls/{}#{url_password}", dto.token.token ), token: dto.token.token, // Name decryption needs the SRP-derived share key, not yet ported; // the URL is enough to open the link. node_name: None, is_folder: dto.token.link_type == 1, creation_time: dto.create_time, }); } timer.success(); Ok(bookmarks) } /// Save a public link to the account as a bookmark /// (`POST v2/urls/{token}/bookmark`). `url` is the full public URL including /// the `#password` fragment; `custom_password` is required only when the link /// is additionally password-protected. pub async fn create_bookmark(&self, url: &str, custom_password: Option<&str>) -> Result<()> { let mut timer = self.telemetry.start("create_bookmark"); let (token, url_password) = parse_public_link_url(url)?; let (address_id, email, _) = self.membership_address().await?; let (_, address_key) = self.own_address_keys(&email).await?; let address_key_id = self.address_primary_key_id(&address_id).await?; // The stored secret is the URL password concatenated with any custom // password, encrypted + signed to our own address key. let concatenated = format!("{url_password}{}", custom_password.unwrap_or("")); let encrypted_url_password = address_key.encrypt_and_sign(&address_key, concatenated.as_bytes(), false, false)?; let request = CreateBookmarkRequest { bookmark_share_url: BookmarkShareUrlDto { encrypted_url_password, address_id, address_key_id, }, }; let path = format!("v2/urls/{token}/bookmark"); let _: proton_sdk::api::ApiResponse = self.http.post(&path, &request).await?; timer.success(); Ok(()) } /// Remove a saved bookmark (`DELETE v2/urls/{token}/bookmark`). pub async fn delete_bookmark(&self, token: &str) -> Result<()> { let path = format!("v2/urls/{token}/bookmark"); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; Ok(()) } // ---- sharing internals ------------------------------------------------- /// Fetch a single incoming invitation's encrypted detail by id. async fn get_incoming_invitation( &self, invitation_id: &str, ) -> Result { let path = format!("v2/shares/invitations/{invitation_id}"); self.http.get(&path).await } /// Fetch and decrypt a single incoming invitation into its public form. async fn incoming_invitation_detail(&self, invitation_id: &str) -> Result { let detail = self.get_incoming_invitation(invitation_id).await?; let node_uid = NodeUid::new( VolumeId::from(detail.share.volume_id.clone()), LinkId::from(detail.link.link_id.clone()), ); let node_name = self.decrypt_invitation_name(&detail).await; Ok(IncomingInvitation { invitation_id: detail.invitation.invitation_id, inviter_email: detail.invitation.inviter_email, invitee_email: detail.invitation.invitee_email, role: MemberRole::from_permissions(detail.invitation.permissions), invitation_time: detail.invitation.create_time, node_uid, node_name, is_folder: detail.link.link_type == 1, }) } /// Decrypt the shared item's name from an invitation, using the invitee's own /// keys to unlock the share key. A failure yields `None` — the invitation is /// still actionable (accept/reject) without a readable name. async fn decrypt_invitation_name(&self, detail: &InvitationDetailsResponse) -> Option { let (keys, _) = self .own_address_keys(&detail.invitation.invitee_email) .await .ok()?; // Unlocking the share key runs its S2K — tens of milliseconds of pure // CPU, and this is called once per invitation in a listing. Off the // reactor, like every other key unlock in the crate // (`crypto::decrypt_link_verified`). let armored_passphrase = detail.share.passphrase.clone(); let armored_share_key = detail.share.share_key.clone(); let armored_name = detail.link.name.clone(); tokio::task::spawn_blocking(move || { let passphrase = keys .iter() .find_map(|k| k.decrypt_armored_message(&armored_passphrase).ok())?; let share_key = PrivateKey::from_armored(&armored_share_key, &passphrase).ok()?; let name_bytes = share_key.decrypt_armored_message(&armored_name).ok()?; String::from_utf8(name_bytes).ok() }) .await .ok()? } /// The current user's own address keys for `email`: all its private keys (for /// decryption) and the primary signing key. async fn own_address_keys(&self, email: &str) -> Result<(Vec, PrivateKey)> { let email_lc = email.to_lowercase(); let address = self .account .addresses() .await? .into_iter() .find(|a| a.email.to_lowercase() == email_lc) .ok_or_else(|| ProtonError::invalid_operation(format!("no own address for {email}")))?; let keys = self.account.address_private_keys(&address.id).await?; let signing_key = keys .get(address.primary_key_index) .cloned() .ok_or_else(|| ProtonError::invalid_operation("address has no primary key"))?; Ok((keys, signing_key)) } async fn list_external_invitations_inner( &self, share_id: &ShareId, ) -> Result> { let path = format!("v2/shares/{share_id}/external-invitations"); let response: ExternalInvitationsResponse = self.http.get(&path).await?; Ok(response .external_invitations .into_iter() .map(|i| external_invitation_from_dto(share_id, i)) .collect()) } /// Fetch a fresh signed SRP modulus (`GET auth/v4/modulus`, root route). async fn fetch_srp_modulus(&self) -> Result { // The modulus endpoint lives at the API root, not under `drive/`, and is // a GET (the server rejects POST with 405 "Allow: GET"). let root = self.http.with_base_route(""); root.get("auth/v4/modulus").await } /// The id of the standard share on a node, if it is shared. async fn node_share_id(&self, uid: &NodeUid) -> Result> { let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; Ok(details .links .into_iter() .find(|detail| detail.link.id == uid.link_id) .and_then(|detail| detail.sharing) .map(|sharing| sharing.share_id)) } /// Get-or-create the standard share on a node, returning its id, passphrase /// session key (for inviting), and the owning address's email + signing key. async fn ensure_node_share( &self, uid: &NodeUid, ) -> Result<(ShareId, ContentKey, String, PrivateKey)> { let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let detail = details .links .into_iter() .find(|detail| detail.link.id == uid.link_id) .ok_or_else(|| ProtonError::invalid_operation("node not found"))?; let link = &detail.link; let parent_key = self.resolve_parent_key(&uid.volume_id, link).await?; let node_key = decrypt_link(&parent_key, link)?.node_key; let (address_id, inviter_email, address_key) = self.membership_address_for(uid).await?; if let Some(sharing) = detail.sharing { // Already shared: recover the existing share's passphrase session key // (the passphrase is encrypted to the node key) so we can invite. let path = format!("shares/{}", sharing.share_id); let response: ShareResponse = self.http.get(&path).await?; let share_session_key = node_key.recover_message_session_key(&response.share.passphrase)?; return Ok(( sharing.share_id, share_session_key, inviter_email, address_key, )); } // Not shared yet: lift the node's passphrase + name session keys, build a // fresh share bound to the node + owning address, and create it. let node_passphrase_sk = parent_key.recover_message_session_key(&link.passphrase)?; let node_name_sk = parent_key.recover_message_session_key(&link.name)?; let material = build_standard_share_material( &node_key, &node_passphrase_sk, &node_name_sk, &address_key, )?; let request = CreateShareRequest { root_link_id: uid.link_id.clone(), address_id, name: "New Share".to_string(), share_key: material.share_key_armored, share_passphrase: material.share_passphrase, share_passphrase_signature: material.share_passphrase_signature, passphrase_key_packet: material.passphrase_key_packet, name_key_packet: material.name_key_packet, }; let path = format!("volumes/{}/shares", uid.volume_id); let response: CreateShareResponse = self.http.post(&path, &request).await?; Ok(( response.share.id, material.share_session_key, inviter_email, address_key, )) } async fn list_share_members_inner(&self, share_id: &ShareId) -> Result> { let path = format!("v2/shares/{share_id}/members"); let response: ShareMembersResponse = self.http.get(&path).await?; Ok(response .members .into_iter() .map(|m| ShareMember { share_id: share_id.clone(), membership_id: m.member_id, email: m.email, added_by_email: m.inviter_email, role: MemberRole::from_permissions(m.permissions), invitation_time: m.create_time, }) .collect()) } async fn list_share_invitations_inner( &self, share_id: &ShareId, ) -> Result> { let path = format!("v2/shares/{share_id}/invitations"); let response: ShareInvitationsResponse = self.http.get(&path).await?; Ok(response .invitations .into_iter() .map(|i| invitation_from_dto(share_id, i)) .collect()) } /// The account's registered devices, with their root-folder names decrypted. /// /// Mirrors C# `DeviceOperations.EnumerateDevicesAsync` (`GET devices` then /// resolve each root folder's name). A device's root folder lives in its own /// share, so the name is decrypted with that share's key rather than the My /// Files one; a per-device name failure is carried in [`Device::name`] rather /// than failing the enumeration. Each resolved root-folder key is cached, so /// enumerating a device's children works straight afterwards. pub async fn enumerate_devices(&self) -> Result> { let mut timer = self.telemetry.start("enumerate_devices"); let metadata = self.device_metadata().await?; let mut devices = Vec::with_capacity(metadata.len()); for device in metadata { let name = self.device_root_folder_name(&device).await; devices.push(device.into_device(name)); } timer.success(); Ok(devices) } /// Register a new device with its own share and root folder. /// /// Mirrors C# `DeviceOperations.CreateDeviceAsync` / `DeviceCrypto`: generate /// a share key and a root-folder key, wrap the share passphrase to the My /// Files membership address key, the folder passphrase to the share key, and /// the folder name + hash key to the share/folder keys, then `POST devices`. /// The crypto material is exactly a volume's root share + root folder, so it /// is built by the same `build_volume_creation_material` — with the device /// name in place of the root folder name. Live validation pending. pub async fn create_device(&self, name: &str, device_type: DeviceType) -> Result { let mut timer = self.telemetry.start("create_device"); let root = self.get_my_files_folder().await?; let volume_id = root.uid.volume_id.clone(); let (address_id, _email, address_key) = self.membership_address_for(&root.uid).await?; let address_key_id = self.address_primary_key_id(&address_id).await?; let material = build_volume_creation_material(&address_key, name)?; let request = DeviceCreationRequest { device: DeviceCreationDeviceDto { device_type: device_type.as_i32(), sync_state: 0, }, share: DeviceCreationShareDto { address_id, address_key_id, key: material.share_key_armored, passphrase: material.share_passphrase, passphrase_signature: material.share_passphrase_signature, }, link: DeviceCreationLinkDto { name: material.folder_name, key: material.folder_key_armored, passphrase: material.folder_passphrase, passphrase_signature: material.folder_passphrase_signature, node_hash_key: material.folder_hash_key, }, }; let created: DeviceCreationResponse = self.http.post("devices", &request).await?; timer.success(); Ok(Device { uid: created.device.id, device_type, name: Ok(name.to_string()), root_folder_uid: NodeUid::new(volume_id, created.device.root_link_id), creation_time: now_epoch_seconds(), last_sync_time: None, share_id: created.device.share_id, }) } /// Rename a device, i.e. rename its root folder. /// /// Mirrors C# `DeviceOperations.RenameDeviceAsync`. A root node has no parent /// and no siblings, so — unlike [`rename_node`](Self::rename_node) — the name /// is encrypted to the device's *share* key and carries no name hash. Devices /// registered before the name moved onto the root folder also keep a copy on /// the share; that copy is cleared first (best-effort, as in C#). pub async fn rename_device(&self, device_uid: &DeviceUid, name: &str) -> Result { let mut timer = self.telemetry.start("rename_device"); let device = self.device_metadata_by_uid(device_uid).await?; if device.has_deprecated_name { let request = DeviceUpdateRequest { share: DeviceUpdateShareDto { name: String::new(), }, }; let path = format!("devices/{device_uid}"); let removed: Result = self.http.put(&path, &request).await; if let Err(e) = removed { tracing::warn!(device_uid = %device_uid, error = %e, "failed to remove deprecated device name"); } } let (share_key, membership_address_id) = self.share_key_by_id(&device.share_id).await?; let (_address_id, email, signing_key) = self .resolve_membership_address(membership_address_id) .await?; // Proton's rename endpoint keeps the link's existing name *key packet* and // replaces only the *data packet*, so the new name must be encrypted under // the **same** session key as the current name (otherwise the retained key // packet no longer decrypts the data packet). Recover that session key from // the current root-folder name and reuse it. Mirrors C# // `DeviceOperations.RenameRootFolderAsync` (`GetNodeMetadata` → // `NameSessionKey` → `DeviceCrypto.GetRenameRequest`). let uid = &device.root_folder_uid; let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let current_name = details .links .into_iter() .find(|detail| detail.link.id == uid.link_id) .map(|detail| detail.link.name) .ok_or_else(|| { ProtonError::invalid_operation(format!("device root folder {uid} not found")) })?; let name_session_key = share_key.recover_message_session_key(¤t_name)?; let encrypted_name = name_session_key.encrypt_and_sign_text_to(&share_key, &signing_key, name.as_bytes())?; let request = RenameLinkRequest { name: encrypted_name, name_hash: String::new(), name_signature_email: email, media_type: None, original_hash: String::new(), }; let path = format!( "v2/volumes/{}/links/{}/rename", device.root_folder_uid.volume_id, device.root_folder_uid.link_id ); let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; timer.success(); Ok(device.into_device(Ok(name.to_string()))) } /// Delete a device and its root folder (`DELETE devices/{uid}`). /// Mirrors C# `DeviceOperations.DeleteDeviceAsync`. pub async fn delete_device(&self, device_uid: &DeviceUid) -> Result<()> { let mut timer = self.telemetry.start("delete_device"); let path = format!("devices/{device_uid}"); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; timer.success(); Ok(()) } // ---- internals --------------------------------------------------------- /// `DELETE v2/shares/{sid}/members/{mid}` — drop a membership from a share. /// C# `SharesApiClient.RemoveMemberAsync`. async fn remove_share_member( &self, share_id: &ShareId, membership_id: &ShareMembershipId, ) -> Result<()> { let path = format!("v2/shares/{share_id}/members/{membership_id}"); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; Ok(()) } /// All registered devices, without their (decrypted) names. /// C# `DeviceOperations.GetDeviceMetadataAsync`. async fn device_metadata(&self) -> Result> { let response: DeviceListResponse = self.http.get("devices").await?; let mut devices = Vec::with_capacity(response.devices.len()); for item in response.devices { devices.push(DeviceMetadata { uid: item.device.id, device_type: DeviceType::from_raw(item.device.device_type)?, root_folder_uid: NodeUid::new(item.device.volume_id, item.share.root_link_id), creation_time: item.device.creation_time, last_sync_time: item.device.last_sync_time, has_deprecated_name: item.share.name.is_some_and(|name| !name.is_empty()), share_id: item.share.id, }); } Ok(devices) } async fn device_metadata_by_uid(&self, device_uid: &DeviceUid) -> Result { self.device_metadata() .await? .into_iter() .find(|device| &device.uid == device_uid) .ok_or_else(|| ProtonError::invalid_operation(format!("device {device_uid} not found"))) } /// Decrypt a device's name: the name of its root folder, which is encrypted /// to the device's own share key. Caches the root-folder key so the device's /// children can be enumerated without re-resolving the share. async fn device_root_folder_name(&self, device: &DeviceMetadata) -> Result { let (share_key, _address_id) = self.share_key_by_id(&device.share_id).await?; let uid = &device.root_folder_uid; let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let link = &details .links .first() .ok_or_else(|| { ProtonError::invalid_operation(format!("device root folder {uid} not found")) })? .link; let decrypted = decrypt_link(&share_key, link)?; self.cache .lock() .await .folder_keys .put(uid.clone(), decrypted.node_key); Ok(decrypted.name) } /// Fetch a share by id and unlock its key, returning it with the share's /// membership address id. C# `ShareOperations.GetShareAsync`. async fn share_key_by_id(&self, share_id: &ShareId) -> Result<(PrivateKey, AddressId)> { let path = format!("shares/{share_id}"); let response: ShareResponse = self.http.get(&path).await?; let key = decrypt_share_key(&self.account, &response.share).await?; Ok((key, response.share.address_id)) } /// The `AddressKeyID` of an address's primary key (write requests that bind /// material to an address key need it alongside the `AddressID`). async fn address_primary_key_id(&self, address_id: &AddressId) -> Result { self.account .addresses() .await? .into_iter() .find(|address| &address.id == address_id) .map(|address| address.primary_key_id) .ok_or_else(|| ProtonError::invalid_operation("membership address not found")) } /// Create a fresh-file draft: generate a node key + content key, encrypt the /// name/passphrase/content-key-packet to the parent, and POST the draft. /// Mirrors C# `NewFileDraftProvider`. async fn create_file_draft(&self, spec: FileDraftSpec<'_>) -> Result { let FileDraftSpec { parent_uid, name, media_type, intended_upload_size, aead, for_photos, override_existing_draft, } = spec; let volume_id = parent_uid.volume_id.clone(); // Resolve the parent folder key + hash key and the membership address. // For photos these route to the photos volume / share. let parent_key = self.folder_node_key(parent_uid).await?; let parent_hash_key = self .parent_hash_key_ctx(parent_uid, &parent_key, for_photos) .await?; let (address_id, email, signing_key) = if for_photos { self.photos_membership_address().await? } else { self.membership_address().await? }; // Generate the node key + content key and the file-creation secrets. // An AEAD file uses a v6 node key (C# `PgpProfile.ProtonAead`) so its v6 // content-key PKESK is addressed to a matching v6 recipient. let (node, content_key) = if aead { (generate_node_key_aead()?, ContentKey::generate_aead()) } else { (generate_node_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 encrypted_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 create_request = FileCreationRequest { name: encrypted_name, name_hash, parent_link_id: parent_uid.link_id.clone(), passphrase: encrypted_passphrase, passphrase_signature, key: node.locked_armored.clone(), media_type: media_type.to_owned(), content_key_packet: BASE64.encode(&content_key_packet), content_key_signature, signature_address: email.clone(), client_uid: Some(self.http.session_id().to_string()), intended_upload_size, }; let create_path = format!("v2/volumes/{volume_id}/files"); let client_uid = self.http.session_id().to_string(); // An upload interrupted after the draft node was created but before it was // sealed (e.g. a transport error on the blocks endpoint) leaves an unsealed // draft file of this name in the parent, and every later attempt to create // the same name then `AlreadyExists` (422) forever. When the conflicting // link is an unsealed draft — a `ConflictDraftRevisionID` with no committed // `ConflictRevisionID` — delete that draft node and retry. By default only // our own client's draft (matching client uid) is cleared; with // `override_existing_draft` any client's stale draft is (a draft holds no // committed content, so this only forfeits an in-flight upload that will // re-create on its next attempt — needed to recover across a daemon restart, // which rotates our client uid). A committed file of this name is never // touched: that is a real name collision the caller must resolve. Mirrors C# // `NewFileDraftProvider.CreateDraftAsync` + `overrideExistingDraftByOtherClient`. const MAX_DRAFT_ATTEMPTS: usize = 3; let mut created: Option = None; for _ in 0..MAX_DRAFT_ATTEMPTS { match self .http .post::<_, FileCreationResponse>(&create_path, &create_request) .await { Ok(response) => { created = Some(response); break; } Err(ProtonError::Api(e)) if e.code == ResponseCode::AlreadyExists => { let conflict = e .details .as_ref() .and_then(|d| serde_json::from_value::(d.clone()).ok()); match conflict { Some(RevisionConflict { link_id: Some(link_id), revision_id: None, draft_revision_id: Some(_), draft_client_uid, }) if override_existing_draft || draft_client_uid.as_deref() == Some(client_uid.as_str()) => { tracing::warn!( %volume_id, %link_id, override_existing_draft, "deleting stale draft file node, then retrying create" ); self.delete_draft_nodes(&volume_id, std::slice::from_ref(&link_id)) .await?; } // A committed file of this name, or a draft owned by another // client we were not told to override — surface the original // conflict unchanged. _ => return Err(ProtonError::Api(e)), } } Err(other) => return Err(other), } } let created = created.ok_or_else(|| { ProtonError::invalid_operation("file draft creation kept conflicting after retries") })?; Ok(RevisionDraft { volume_id, link_id: created.file.link_id, revision_id: created.file.revision_id, node_key: node.key, content_key, address_id, email, signing_key, parent_hash_key, }) } /// Open a new-revision draft on an existing file: recover the file's node and /// content keys, then POST a revision based on the currently active one. /// Mirrors C# `NewRevisionDraftProvider`. async fn create_revision_draft( &self, file_uid: &NodeUid, intended_upload_size: i64, ) -> Result { let volume_id = file_uid.volume_id.clone(); // Recover the file's existing secrets (node key + content key). let details = self .get_link_details(&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 link = detail.link; let file = detail.file.ok_or_else(|| { ProtonError::invalid_operation(format!("node {file_uid} is not a file")) })?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { ProtonError::invalid_operation("file is missing its content key packet") })?; let content_key_packet = BASE64.decode(content_key_packet_b64.trim()).map_err(|e| { ProtonError::invalid_operation(format!("decode content key packet: {e}")) })?; let active_revision_id = file .active_revision .map(|r| r.id) .ok_or_else(|| ProtonError::invalid_operation("file has no active revision"))?; let parent_key = self.resolve_parent_key(&volume_id, &link).await?; let node_key = decrypt_link(&parent_key, &link)?.node_key; let content_key = node_key.decrypt_content_key(&content_key_packet)?; let (address_id, email, signing_key) = self.membership_address().await?; // Create the revision draft, superseding the active revision. let client_uid = self.http.session_id().to_string(); let request = RevisionCreationRequest { current_revision_id: active_revision_id, client_uid: Some(client_uid.clone()), intended_upload_size, }; let path = format!( "v2/volumes/{volume_id}/files/{}/revisions", file_uid.link_id ); // An upload interrupted mid-flight (e.g. the daemon was killed) leaves a // draft revision open on the link, and every later attempt 409s with // `AlreadyExists` forever. When the abandoned draft is our own client's // (the server echoes `ConflictDraftClientUID`), delete it and retry — // never touch another client's in-progress draft. Mirrors C# // `NewRevisionDraftProvider`. const MAX_DRAFT_ATTEMPTS: usize = 3; let mut created: Option = None; for _ in 0..MAX_DRAFT_ATTEMPTS { match self .http .post::<_, RevisionCreationResponse>(&path, &request) .await { Ok(response) => { created = Some(response); break; } Err(ProtonError::Api(ref e)) if e.code == ResponseCode::AlreadyExists => { let conflict = e .details .as_ref() .and_then(|d| serde_json::from_value::(d.clone()).ok()); let Some(RevisionConflict { draft_revision_id: Some(draft_id), draft_client_uid: Some(owner), .. }) = conflict else { // No detail, or a draft we can't attribute — do not delete // a draft that might be another client's live upload. return Err(ProtonError::invalid_operation( "revision draft already exists and cannot be recovered", )); }; if owner != client_uid { return Err(ProtonError::invalid_operation( "revision draft is held by another client", )); } tracing::warn!(%file_uid, draft_id, "deleting our stale draft revision, then retrying"); self.delete_revision_by_ids(&volume_id, &file_uid.link_id, &draft_id) .await?; } Err(other) => return Err(other), } } let created = created.ok_or_else(|| { ProtonError::invalid_operation("revision draft creation kept conflicting after retries") })?; Ok(RevisionDraft { volume_id, link_id: file_uid.link_id.clone(), revision_id: created.revision.revision_id, node_key, content_key, address_id, email, signing_key, parent_hash_key: Vec::new(), }) } /// Delete a revision (`DELETE v2/volumes/{vid}/files/{lid}/revisions/{rid}`). /// Used to clear an abandoned draft revision left by an interrupted upload; /// the server permits a writer to delete its own drafts. Mirrors C# /// `FilesApiClient.DeleteRevisionAsync`. async fn delete_revision_by_ids( &self, volume_id: &VolumeId, link_id: &LinkId, revision_id: &str, ) -> Result<()> { let path = format!("v2/volumes/{volume_id}/files/{link_id}/revisions/{revision_id}"); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; Ok(()) } /// Permanently delete unsealed draft file nodes by link id via /// `POST v2/volumes/{vid}/delete_multiple`. Unlike [`delete_nodes`], this does /// not route through the trash — a never-sealed draft is not trashed, so the /// trash-delete path does not apply. A per-link `DoesNotExist` is tolerated /// (the draft may already be gone). Mirrors C# `LinksApiClient.DeleteMultipleAsync` /// as used by `NewFileDraftProvider`. async fn delete_draft_nodes(&self, volume_id: &VolumeId, link_ids: &[LinkId]) -> Result<()> { let path = format!("v2/volumes/{volume_id}/delete_multiple"); let body = MultipleLinksRequest { link_ids }; let response: AggregateLinksResponse = self.http.post(&path, &body).await?; let failures: Vec = response .responses .iter() .filter(|pair| { !pair.response.is_success() && pair.response.code != ResponseCode::DoesNotExist }) .map(|pair| format!("{} ({:?})", pair.link_id, pair.response.code)) .collect(); if failures.is_empty() { Ok(()) } else { Err(ProtonError::invalid_operation(format!( "delete draft failed for {} link(s): {}", failures.len(), failures.join(", ") ))) } } /// Encrypt, sign, verify and upload every content block of a draft revision, /// accumulating the content manifest and extended-attribute metadata. /// /// Two halves run concurrently, mirroring the TypeScript SDK's /// `internal/upload/streamUploader.ts`: an **encryptor** reads the plaintext /// block by block and hands encrypted blocks to an **uploader** over a /// bounded channel; the uploader asks for upload tokens for every block that /// is ready (one `POST blocks` per batch, thumbnails riding the first one) /// and pushes up to [`MAX_CONCURRENT_BLOCK_UPLOADS`] of them at a time. /// /// Both halves stay on *this* task: `reader` is only `Read + Send`, never /// `'static` (`upload_file` hands in a `&[u8]`), so it cannot move into a /// spawned task. Only the CPU-bound per-block crypto is offloaded, via /// `spawn_blocking`. /// /// Unlike TS, which keeps a sliding window, a batch's tokens are requested /// once the previous batch has finished storing. That costs one round-trip /// per batch of overlap and keeps the ordering bookkeeping trivial. /// /// Thumbnail digests lead the manifest in `ThumbnailType` order and content /// digests follow in block-index order — the layout the download path /// verifies, unchanged even though blocks now reach storage out of order /// (each block carries its index on the wire). SHA-1, block sizes and the /// total-size counter are folded by the encryptor, in order. An empty reader /// yields zero content blocks (an empty file). async fn write_blocks( &self, draft: &RevisionDraft, mut reader: R, mut thumbnails: Vec, ) -> Result { // Confirm the verification input matches our node/content key. let verification_code = self .fetch_verification_code( &draft.volume_id, &draft.link_id, &draft.revision_id, &draft.node_key, ) .await?; // Thumbnails are encrypted up front, in type order: the API only issues // thumbnail upload tokens with the *first* block-token request (TS // `requestAndInitiateUpload`), and they are tiny enough that encrypting // them inline costs nothing. thumbnails.sort_by_key(|t| t.thumbnail_type); let encrypted_thumbnails = thumbnails .iter() .map(|thumbnail| encrypt_thumbnail_block(draft, thumbnail)) .collect::>>()?; let thumbnail_digests: Vec<[u8; 32]> = encrypted_thumbnails.iter().map(|t| t.digest).collect(); let (tx, rx) = mpsc::channel(MAX_BUFFERED_UPLOAD_BLOCKS); let (written, content_digests) = tokio::try_join!( self.encrypt_content_blocks(draft, &mut reader, &verification_code, tx), self.upload_prepared_blocks(draft, rx, encrypted_thumbnails), )?; let (block_sizes, total_size, sha1_hex) = written; if content_digests.len() != block_sizes.len() { return Err(ProtonError::invalid_operation(format!( "upload stored {} of {} content blocks", content_digests.len(), block_sizes.len() ))); } Ok(BlockWriteResult { manifest: assemble_manifest(&thumbnail_digests, content_digests), block_sizes, total_size, sha1_hex, }) } /// Read `reader` one [`DEFAULT_BLOCK_SIZE`] block at a time, encrypt and sign /// each block off the runtime, and hand it to the uploader over `tx`. /// /// Returns the per-block plaintext sizes, the total plaintext size and the /// hex SHA-1 of the whole plaintext — all folded as each block is *read*, /// which is why reading stays sequential. The PGP half of each block runs on /// the blocking pool, [`MAX_CONCURRENT_BLOCK_ENCRYPTS`] at a time, and blocks /// leave in index order regardless of which finishes first /// ([`FuturesOrdered`]) — not for the uploader's sake, which indexes blocks /// on the wire anyway, but so a stall cannot reorder the channel. async fn encrypt_content_blocks( &self, draft: &RevisionDraft, reader: &mut R, verification_code: &[u8], tx: mpsc::Sender, ) -> Result<(Vec, i64, String)> { let mut block_sizes = Vec::new(); let mut sha1 = Sha1::new(); let mut total_size: i64 = 0; // Block indices are 1-based (C# `blockNumber = i + 1`). let mut index = 1_i32; let mut reader_drained = false; // Each entry pairs the encrypt task with the in-flight permit its block // holds, so the permit lives exactly as long as the block does. let mut encrypting = FuturesOrdered::new(); loop { while !reader_drained && encrypting.len() < MAX_CONCURRENT_BLOCK_ENCRYPTS { // The client-wide in-flight block permit, held until this block's // ciphertext has been stored. // // Only *wait* for one when nothing is in flight. A block being // encrypted already holds a permit and cannot release it until it // has been sent, uploaded and dropped — so blocking here while // holding one would deadlock as soon as the pool is smaller than // this window, which `with_max_inflight_blocks(1)` makes exact. // With something to drain, an unavailable permit just means the // window is as wide as the pool currently allows. let permit = if encrypting.is_empty() { self.block_slots().acquire_owned().await.map_err(|e| { ProtonError::invalid_operation(format!("block slots closed: {e}")) })? } else { match self.block_slots().try_acquire_owned() { Ok(permit) => permit, Err(TryAcquireError::NoPermits) => break, Err(e) => { return Err(ProtonError::invalid_operation(format!( "block slots closed: {e}" ))); } } }; // A fresh buffer per block rather than one reused across the // loop: the plaintext is moved into the blocking encrypt task, // and copying 4 MiB out of a shared buffer would cost as much as // allocating. let mut buf = vec![0u8; DEFAULT_BLOCK_SIZE]; let n = read_full_block(reader, &mut buf)?; if n == 0 { reader_drained = true; break; } buf.truncate(n); sha1.update(&buf); total_size += n as i64; block_sizes.push(n as i32); let content_key = draft.content_key.clone(); let node_key = draft.node_key.clone(); let signing_key = draft.signing_key.clone(); let code = verification_code.to_vec(); let encrypt = tokio::task::spawn_blocking(move || { encrypt_content_block(&content_key, &node_key, &signing_key, &code, index, &buf) }); encrypting.push_back(async move { (encrypt.await, permit) }); index += 1; } let Some((encrypted, permit)) = encrypting.next().await else { // Reader drained and nothing left in flight. break; }; let (ciphertext, digest, request) = encrypted.map_err(|e| { ProtonError::invalid_operation(format!("block encrypt task failed: {e}")) })??; let prepared = PreparedBlock { ciphertext, digest, request, permit, }; if tx.send(prepared).await.is_err() { // The uploader is gone, which means it failed: `try_join!` will // surface its error rather than anything we could say here. break; } } Ok((block_sizes, total_size, hex::encode(sha1.finalize()))) } /// Take encrypted blocks off `rx`, request upload targets for each batch and /// store the ciphertext, [`MAX_CONCURRENT_BLOCK_UPLOADS`] blocks at a time. /// /// Returns `(block index, ciphertext digest)` per stored content block, in /// completion order — the caller sorts. Thumbnails ride the first batch and /// contribute no entries; their digests are already known to the caller. async fn upload_prepared_blocks( &self, draft: &RevisionDraft, mut rx: mpsc::Receiver, thumbnails: Vec, ) -> Result> { let context = UploadContext::from_draft(draft); // Both are local to this one file's upload: a timeout downshift must not // leak into other transfers (TS `limitUploadCapacity`). let limiter = Arc::new(Semaphore::new(MAX_CONCURRENT_BLOCK_UPLOADS)); let downshifted = Arc::new(AtomicBool::new(false)); let mut pending_thumbnails = thumbnails; let mut digests = Vec::new(); let mut batch: Vec = Vec::with_capacity(MAX_BUFFERED_UPLOAD_BLOCKS + 1); loop { // Blocks the encryptor has finished so far, however many that is: // one token request per batch instead of one per block. if rx .recv_many(&mut batch, MAX_BUFFERED_UPLOAD_BLOCKS + 1) .await == 0 { break; } let stored = self .upload_batch( &context, &limiter, &downshifted, std::mem::take(&mut batch), std::mem::take(&mut pending_thumbnails), ) .await?; digests.extend(stored); } // A file with thumbnails but no content at all still has to ship them, // and they never got a batch to ride along with. if !pending_thumbnails.is_empty() { self.upload_batch( &context, &limiter, &downshifted, Vec::new(), pending_thumbnails, ) .await?; } Ok(digests) } /// Request upload targets for one batch of encrypted blocks (plus any /// thumbnails still waiting for their first request) and store them, /// [`MAX_CONCURRENT_BLOCK_UPLOADS`] at a time. async fn upload_batch( &self, context: &UploadContext, limiter: &Arc, downshifted: &Arc, batch: Vec, thumbnails: Vec, ) -> Result> { let requests: Vec = batch.iter().map(|block| block.request.clone()).collect(); let thumbnail_requests: Vec = thumbnails .iter() .map(|thumbnail| thumbnail.request.clone()) .collect(); let prepared = request_upload_targets(&self.http, context, requests, thumbnail_requests).await?; let mut block_targets = prepared.upload_targets; let mut thumbnail_targets = prepared.thumbnail_upload_targets; let mut jobs = Vec::with_capacity(batch.len() + thumbnails.len()); for thumbnail in thumbnails { let target = take_thumbnail_target(&mut thumbnail_targets, thumbnail.thumbnail_type) .ok_or_else(|| { ProtonError::invalid_operation(format!( "thumbnail upload preparation returned no target for type {}", thumbnail.thumbnail_type )) })?; jobs.push(UploadJob { index: None, ciphertext: thumbnail.ciphertext, digest: thumbnail.digest, request: None, target, permit: None, }); } for block in batch { let index = block.request.index; let target = take_block_target(&mut block_targets, index).ok_or_else(|| { ProtonError::invalid_operation(format!( "block upload preparation returned no target for block {index}" )) })?; jobs.push(UploadJob { index: Some(index), ciphertext: block.ciphertext, digest: block.digest, request: Some(block.request), target, permit: Some(block.permit), }); } // Each job owns everything it needs; nothing borrows the draft, so this // future stays `tokio::spawn`-able for callers (see `tests/spawnable.rs`). let stored: Vec> = stream::iter(jobs.into_iter().map(|job| { let http = self.http.clone(); let context = context.clone(); let limiter = limiter.clone(); let downshifted = downshifted.clone(); async move { upload_one_block(&http, &context, job, &limiter, &downshifted).await } })) .buffer_unordered(MAX_CONCURRENT_BLOCK_UPLOADS) .try_collect() .await?; Ok(stored.into_iter().flatten().collect()) } /// Seal a draft revision: PUT a signed content manifest plus encrypted + /// signed extended attributes, making the revision active. async fn seal_revision( &self, draft: &RevisionDraft, written: &BlockWriteResult, modification_time: Option, photos_attributes: Option, ) -> Result<()> { let manifest_signature = draft.signing_key.sign_detached(&written.manifest)?; let extended_attributes = ExtendedAttributes { common: CommonExtendedAttributes { size: Some(written.total_size), modification_time: modification_time.map(epoch_to_iso8601), block_sizes: Some(written.block_sizes.clone()), digests: Some(FileContentDigests { sha1: written.sha1_hex.clone(), }), }, }; let xattr_json = serde_json::to_vec(&extended_attributes) .map_err(|e| ProtonError::invalid_operation(format!("serialize xattr: {e}")))?; let encrypted_xattr = draft .node_key .encrypt_and_sign(&draft.signing_key, &xattr_json, false, true)?; let seal_request = RevisionUpdateRequest { manifest_signature, signature_address: draft.email.clone(), checksum_verified: false, extended_attributes: Some(encrypted_xattr), photos_attributes, }; let seal_path = format!( "v2/volumes/{}/files/{}/revisions/{}", draft.volume_id, draft.link_id, draft.revision_id ); let _: proton_sdk::api::ApiResponse = self.http.put(&seal_path, &seal_request).await?; Ok(()) } /// The membership address for the My Files share: its id, email, and /// primary (signing) private key. async fn membership_address(&self) -> Result<(AddressId, String, PrivateKey)> { self.ensure_my_files().await?; let address_id = self .cache .lock() .await .my_files_share .as_ref() .expect("ensure_my_files populates the share") .address_id .clone(); self.resolve_membership_address(address_id).await } /// Resolve a node's context-share membership address. /// /// The fallback is intentional and permanent: the context endpoint is newer /// than the My Files path and sharing must retain its previous behavior when /// the endpoint or context share cannot be resolved. async fn membership_address_for( &self, uid: &NodeUid, ) -> Result<(AddressId, String, PrivateKey)> { let context_result = async { let share_id = self.context_share_id(uid).await?; let path = format!("shares/{share_id}"); let response: ShareResponse = self.http.get(&path).await?; self.resolve_membership_address(response.share.address_id) .await } .await; match context_result { Ok(address) => Ok(address), Err(error) => { tracing::warn!( %uid, %error, "failed to resolve context-share membership address; falling back to My Files" ); self.membership_address().await } } } /// The membership address for the Photos share. Errors when the account has /// no photos volume. C# resolves this from the node's context share; here the /// photos share's `MembershipAddressId` is cached by [`ensure_photos`]. async fn photos_membership_address(&self) -> Result<(AddressId, String, PrivateKey)> { if !self.ensure_photos().await? { return Err(ProtonError::invalid_operation( "account has no photos volume", )); } let address_id = self .cache .lock() .await .photos_share .as_ref() .expect("ensure_photos populated the photos share") .address_id .clone(); self.resolve_membership_address(address_id).await } /// Resolve a membership address id to its email + primary signing key. async fn resolve_membership_address( &self, address_id: AddressId, ) -> Result<(AddressId, String, PrivateKey)> { let address = self .account .addresses() .await? .into_iter() .find(|a| a.id == address_id) .ok_or_else(|| ProtonError::invalid_operation("membership address not found"))?; let keys = self.account.address_private_keys(&address_id).await?; let signing_key = keys .get(address.primary_key_index) .cloned() .ok_or_else(|| { ProtonError::invalid_operation("membership address has no primary key") })?; Ok((address_id, address.email, signing_key)) } /// Decrypt the parent folder's hash key (HMAC key for name hashing). async fn parent_hash_key( &self, parent_uid: &NodeUid, parent_key: &PrivateKey, ) -> Result> { self.parent_hash_key_ctx(parent_uid, parent_key, false) .await } /// As [`parent_hash_key`](Self::parent_hash_key), but routes the link-details /// lookup to the photos volume endpoint when `for_photos`. async fn parent_hash_key_ctx( &self, parent_uid: &NodeUid, parent_key: &PrivateKey, for_photos: bool, ) -> Result> { let details = self .get_link_details_ctx( &parent_uid.volume_id, std::slice::from_ref(&parent_uid.link_id), for_photos, ) .await?; let detail = details .links .into_iter() .next() .ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?; // An album carries its hash key in the `Album` block instead of `Folder` // (C# builds a synthetic `FolderDto` from `AlbumDto`), and album children // are hashed under it exactly as a folder's are. let folder = detail .folder_properties() .ok_or_else(|| ProtonError::invalid_operation("parent node is not a folder"))?; Ok(parent_key.decrypt_armored_message(folder.hash_key)?) } /// Fetch the block verification code, validating that the returned content /// key packet decrypts under our node key (C# `NodeKeyAndSessionKey` check). async fn fetch_verification_code( &self, volume_id: &VolumeId, link_id: &LinkId, revision_id: &str, node_key: &PrivateKey, ) -> Result> { let path = format!("v2/volumes/{volume_id}/links/{link_id}/revisions/{revision_id}/verification"); let response: BlockVerificationInputResponse = self.http.get(&path).await?; let packet = BASE64 .decode(response.content_key_packet.trim()) .map_err(|e| { ProtonError::invalid_operation(format!("decode verification packet: {e}")) })?; node_key.decrypt_content_key(&packet).map_err(|e| { ProtonError::invalid_operation(format!("verification content key mismatch: {e}")) })?; BASE64 .decode(response.verification_code.trim()) .map_err(|e| ProtonError::invalid_operation(format!("decode verification code: {e}"))) } /// Fetch every block of a revision (paginated), returning the revision /// metadata plus the contiguous, index-sorted block list. /// Fetch every content block, decrypt it, write the plaintext to `output` /// and append the ciphertext digest to `manifest`. /// /// Blocks are fetched [`MAX_CONCURRENT_BLOCK_DOWNLOADS`] at a time and /// decrypted off-runtime, but are consumed strictly in index order: both the /// manifest (whose signature is checked over the concatenated digests) and /// `output` depend on that ordering. async fn write_content_blocks( &self, targets: Arc, block_count: usize, content_key: &ContentKey, manifest: &mut Vec, output: &mut W, ) -> Result<()> { // Each fetch owns an `Arc` of the block table rather than borrowing it. // Borrowing here makes the resulting future carry a higher-ranked // lifetime that `tokio::spawn` rejects ("implementation of `FnOnce` is // not general enough") in *callers* of `download_file_to` — a downstream // break, not a local one, so it does not show up in this crate's own // build. let mut decrypted = stream::iter((0..block_count).map(|index| { let targets = targets.clone(); let content_key = content_key.clone(); let slots = self.block_slots(); async move { // The client-wide in-flight block cap. Held across fetch and // decrypt, and returned to the caller so it outlives the // plaintext sitting in `buffered`'s queue — see // `RevisionReader::block_plaintext`. let permit = slots.acquire_owned().await.map_err(|e| { ProtonError::invalid_operation(format!("block slots closed: {e}")) })?; // Goes through the block table, not a snapshot of it: a download // of a large file outlives its block URLs, and this re-lists the // revision once per expiry instead of failing the transfer. let ciphertext = targets.ciphertext(index).await?; let (digest, plaintext) = digest_and_decrypt_block_blocking(content_key, ciphertext).await?; Ok::<_, ProtonError>((digest, plaintext, permit)) } })) .buffered(MAX_CONCURRENT_BLOCK_DOWNLOADS); while let Some((digest, plaintext, _permit)) = decrypted.try_next().await? { output .write_all(&plaintext) .map_err(|e| ProtonError::invalid_operation(format!("write block: {e}")))?; manifest.extend_from_slice(&digest); } Ok(()) } /// The revision's metadata and its full, ordered block table. /// /// The paging, ordering and contiguity rules are identical on the visitor /// path, so they live in /// [`RevisionTransport::list_blocks`](crate::transport::RevisionTransport) /// and this is the authenticated entry point to them. pub(crate) async fn fetch_revision_blocks( &self, volume_id: &VolumeId, link_id: &LinkId, revision_id: &str, ) -> Result<(RevisionDto, Vec)> { self.revision_transport() .list_blocks(volume_id, link_id, revision_id) .await } async fn ensure_my_files(&self) -> Result<()> { if self.cache.lock().await.my_files_share.is_some() { return Ok(()); } // Only one of a burst of first calls does the bootstrap; the rest wait // here and take the second check below. On failure the guard is dropped // with nothing cached, so the next caller retries rather than inheriting // the error. let _guard = self.bootstrap.my_files.lock().await; if self.cache.lock().await.my_files_share.is_some() { return Ok(()); } // Mirrors C# `NodeOperations.GetOrCreateMyFilesFolderAsync`: a brand-new // account has no My Files volume yet, so the share lookup fails. Create // the volume, then re-read it through the normal path to populate caches. let mut response: MyFilesShareResponse = match self.http.get("v2/shares/my-files").await { Ok(response) => response, Err(e) if is_not_found(&e) => { self.create_volume().await?; self.http.get("v2/shares/my-files").await? } Err(e) => return Err(e), }; // A password reset replaces the account's keys and locks the volume // whose share was encrypted to the old ones; its key then fails with // "missing key" on every call. The web client answers a missing // *active* regular volume by creating a fresh one (the locked one stays // restorable from the web), so do the same, but only once the server // confirms no active regular volume is left, never on a guess. let share_key = match decrypt_share_key(&self.account, &response.share).await { Ok(key) => key, Err(e @ ProtonError::Crypto(_)) => { let volumes: VolumeListResponse = self.http.get("volumes").await?; if has_active_regular_volume(&volumes) { return Err(e); } tracing::warn!(error = %e, "My Files volume is locked; creating a new one"); self.create_volume().await?; response = self.http.get("v2/shares/my-files").await?; decrypt_share_key(&self.account, &response.share).await? } Err(e) => return Err(e), }; let volume_id = response.volume.id.clone(); let share_id = response.share.id.clone(); // The My Files root link's parent key is the share key. let root_link = &response.link.link; let root_uid = NodeUid::new(volume_id.clone(), root_link.id.clone()); let decrypted_root = decrypt_link(&share_key, root_link)?; let mut cache = self.cache.lock().await; cache.main_volume_id = Some(volume_id); cache.my_files_root = Some(root_uid.clone()); cache.folder_keys.put(root_uid, decrypted_root.node_key); cache.my_files_share = Some(ShareKey { share_id, address_id: response.share.address_id.clone(), key: share_key, }); Ok(()) } /// Create the account's main volume (root share + root folder). /// /// Mirrors C# `VolumeOperations.CreateVolumeAsync`: build the root share and /// folder crypto material against the default address's primary key and /// `POST volumes`. The server-side state is then read back by the caller /// ([`ensure_my_files`](Self::ensure_my_files)) via the normal share lookup, /// so no local cache priming is needed here. async fn create_volume(&self) -> Result<()> { let mut timer = self.telemetry.start("create_volume"); let address = self.account.default_address().await?; let address_keys = self.account.address_private_keys(&address.id).await?; let address_key = address_keys .get(address.primary_key_index) .ok_or_else(|| ProtonError::invalid_operation("default address has no primary key"))?; let material = build_volume_creation_material(address_key, "root")?; let request = VolumeCreationRequest { address_id: address.id.clone(), address_key_id: address.primary_key_id.clone(), share_key: material.share_key_armored, share_passphrase: material.share_passphrase, share_passphrase_signature: material.share_passphrase_signature, folder_name: material.folder_name, folder_key: material.folder_key_armored, folder_passphrase: material.folder_passphrase, folder_passphrase_signature: material.folder_passphrase_signature, folder_hash_key: material.folder_hash_key, }; let _: proton_sdk::api::ApiResponse = self.http.post("volumes", &request).await?; timer.success(); Ok(()) } async fn root_share_key(&self) -> Result { self.ensure_my_files().await?; Ok(self .cache .lock() .await .my_files_share .as_ref() .expect("ensure_my_files populates the share key") .key .clone()) } /// The share key that unlocks a volume-*root* link. Normally that's the My /// Files root (backed by the main share key), but a registered device's /// root folder is also parentless and lives on the same volume while being /// wrapped to that device's *own* share key, and a node another user shares /// with us is parentless on *their* volume, wrapped to the membership share /// we were granted. Match the root link id against the device set, then the /// shared-with-me set, unlocking whichever share owns it; fall back to the /// My Files share for anything unrecognised. async fn root_link_share_key( &self, volume_id: &VolumeId, root_id: &LinkId, ) -> Result { self.ensure_my_files().await?; if self .cache .lock() .await .my_files_root .as_ref() .is_some_and(|uid| &uid.link_id == root_id) { return self.root_share_key().await; } if let Ok(devices) = self.device_metadata().await && let Some(device) = devices.into_iter().find(|d| { &d.root_folder_uid.link_id == root_id && &d.root_folder_uid.volume_id == volume_id }) { let (key, _address_id) = self.share_key_by_id(&device.share_id).await?; return Ok(key); } let root_uid = NodeUid::new(volume_id.clone(), root_id.clone()); if let Some(key) = self.shared_with_me_share_key(&root_uid).await? { return Ok(key); } self.root_share_key().await } /// The membership share key for a node shared with us, or `None` when the /// node is not one. A cached listing that misses is re-paged once, so a /// share joined since the last listing still resolves. async fn shared_with_me_share_key(&self, uid: &NodeUid) -> Result> { let was_cached = self.cache.lock().await.shared_with_me_shares.is_some(); let mut share_id = self.shared_with_me_shares(false).await?.get(uid).cloned(); if share_id.is_none() && was_cached { share_id = self.shared_with_me_shares(true).await?.get(uid).cloned(); } match share_id { Some(share_id) => { let (key, _address_id) = self.share_key_by_id(&share_id).await?; Ok(Some(key)) } None => Ok(None), } } /// Resolve the Photos share + root folder, caching them. /// /// Mirrors C# `PhotosNodeOperations.GetFreshExistingPhotosFolderAsync`: /// `GET v2/shares/photos`, decrypt the share key, decrypt the root link. /// Returns `false` (and records the absence) when the account has no photos /// volume — the API answers [`ResponseCode::DoesNotExist`], which C# catches /// the same way. Read-only: it does not create a photos volume. async fn ensure_photos(&self) -> Result { { let cache = self.cache.lock().await; if let Some(exists) = cache.photos_volume_exists { return Ok(exists); } } // Single-flighted for the same reason as `ensure_my_files`: a burst of // first photos calls (a gallery opening pages several at once) would // otherwise each fetch and decrypt the share. let _guard = self.bootstrap.photos.lock().await; { let cache = self.cache.lock().await; if let Some(exists) = cache.photos_volume_exists { return Ok(exists); } } let response: MyFilesShareResponse = match self.http.get("v2/shares/photos").await { Ok(response) => response, Err(ProtonError::Api(e)) if e.code == proton_sdk::api::ResponseCode::DoesNotExist => { self.cache.lock().await.photos_volume_exists = Some(false); return Ok(false); } Err(e) => return Err(e), }; let volume_id = response.volume.id.clone(); let share_id = response.share.id.clone(); let share_key = decrypt_share_key(&self.account, &response.share).await?; let root_link = &response.link.link; let root_uid = NodeUid::new(volume_id.clone(), root_link.id.clone()); let decrypted_root = decrypt_link(&share_key, root_link)?; let mut cache = self.cache.lock().await; cache.photos_root = Some(root_uid.clone()); cache.folder_keys.put(root_uid, decrypted_root.node_key); cache.photos_share = Some(ShareKey { share_id, address_id: response.share.address_id.clone(), key: share_key, }); cache.photos_volume_exists = Some(true); Ok(true) } async fn photos_share_key(&self) -> Result { if !self.ensure_photos().await? { return Err(ProtonError::invalid_operation( "account has no photos volume", )); } Ok(self .cache .lock() .await .photos_share .as_ref() .expect("ensure_photos populated the photos share") .key .clone()) } /// The resolved Photos share id, if Photos has been resolved. async fn photos_share_id(&self) -> Option { self.cache .lock() .await .photos_share .as_ref() .map(|share| share.share_id.clone()) } /// The Photos root folder, or `None` if the account has no photos volume. /// C# `PhotosNodeOperations.TryGetExistingPhotosFolderAsync` (read-only: /// does not create one). pub(crate) async fn get_photos_root(&self) -> Result> { if !self.ensure_photos().await? { return Ok(None); } let root_uid = self .cache .lock() .await .photos_root .clone() .expect("ensure_photos populated the photos root"); self.get_photos_node(&root_uid).await } /// Fetch a single photo/photos-volume node, routed to the photos endpoint. /// C# `ProtonPhotosClient.GetNodeAsync` (`EnumerateNodesAsync forPhotos`). pub(crate) async fn get_photos_node(&self, uid: &NodeUid) -> Result> { let response = self .get_link_details_ctx(&uid.volume_id, std::slice::from_ref(&uid.link_id), true) .await?; let Some(details) = response.links.into_iter().next() else { return Ok(None); }; let parent_key = self .resolve_parent_key_ctx(&uid.volume_id, &details.link, true) .await?; let node = self .build_node_ctx( &uid.volume_id, &details, &parent_key, true, NodeDetail::Full, ) .await?; Ok(Some(node)) } /// Fetch decrypted metadata for many photo nodes (photos routing). /// C# `ProtonPhotosClient.EnumerateNodesAsync`. Undecryptable nodes are /// logged and skipped, matching the main-volume enumeration behavior. pub(crate) async fn enumerate_photos_nodes(&self, uids: &[NodeUid]) -> Result> { let mut nodes = Vec::new(); for (volume_id, link_ids) in group_by_volume(uids) { for chunk in link_ids.chunks(MAX_BATCH_COUNT) { let details = self.get_link_details_ctx(&volume_id, chunk, true).await?; for detail in &details.links { let parent_key = match self .resolve_parent_key_ctx(&volume_id, &detail.link, true) .await { Ok(key) => key, Err(e) => { tracing::warn!(link_id = %detail.link.id, error = %e, "skipping photo: parent key unavailable"); continue; } }; match self .build_node_ctx(&volume_id, detail, &parent_key, true, NodeDetail::Full) .await { Ok(node) => nodes.push(node), Err(e) => { tracing::warn!(link_id = %detail.link.id, error = %e, "skipping undecryptable photo"); } } } } } Ok(nodes) } /// Page the photos timeline newest-first. /// /// Mirrors C# `PhotosNodeOperations.EnumeratePhotosTimelineAsync`: /// `GET volumes/{vid}/photos`, 500 per page, anchored on the last link id of /// a full page. Returns an empty list when no photos volume exists. pub(crate) async fn enumerate_photos_timeline(&self) -> Result> { if !self.ensure_photos().await? { return Ok(Vec::new()); } let volume_id = self .cache .lock() .await .photos_root .as_ref() .expect("ensure_photos populated the photos root") .volume_id .clone(); let mut items = Vec::new(); let mut anchor: Option = None; loop { let mut path = format!("volumes/{volume_id}/photos"); if let Some(anchor_id) = &anchor { path.push_str(&format!("?PreviousPageLastLinkID={anchor_id}")); } let page: TimelinePhotoListResponse = self.http.get(&path).await?; let count = page.photos.len(); for photo in &page.photos { items.push(PhotosTimelineItem { uid: NodeUid::new(volume_id.clone(), photo.id.clone()), capture_time: photo.capture_time, }); } if count == TIMELINE_PAGE_SIZE { anchor = page.photos.last().map(|p| p.id.clone()); } else { break; } if anchor.is_none() { break; } } 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_query( &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()) } /// Batch form of [`find_photo_duplicates`](Self::find_photo_duplicates): /// one `POST volumes/{vid}/photos/duplicates` per [`DUPLICATE_BATCH`] input /// pairs instead of one per photo. /// /// Each input is `(name, sha1_hex)` — the *plaintext* SHA-1 of the photo's /// contents, lowercase hex, which the caller usually already computed while /// reading the file. The result is one match list per input, in input order. /// /// Not an upstream port: the TS/C# SDKs only expose the one-photo call, but /// the endpoint already takes a `NameHashes` array, and a Google Takeout /// import asks this question tens of thousands of times. pub(crate) async fn find_photo_duplicates_many( &self, items: &[(String, String)], ) -> Result>> { const ACTIVE_LINK_STATE: i32 = 1; /// Name hashes per duplicates request. const DUPLICATE_BATCH: usize = 150; let mut out = vec![Vec::new(); items.len()]; if items.is_empty() { return Ok(out); } 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?; // Two inputs can share a name (the same photo under two Takeout album // folders), so a name hash maps to *every* input position that produced // it, and each position is matched on its own content hash. let mut positions: HashMap> = HashMap::new(); let mut name_hashes: Vec = Vec::new(); let mut content_hashes: Vec = Vec::with_capacity(items.len()); for (index, (name, sha1_hex)) in items.iter().enumerate() { let name_hash = hex::encode(hmac_sha256(&hash_key, name.as_bytes())); positions .entry(name_hash.clone()) .or_insert_with(|| { name_hashes.push(name_hash.clone()); Vec::new() }) .push(index); content_hashes.push(hex::encode(hmac_sha256( &hash_key, sha1_hex.to_ascii_lowercase().as_bytes(), ))); } for chunk in name_hashes.chunks(DUPLICATE_BATCH) { let path = format!("volumes/{}/photos/duplicates", photos_root.volume_id); let response: FindPhotoDuplicatesResponse = self .http .post_query( &path, &FindPhotoDuplicatesRequest { name_hashes: chunk.to_vec(), }, ) .await?; for duplicate in response.duplicate_hashes { let Some(link_id) = duplicate.link_id else { continue; }; if duplicate.link_state != Some(ACTIVE_LINK_STATE) || duplicate.name_hash.is_empty() || duplicate.content_hash.is_empty() { continue; } // The server echoes the name hash it matched; find the inputs // that asked for it and keep the ones whose content agrees too. let Some(indices) = positions.get(&duplicate.name_hash.to_ascii_lowercase()) else { continue; }; for &index in indices { if duplicate .content_hash .eq_ignore_ascii_case(&content_hashes[index]) { out[index] .push(NodeUid::new(photos_root.volume_id.clone(), link_id.clone())); } } } } Ok(out) } /// Which of `names` any *active* photo on the volume already carries. /// /// The cheap half of [`find_photo_duplicates_many`](Self::find_photo_duplicates_many): /// it matches on the name hash alone, so it needs no content digest and /// therefore no reading of the candidate files. A `true` is "maybe a /// duplicate, hash it and ask again"; a `false` is conclusive. pub(crate) async fn photo_name_collisions(&self, names: &[String]) -> Result> { const ACTIVE_LINK_STATE: i32 = 1; const DUPLICATE_BATCH: usize = 150; let mut out = vec![false; names.len()]; if names.is_empty() { return Ok(out); } 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 mut positions: HashMap> = HashMap::new(); let mut name_hashes: Vec = Vec::new(); for (index, name) in names.iter().enumerate() { let name_hash = hex::encode(hmac_sha256(&hash_key, name.as_bytes())); positions .entry(name_hash.clone()) .or_insert_with(|| { name_hashes.push(name_hash.clone()); Vec::new() }) .push(index); } for chunk in name_hashes.chunks(DUPLICATE_BATCH) { let path = format!("volumes/{}/photos/duplicates", photos_root.volume_id); let response: FindPhotoDuplicatesResponse = self .http .post_query( &path, &FindPhotoDuplicatesRequest { name_hashes: chunk.to_vec(), }, ) .await?; for duplicate in response.duplicate_hashes { if duplicate.link_id.is_none() || duplicate.link_state != Some(ACTIVE_LINK_STATE) { continue; } if let Some(indices) = positions.get(&duplicate.name_hash.to_ascii_lowercase()) { for &index in indices { out[index] = true; } } } } Ok(out) } /// Create an album named `name` on the account's photos volume. /// /// Ported from the TypeScript SDK (`AlbumsManager.createAlbum` + /// `AlbumsCryptoService.createAlbum`); C# exposes no album write API. An /// album is a folder node parented to the photos root, so the crypto is the /// folder create: a generated node key locked to a fresh passphrase that is /// encrypted to the photos root key and signed by the photos share's /// membership address, plus the album's own child-name hash key. pub(crate) async fn create_album(&self, name: &str) -> Result { let mut timer = self.telemetry.start("create_album"); validate_node_name(name)?; 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 root_hash_key = self .parent_hash_key_ctx(&photos_root, &root_key, true) .await?; let (_address_id, email, signing_key) = self.photos_membership_address().await?; let node = generate_node_key()?; let node_hash_key = generate_node_hash_key(&node.key)?; let request = AlbumCreationRequest { locked: false, link: AlbumCreationLink { name: root_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?, name_hash: hex::encode(hmac_sha256(&root_hash_key, name.as_bytes())), key: node.locked_armored, passphrase: root_key.encrypt(&node.passphrase)?, passphrase_signature: signing_key.sign_detached(&node.passphrase)?, signature_email: email, node_hash_key, extended_attributes: None, }, }; let path = format!("photos/volumes/{}/albums", photos_root.volume_id); let created: AlbumCreationResponse = self.http.post(&path, &request).await?; timer.success(); Ok(NodeUid::new( photos_root.volume_id, created.album.link.link_id, )) } /// Add photos to `album_uid`, returning one outcome per input photo in /// input order. The photos stay where they are, timeline included. /// /// Mirrors C# `PhotoOperations.AddPhotosToAlbumAsync` (itself the TS /// `AddToAlbumProcess`). A photo on the album's volume is added through /// `POST photos/volumes/{vid}/albums/{lid}/add-multiple`; one on another /// volume — from an album shared with us — is copied into the album /// through `POST volumes/{vid}/links/{lid}/copy`. Each photo's name and node /// passphrase are re-encrypted to the album key and its name/content hashes /// recomputed under the album's hash key; the photo itself is not /// re-uploaded. A main photo's related photos (live-photo video, burst /// siblings) travel with it, and when the server names related photos it /// still wants, the photo is sent once more with them, as in /// [`save_photos_to_timeline`](Self::save_photos_to_timeline). A photo /// already in the album fails its own outcome. pub(crate) async fn add_photos_to_album( &self, album_uid: &NodeUid, photo_uids: &[NodeUid], ) -> Result)>> { let mut timer = self.telemetry.start("add_photos_to_album"); timer.attr("photo_count", photo_uids.len()); if photo_uids.is_empty() { timer.success(); return Ok(Vec::new()); } let key = self.folder_node_key(album_uid).await?; let hash_key = self.parent_hash_key_ctx(album_uid, &key, true).await?; let (_address_id, email, signing_key) = self.photos_membership_address().await?; let target = PhotoTransferTarget { uid: album_uid.clone(), kind: PhotoTransferKind::Album, key, hash_key, email, signing_key, }; let mut outcomes = self.transfer_photos(&target, photo_uids).await?; // The transfer reports in completion order; this call has always // answered in input order. let position: HashMap<&NodeUid, usize> = photo_uids .iter() .enumerate() .rev() .map(|(index, uid)| (uid, index)) .collect(); outcomes.sort_by_key(|(uid, _)| position.get(uid).copied().unwrap_or(usize::MAX)); timer.success(); Ok(outcomes) } /// Re-encrypt one photo's name, passphrase and hashes for a target node — /// an album or the timeline root (C# `PhotosCrypto.EncryptPhotoForTarget` /// via `PhotoTransferPayloadBuilder`, TS `preparePhotoPayload`). /// /// The passphrase is *rewrapped*, not re-encrypted from plaintext: only its /// key packet changes recipient, so the locked node key still unlocks and /// the original `NodePassphraseSignature` stays valid (which is why the /// payload carries no signature of its own — both upstreams sign one only /// for photos whose key author is anonymous). async fn photo_target_payload( &self, uid: &NodeUid, target_key: &PrivateKey, target_hash_key: &[u8], email: &str, signing_key: &PrivateKey, ) -> Result { let node = self .get_photos_node(uid) .await? .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?; let NodeKind::File { content_sha1, .. } = &node.kind else { return Err(ProtonError::invalid_operation(format!( "{uid} is not a photo file" ))); }; let sha1 = content_sha1.clone().ok_or_else(|| { ProtonError::invalid_operation(format!( "photo {uid} has no content digest; it cannot be re-encrypted for another node" )) })?; let details = self .get_link_details_ctx(&uid.volume_id, std::slice::from_ref(&uid.link_id), true) .await?; let link = &details .links .first() .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))? .link; let parent_key = self .resolve_parent_key_ctx(&uid.volume_id, link, true) .await?; let passphrase = parent_key.rewrap_message_to(&link.passphrase, target_key)?; Ok(PhotoTargetPayload { uid: uid.clone(), parent_uid: link .parent_id .clone() .map(|parent_id| NodeUid::new(uid.volume_id.clone(), parent_id)), original_name_hash: link.name_hash.clone(), name_hash: hex::encode(hmac_sha256(target_hash_key, node.name.as_bytes())), name: target_key.encrypt_and_sign(signing_key, node.name.as_bytes(), true, false)?, name_signature_email: email.to_string(), passphrase, content_hash: hex::encode(hmac_sha256( target_hash_key, sha1.to_ascii_lowercase().as_bytes(), )), related: Vec::new(), }) } /// Rename the album `album_uid` to `name`. /// /// Ported from the TypeScript SDK (`AlbumsManager.updateAlbum` + /// `AlbumsCryptoService.renameAlbum`). An album's name is encrypted to the /// photos root like any child name, so this is a rename sent to the album /// endpoint: the new name encrypted and signed by the photos share's /// membership address, its hash under the root's hash key, and the current /// hash as `OriginalHash`. The cover photo is left alone. pub(crate) async fn rename_album(&self, album_uid: &NodeUid, name: &str) -> Result<()> { let mut timer = self.telemetry.start("rename_album"); validate_node_name(name)?; let details = self .get_link_details_ctx( &album_uid.volume_id, std::slice::from_ref(&album_uid.link_id), true, ) .await?; let link = details .links .into_iter() .next() .ok_or_else(|| ProtonError::invalid_operation(format!("album {album_uid} not found")))? .link; let parent_id = link .parent_id .clone() .ok_or_else(|| ProtonError::invalid_operation("an album has a parent"))?; let parent_uid = NodeUid::new(album_uid.volume_id.clone(), parent_id); let parent_key = self.folder_node_key(&parent_uid).await?; let parent_hash_key = self .parent_hash_key_ctx(&parent_uid, &parent_key, true) .await?; let (_address_id, email, signing_key) = self.photos_membership_address().await?; let original_hash = self .original_name_hash(album_uid, &link, &parent_key, &parent_hash_key) .await?; let request = AlbumUpdateRequest { cover_link_id: None, link: Some(AlbumUpdateLink { name: parent_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?, name_hash: hex::encode(hmac_sha256(&parent_hash_key, name.as_bytes())), original_hash, name_signature_email: email, }), }; let path = format!( "photos/volumes/{}/albums/{}", album_uid.volume_id, album_uid.link_id ); let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; timer.success(); Ok(()) } /// Set the cover photo of the album `album_uid`. /// /// Mirrors the cover half of C# `PhotoOperations.UpdateAlbumAsync` /// (`PUT photos/volumes/{vid}/albums/{lid}` with only `CoverLinkID`). The /// cover must be on the album's volume; upstream refuses anything else /// before sending. pub(crate) async fn set_album_cover( &self, album_uid: &NodeUid, cover_uid: &NodeUid, ) -> Result<()> { let mut timer = self.telemetry.start("set_album_cover"); if cover_uid.volume_id != album_uid.volume_id { return Err(ProtonError::invalid_operation( "the cover photo must be on the same volume as the album", )); } let request = AlbumUpdateRequest { cover_link_id: Some(cover_uid.link_id.clone()), link: None, }; let path = format!( "photos/volumes/{}/albums/{}", album_uid.volume_id, album_uid.link_id ); let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; timer.success(); Ok(()) } /// Delete the album `album_uid`. /// /// Ported from the TypeScript SDK (`PhotosAPIService.deleteAlbum`). With /// `delete_photos` false the photos stay in the timeline, and the server /// refuses with [`ResponseCode::AlbumContainsPhotosNotInTimeline`] when the /// album holds photos that exist only in it, such as ones saved from a /// shared album; with it true those are deleted together with the album. pub(crate) async fn delete_album( &self, album_uid: &NodeUid, delete_photos: bool, ) -> Result<()> { let mut timer = self.telemetry.start("delete_album"); let path = format!( "photos/volumes/{}/albums/{}?DeleteAlbumPhotos={}", album_uid.volume_id, album_uid.link_id, u8::from(delete_photos) ); let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?; timer.success(); Ok(()) } /// Delete the album `album_uid`, first saving to the timeline the photos /// that exist only in it, so no photo is lost. /// /// Mirrors C# `PhotoOperations.DeleteAlbumAsync` with `saveToTimeline`: the /// delete is tried as is; when the server refuses with /// [`ResponseCode::AlbumContainsPhotosNotInTimeline`], the photos it names /// are saved through [`save_photos_to_timeline`](Self::save_photos_to_timeline) /// and the delete is sent once more. The first photo that cannot be saved /// fails the call, and the album stays. pub(crate) async fn delete_album_saving_photos(&self, album_uid: &NodeUid) -> Result<()> { let only_in_album = match self.delete_album(album_uid, false).await { Err(ProtonError::Api(error)) if error.code == ResponseCode::AlbumContainsPhotosNotInTimeline => { let photos = photos_only_in_album(&error, &album_uid.volume_id); if photos.is_empty() { return Err(ProtonError::Api(error)); } photos } done => return done, }; for (_, outcome) in self.save_photos_to_timeline(&only_in_album).await? { outcome?; } self.delete_album(album_uid, false).await } /// Take photos out of `album_uid`, one outcome per input photo in input /// order. The photos stay in the timeline. /// /// Ported from the TypeScript SDK (`PhotosAPIService.removePhotosFromAlbum`): /// batches of ten, and since the server reports no per-photo result, a /// failed batch fails every photo in it. pub(crate) async fn remove_photos_from_album( &self, album_uid: &NodeUid, photo_uids: &[NodeUid], ) -> Result)>> { /// Photos per `remove-multiple` request (TS `batchSize`). const REMOVE_BATCH: usize = 10; let mut timer = self.telemetry.start("remove_photos_from_album"); let path = format!( "photos/volumes/{}/albums/{}/remove-multiple", album_uid.volume_id, album_uid.link_id ); // A photo on another volume cannot be in this album's request; it fails // its own outcome without being sent, as upstream does. let mut results: Vec>> = photo_uids .iter() .map(|uid| { (uid.volume_id != album_uid.volume_id).then(|| { Err(ProtonError::invalid_operation( "the photo must be on the same volume as the album", )) }) }) .collect(); let sendable: Vec = (0..photo_uids.len()) .filter(|&index| results[index].is_none()) .collect(); for batch in sendable.chunks(REMOVE_BATCH) { let request = RemovePhotosFromAlbumRequest { link_ids: batch .iter() .map(|&index| photo_uids[index].link_id.clone()) .collect(), }; let result: Result = self.http.post(&path, &request).await; let message = result.err().map(|error| error.to_string()); for &index in batch { results[index] = Some(match &message { None => Ok(()), Some(message) => Err(ProtonError::invalid_operation(message.clone())), }); } } let outcomes: Vec<(NodeUid, Result<()>)> = photo_uids .iter() .cloned() .zip( results .into_iter() .map(|result| result.expect("every photo is either refused or sent")), ) .collect(); timer.success(); Ok(outcomes) } /// Copy photos into this account's own timeline, keeping them even after /// the originals stop being shared. One outcome per input photo. /// /// Mirrors C# `PhotoOperations.SavePhotosToTimelineAsync` (itself the JS /// `saveToTimeline`): a photo already on our photos volume is *moved* into /// the timeline root via `PUT photos/volumes/{vid}/links/transfer-multiple`, /// batched so no request carries more than ten links; a photo on another /// volume — one from a shared-with-me album — is *copied* through /// `POST volumes/{vid}/links/{lid}/copy`, one request each. Both re-encrypt /// the photo's name and passphrase for the timeline root, related photos /// included. /// /// When the server answers that the photo's group is incomplete it names /// the related photos it wants; that photo is re-queued **once** with them /// and only its second answer becomes an outcome. A photo already parented /// to the timeline root fails its own outcome, as upstream reports it. /// Outcome order therefore follows completion, not input. pub(crate) async fn save_photos_to_timeline( &self, photo_uids: &[NodeUid], ) -> Result)>> { let mut timer = self.telemetry.start("save_photos_to_timeline"); timer.attr("photo_count", photo_uids.len()); if photo_uids.is_empty() { timer.success(); return Ok(Vec::new()); } // The timeline root is the target for every photo, so a missing photos // volume fails the whole call rather than each photo in turn. if !self.ensure_photos().await? { return Err(ProtonError::invalid_operation( "this account has no photos volume to save photos to", )); } let target_uid = self .cache .lock() .await .photos_root .clone() .expect("ensure_photos populated the photos root"); let target_key = self.folder_node_key(&target_uid).await?; let target_hash_key = self .parent_hash_key_ctx(&target_uid, &target_key, true) .await?; let (_address_id, email, signing_key) = self.photos_membership_address().await?; let target = PhotoTransferTarget { uid: target_uid, kind: PhotoTransferKind::Timeline, key: target_key, hash_key: target_hash_key, email, signing_key, }; let outcomes = self.transfer_photos(&target, photo_uids).await?; timer.success(); Ok(outcomes) } /// Bring photos into `target`, the timeline root or an album, with one /// outcome per photo in completion order (C# /// `PhotoOperations.TransferPhotosToTargetAsync`). /// /// Photos on the target's volume go in batches through the target's own /// endpoint (see [`PhotoTransferKind`]); photos on another volume are /// copied one at a time. A photo whose group the server reports /// incomplete is re-queued once with the related photos it named, and only /// its second answer becomes an outcome. async fn transfer_photos( &self, target: &PhotoTransferTarget, photo_uids: &[NodeUid], ) -> Result)>> { /// Photos whose payloads are built before a round of requests goes out /// (C# `PhotoTransferLoadBatchSize`). const LOAD_BATCH: usize = 20; /// Links — main photos plus related ones — per same-volume request /// (C# `MaxLinksPerTransferBatch`). const MAX_LINKS_PER_TRANSFER: usize = 10; let mut outcomes: Vec<(NodeUid, Result<()>)> = Vec::with_capacity(photo_uids.len()); // Each entry is a photo plus the related photos the server asked for on // a previous attempt; `retried` is what keeps that a single retry. let mut queue: VecDeque<(NodeUid, Vec)> = photo_uids .iter() .cloned() .map(|uid| (uid, Vec::new())) .collect(); let mut retried: HashSet = HashSet::new(); while !queue.is_empty() { let mut same_volume: Vec = Vec::new(); let mut cross_volume: Vec = Vec::new(); for _ in 0..LOAD_BATCH { let Some((uid, extra_related)) = queue.pop_front() else { break; }; match self .transfer_photo_payload(&uid, &extra_related, target) .await { Ok(payload) if payload.uid.volume_id == target.uid.volume_id => { same_volume.push(payload) } Ok(payload) => cross_volume.push(payload), Err(error) => outcomes.push((uid, Err(error))), } } // A photo and its related photos must not be split across requests, // so the batch closes before the one that would overflow it. let mut batch: Vec = Vec::new(); let mut links = 0usize; for payload in same_volume { let count = 1 + payload.related.len(); if !batch.is_empty() && links + count > MAX_LINKS_PER_TRANSFER { self.transfer_photo_batch( target, &batch, &mut queue, &mut retried, &mut outcomes, ) .await?; batch.clear(); links = 0; } links += count; batch.push(payload); } if !batch.is_empty() { self.transfer_photo_batch(target, &batch, &mut queue, &mut retried, &mut outcomes) .await?; } for payload in cross_volume { self.copy_photo_to_target( target, &payload, &mut queue, &mut retried, &mut outcomes, ) .await; } } Ok(outcomes) } /// One photo's transfer payload: the photo itself plus its related photos, /// re-encrypted for `target` (C# /// `PhotoTransferPayloadBuilder.BuildPayloadsAsync`). /// /// `extra_related` are the ones the server demanded on a previous attempt, /// merged with what the photo itself declares. A related photo that cannot /// be built is skipped rather than failing its main photo, as upstream does. async fn transfer_photo_payload( &self, uid: &NodeUid, extra_related: &[NodeUid], target: &PhotoTransferTarget, ) -> Result { let mut payload = self .photo_target_payload( uid, &target.key, &target.hash_key, &target.email, &target.signing_key, ) .await?; // Album membership is not parentage, so only the timeline can tell here; // the album endpoint reports a photo already in it per link. if payload.parent_uid.as_ref() == Some(&target.uid) { return Err(ProtonError::invalid_operation(format!( "photo {uid} is already in the timeline" ))); } let node = self .get_photos_node(uid) .await? .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?; let mut related: Vec = node .photo .as_ref() .map(|photo| photo.related_photo_uids.clone()) .unwrap_or_default(); for extra in extra_related { if !related.contains(extra) { related.push(extra.clone()); } } for related_uid in related { if let Ok(related_payload) = self .photo_target_payload( &related_uid, &target.key, &target.hash_key, &target.email, &target.signing_key, ) .await { payload.related.push(related_payload); } } Ok(payload) } /// Post one same-volume batch — `transfer-multiple` into the timeline, /// `add-multiple` into an album — and record an outcome for each of its main /// photos (C# `TransferSameVolumeBatchAsync` / `AddToAlbumSameVolumeBatchAsync` /// + `RecordTransferOutcomes`). async fn transfer_photo_batch( &self, target: &PhotoTransferTarget, payloads: &[PhotoTargetPayload], queue: &mut VecDeque<(NodeUid, Vec)>, retried: &mut HashSet, outcomes: &mut Vec<(NodeUid, Result<()>)>, ) -> Result<()> { let response: TransferPhotosResponse = match target.kind { PhotoTransferKind::Timeline => { let mut links: Vec = Vec::new(); let mut sent: Vec<&PhotoTargetPayload> = Vec::new(); for payload in payloads { // The endpoint matches each link on the hash it currently has, // so a photo whose link carries none cannot be moved. let items: Option> = std::iter::once(payload) .chain(payload.related.iter()) .map(transfer_link_item) .collect(); match items { Some(items) => { links.extend(items); sent.push(payload); } None => outcomes.push(( payload.uid.clone(), Err(ProtonError::invalid_operation(format!( "photo {} has no name hash on its current parent; it cannot be moved", payload.uid ))), )), } } if links.is_empty() { return Ok(()); } let path = format!( "photos/volumes/{}/links/transfer-multiple", target.uid.volume_id ); let response = self .http .put( &path, &TransferPhotosRequest { parent_link_id: target.uid.link_id.clone(), links, name_signature_email: target.email.clone(), signature_email: None, }, ) .await?; return self .record_transfer_outcomes(target, response, sent, queue, retried, outcomes); } PhotoTransferKind::Album => { let album_data: Vec = payloads .iter() .flat_map(|payload| std::iter::once(payload).chain(payload.related.iter())) .map(album_entry) .collect(); let path = format!( "photos/volumes/{}/albums/{}/add-multiple", target.uid.volume_id, target.uid.link_id ); self.http .post(&path, &AddPhotosToAlbumRequest { album_data }) .await? } }; self.record_transfer_outcomes( target, response, payloads.iter().collect(), queue, retried, outcomes, ) } /// Map one same-volume batch's per-link answers onto the main photos `sent`. fn record_transfer_outcomes( &self, target: &PhotoTransferTarget, response: TransferPhotosResponse, sent: Vec<&PhotoTargetPayload>, queue: &mut VecDeque<(NodeUid, Vec)>, retried: &mut HashSet, outcomes: &mut Vec<(NodeUid, Result<()>)>, ) -> Result<()> { /// `ResponseCode::Success` as it arrives inside a per-link sub-response. const SUCCESS_CODE: i32 = 1000; // Only the links the server reports on failed; a main photo without an // entry succeeded, and a related photo's own failure is not reported // (the incomplete-group case is keyed on the main link), exactly as C# // and JS map it. let mut failures: HashMap, ProtonError)> = HashMap::new(); for outcome in response.responses { let body = &outcome.response; if body.code == SUCCESS_CODE && body.error.is_none() { continue; } let missing: Vec = body .details .as_ref() .map(|details| details.missing.as_slice()) .unwrap_or_default() .iter() .map(|link_id| NodeUid::new(target.uid.volume_id.clone(), link_id.clone())) .collect(); let error = if missing.is_empty() { ProtonError::invalid_operation( body.error .clone() .unwrap_or_else(|| format!("transfer failed with code {}", body.code)), ) } else { missing_related_photos_error(&missing) }; failures.insert(outcome.link_id.to_string(), (missing, error)); } for payload in sent { let failure = failures.remove(&payload.uid.link_id.to_string()); record_transfer_outcome(payload.uid.clone(), failure, queue, retried, outcomes); } Ok(()) } /// Copy one photo that lives on another volume into `target`, the timeline /// root or an album (C# `CopyCrossVolumePhotoAsync`, JS `copyPhoto`). async fn copy_photo_to_target( &self, target: &PhotoTransferTarget, payload: &PhotoTargetPayload, queue: &mut VecDeque<(NodeUid, Vec)>, retried: &mut HashSet, outcomes: &mut Vec<(NodeUid, Result<()>)>, ) { let path = format!( "volumes/{}/links/{}/copy", payload.uid.volume_id, payload.uid.link_id ); let request = CopyPhotoRequest { target_volume_id: target.uid.volume_id.clone(), target_parent_link_id: target.uid.link_id.clone(), name_hash: payload.name_hash.clone(), name: payload.name.clone(), name_signature_email: payload.name_signature_email.clone(), passphrase: payload.passphrase.clone(), passphrase_signature: None, signature_email: None, photos: CopyPhotoContent { content_hash: payload.content_hash.clone(), related_photos: payload .related .iter() .map(|related| CopyPhotoRelatedItem { link_id: related.uid.link_id.clone(), name_hash: related.name_hash.clone(), name: related.name.clone(), passphrase: related.passphrase.clone(), content_hash: related.content_hash.clone(), }) .collect(), }, }; 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. Err(ProtonError::Api(error)) => { let missing = missing_related_links(&error, &payload.uid.volume_id); let error = if missing.is_empty() { ProtonError::Api(error) } else { missing_related_photos_error(&missing) }; Some((missing, error)) } Err(error) => Some((Vec::new(), error)), }; record_transfer_outcome(payload.uid.clone(), failure, queue, retried, outcomes); } /// Page the albums on the account's photos volume. /// /// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`: /// `GET photos/volumes/{vid}/albums`, paged on `AnchorID`. Empty when the /// account has no photos volume (C# yields nothing rather than erroring). pub(crate) async fn enumerate_album_node_uids(&self) -> Result> { let mut timer = self.telemetry.start("enumerate_album_node_uids"); if !self.ensure_photos().await? { timer.success(); return Ok(Vec::new()); } let volume_id = self .cache .lock() .await .photos_root .clone() .expect("ensure_photos populated the photos root") .volume_id; let mut uids = Vec::new(); let mut anchor: Option = None; loop { let path = match &anchor { Some(anchor_id) => { format!("photos/volumes/{volume_id}/albums?AnchorID={anchor_id}") } None => format!("photos/volumes/{volume_id}/albums"), }; let page: AlbumListResponse = self.http.get(&path).await?; for album in &page.albums { uids.push(NodeUid::new(volume_id.clone(), album.id.clone())); } anchor = page.anchor_id.filter(|id| !id.as_str().is_empty()); if !page.more || anchor.is_none() { break; } } timer.success(); Ok(uids) } /// Page one album's photos, newest capture first. /// /// Mirrors C# `PhotosNodeOperations.EnumerateAlbumAsync`: /// `GET photos/volumes/{vid}/albums/{lid}/children?Sort=Captured&Desc=1`, /// paged on `AnchorID`. The album's own volume is used, so an album shared /// with us (on the sharer's photos volume) enumerates the same way. pub(crate) async fn enumerate_album(&self, album_uid: &NodeUid) -> Result> { let mut timer = self.telemetry.start("enumerate_album"); let mut items = Vec::new(); let mut anchor: Option = None; loop { let base = format!( "photos/volumes/{}/albums/{}/children?Sort=Captured&Desc=1", album_uid.volume_id, album_uid.link_id ); let path = match &anchor { Some(anchor_id) => format!("{base}&AnchorID={anchor_id}"), None => base, }; let page: AlbumItemListResponse = self.http.get(&path).await?; for photo in &page.photos { items.push(AlbumItem { uid: NodeUid::new(album_uid.volume_id.clone(), photo.id.clone()), capture_time: photo.capture_time, }); } anchor = page.anchor_id.filter(|id| !id.as_str().is_empty()); if !page.more || anchor.is_none() { break; } } timer.success(); Ok(items) } /// Page the albums other users share with us. /// /// Mirrors C# `PhotoOperations.EnumerateSharedWithMeAlbumUidsAsync`: /// `GET photos/albums/shared-with-me`, paged on `AnchorID`. Unlike /// `v2/sharedwithme` each row carries its own `VolumeID` — a shared album /// lives on the sharer's photos volume — and the listing is account-wide, so /// it works even when *we* have no photos volume. pub(crate) async fn enumerate_shared_with_me_album_uids(&self) -> Result> { let mut timer = self.telemetry.start("enumerate_shared_with_me_album_uids"); let mut uids = Vec::new(); let mut anchor: Option = None; loop { let path = match &anchor { Some(anchor_id) => format!("photos/albums/shared-with-me?AnchorID={anchor_id}"), None => "photos/albums/shared-with-me".to_string(), }; let page: SharedAlbumsResponse = self.http.get(&path).await?; for album in &page.albums { uids.push(NodeUid::new(album.volume_id.clone(), album.link_id.clone())); } anchor = page.anchor_id.filter(|id| !id.as_str().is_empty()); if !page.more || anchor.is_none() { break; } } timer.success(); Ok(uids) } /// The photos and albums shared *with* us, from `v2/sharedwithme`. /// /// The same listing [`enumerate_shared_with_me_node_uids`](Self::enumerate_shared_with_me_node_uids) /// pages, kept to the target types the Photos client owns (C# /// `ProtonPhotosClient.ShareTargetTypes` = Photo + Album). Exposed through /// [`ProtonPhotosClient`](crate::ProtonPhotosClient). pub(crate) async fn enumerate_photos_shared_with_me_node_uids(&self) -> Result> { let mut timer = self .telemetry .start("enumerate_photos_shared_with_me_node_uids"); let mut uids = Vec::new(); let mut anchor: Option = None; loop { let path = match &anchor { Some(anchor_id) => format!("v2/sharedwithme?AnchorID={anchor_id}"), None => "v2/sharedwithme".to_string(), }; let page: SharedWithMeResponse = self.http.get(&path).await?; for link in &page.links { if ShareTargetType::from_raw(link.share_target_type) .is_some_and(ShareTargetType::is_photos_item) { uids.push(NodeUid::new(link.volume_id.clone(), link.link_id.clone())); } } anchor = page.anchor_id.filter(|id| !id.is_empty()); if !page.more || anchor.is_none() { break; } } timer.success(); Ok(uids) } /// Apply tag additions/removals to photos. /// /// Mirrors C# `PhotoOperations.UpdatePhotosAsync`: one outcome per input /// update, in input order, so a photo that fails does not stop the others — /// the outer `Err` is reserved for what makes the whole call impossible. /// `Favorite` is special-cased exactly as upstream: it is *set* through /// `POST photos/volumes/{vid}/links/{lid}/favorite` and only for photos on /// our own photos volume, while every other tag (and every removal, /// `Favorite` included) goes through the tags endpoints. /// /// Favoriting a photo on someone else's volume needs the photo — and its /// related photos — re-encrypted for our timeline root and sent as the /// favorite request body (upstream `03b1cb7f`, `PhotoTransferPayloadBuilder`). /// That is not ported, so such an update fails with an explicit error rather /// than silently doing nothing. pub(crate) async fn update_photos( &self, updates: &[PhotoTagsUpdate], ) -> Result)>> { let mut timer = self.telemetry.start("update_photos"); timer.attr("photo_count", updates.len()); if updates.is_empty() { timer.success(); return Ok(Vec::new()); } // Resolved once: the volume a bodyless favorite is valid on. `None` when // the account has no photos volume, which makes every favorite fail below. let own_photos_volume = if self.ensure_photos().await? { self.cache .lock() .await .photos_root .clone() .map(|root| root.volume_id) } else { None }; let mut outcomes = Vec::with_capacity(updates.len()); for update in updates { let outcome = self .apply_tag_update(update, own_photos_volume.as_ref()) .await; outcomes.push((update.node_uid.clone(), outcome)); } timer.success(); Ok(outcomes) } /// [`update_photos`](Self::update_photos) as a stream: one outcome per /// photo, yielded as that photo's update finishes instead of after all of /// them (C# `PhotoOperations.UpdatePhotosAsync`'s /// `IAsyncEnumerable`). The single `Err` item is the /// photos-root resolution that makes the whole call impossible; it ends the /// stream. pub(crate) fn update_photos_streaming<'a>( &'a self, updates: &[PhotoTagsUpdate], ) -> impl futures::Stream)>> + 'a { let updates: VecDeque = updates.iter().cloned().collect(); // `None` until the first item resolves the photos volume once, exactly // as the collecting variant does before its loop. let own_photos_volume: Option> = None; stream::unfold( (updates, own_photos_volume), move |(mut updates, own_photos_volume): (VecDeque<_>, Option>)| async move { let update = updates.pop_front()?; let own_photos_volume = match own_photos_volume { Some(resolved) => resolved, None => match self.ensure_photos().await { Ok(true) => self .cache .lock() .await .photos_root .clone() .map(|root| root.volume_id), Ok(false) => None, Err(e) => return Some((Err(e), (VecDeque::new(), Some(None)))), }, }; let outcome = self .apply_tag_update(&update, own_photos_volume.as_ref()) .await; Some(( Ok((update.node_uid.clone(), outcome)), (updates, Some(own_photos_volume)), )) }, ) } /// One photo's tag update (C# `PhotoOperations.ApplyTagUpdateAsync`). async fn apply_tag_update( &self, update: &PhotoTagsUpdate, own_photos_volume: Option<&VolumeId>, ) -> Result<()> { let volume_id = &update.node_uid.volume_id; let link_id = &update.node_uid.link_id; if update.tags_to_add.contains(&PhotoTag::Favorite) { match own_photos_volume { Some(own) if own == volume_id => { let path = format!("photos/volumes/{volume_id}/links/{link_id}/favorite"); // An empty object, not `()`: serde renders the unit type as // `null`, which the API rejects. let _: proton_sdk::api::ApiResponse = self.http.post(&path, &serde_json::json!({})).await?; } _ => { return Err(ProtonError::invalid_operation( "favoriting a photo that is not on this account's photos volume is not supported yet", )); } } } // `Favorite` has its own endpoint and is never sent as a plain tag. let tags_to_add: Vec = update .tags_to_add .iter() .filter(|&&tag| tag != PhotoTag::Favorite) .map(|&tag| tag as i32) .collect(); if !tags_to_add.is_empty() { let path = format!("photos/volumes/{volume_id}/links/{link_id}/tags"); let _: proton_sdk::api::ApiResponse = self .http .post(&path, &PhotoTagsRequest { tags: tags_to_add }) .await?; } if !update.tags_to_remove.is_empty() { let tags_to_remove: Vec = update .tags_to_remove .iter() .map(|&tag| tag as i32) .collect(); let path = format!("photos/volumes/{volume_id}/links/{link_id}/tags"); let _: proton_sdk::api::ApiResponse = self .http .delete_with_body( &path, &PhotoTagsRequest { tags: tags_to_remove, }, ) .await?; } Ok(()) } /// 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 /// for main-volume files. pub(crate) async fn download_photo_to( &self, uid: &NodeUid, output: &mut W, ) -> Result<()> { let details = self .get_link_details_ctx(&uid.volume_id, std::slice::from_ref(&uid.link_id), true) .await?; let detail = details .links .into_iter() .next() .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?; let link = detail.link; let file = detail .file .or(detail.photo.map(|photo| photo.file)) .ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} is not a file")))?; let content_key_packet_b64 = file.content_key_packet.ok_or_else(|| { ProtonError::invalid_operation("photo is missing its content key packet") })?; let content_key_packet = BASE64.decode(content_key_packet_b64.trim()).map_err(|e| { ProtonError::invalid_operation(format!("decode content key packet: {e}")) })?; let revision_id = file .active_revision .map(|r| r.id) .ok_or_else(|| ProtonError::invalid_operation("photo has no active revision"))?; let parent_key = self .resolve_parent_key_ctx(&uid.volume_id, &link, true) .await?; let node_key = decrypt_link(&parent_key, &link)?.node_key; let content_key = node_key.decrypt_content_key(&content_key_packet)?; let (revision, blocks) = self .fetch_revision_blocks(&uid.volume_id, &uid.link_id, &revision_id) .await?; let mut manifest = Vec::new(); let mut thumbnails: Vec<&ThumbnailDto> = revision.thumbnails.iter().collect(); thumbnails.sort_by_key(|t| t.thumbnail_type); for thumb in thumbnails { if let Some(hash_b64) = &thumb.hash_digest { let digest = BASE64.decode(hash_b64.trim()).map_err(|e| { ProtonError::invalid_operation(format!("decode thumbnail digest: {e}")) })?; manifest.extend_from_slice(&digest); } } let block_count = blocks.len(); let targets = Arc::new(BlockTargets::new( self.revision_transport(), uid.clone(), revision_id.clone(), blocks, block_count, )); self.write_content_blocks(targets, block_count, &content_key, &mut manifest, output) .await?; verify_manifest(&self.account, &revision, &node_key, &manifest).await; Ok(()) } /// Decrypted node key for a folder, decrypting (and caching) ancestors as /// needed. async fn folder_node_key(&self, uid: &NodeUid) -> Result { if let Some(key) = self.cache.lock().await.folder_keys.get(uid) { return Ok(key.clone()); } let details = self .get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id)) .await?; let link = &details .links .first() .ok_or_else(|| ProtonError::invalid_operation(format!("folder {uid} not found")))? .link; let parent_key = self.resolve_parent_key(&uid.volume_id, link).await?; let decrypted = decrypt_link(&parent_key, link)?; self.cache .lock() .await .folder_keys .put(uid.clone(), decrypted.node_key.clone()); Ok(decrypted.node_key) } /// Resolve the key used to decrypt `link` (i.e. its parent's key). /// /// Walks the ancestor chain upward until it reaches a cached folder key or /// the volume root (whose parent key is the share key), then decrypts back /// down, caching each ancestor's node key. Iterative to avoid async /// recursion. Mirrors `GetEntryPointKeyOrThrowAsync`. async fn resolve_parent_key(&self, volume_id: &VolumeId, link: &LinkDto) -> Result { self.resolve_parent_key_ctx(volume_id, link, false).await } /// As [`resolve_parent_key`](Self::resolve_parent_key), but for `for_photos` /// it routes ancestor lookups to the photos endpoint and falls back to the /// photos share key (not My Files) at the volume root. async fn resolve_parent_key_ctx( &self, volume_id: &VolumeId, link: &LinkDto, for_photos: bool, ) -> Result { // An already-decrypted parent key needs neither a walk nor a queue. if let Some(parent_id) = &link.parent_id { let uid = NodeUid::new(volume_id.clone(), parent_id.clone()); if let Some(key) = self.cache.lock().await.folder_keys.get(&uid) { return Ok(key.clone()); } } // Siblings resolved concurrently all want this same key, and getting it // means walking and decrypting the whole ancestor chain — so one of them // does it and the rest wait. A root's share key and the node key its // children want are different keys of the same link, so they must not // join each other's load (`ParentKeyId` tells them apart). let target = (volume_id.clone(), ParentKeyId::of(link), for_photos); let client = self.clone(); let volume_id = volume_id.clone(); let link = link.clone(); self.parent_key_loads .run(target, async move { client .resolve_parent_key_walk(&volume_id, &link, for_photos) .await }) .await } /// The ancestor walk behind [`resolve_parent_key_ctx`]: climb to the nearest /// decrypted key (or the root share key) and decrypt downward from there, /// caching every folder key on the way. async fn resolve_parent_key_walk( &self, volume_id: &VolumeId, link: &LinkDto, for_photos: bool, ) -> Result { let mut ancestry: Vec = Vec::new(); let mut current = link.parent_id.clone(); let mut base_key: Option = None; while let Some(parent_id) = current.take() { let uid = NodeUid::new(volume_id.clone(), parent_id.clone()); if let Some(key) = self.cache.lock().await.folder_keys.get(&uid) { base_key = Some(key.clone()); break; } // Walk as far up as the entity cache can take us for free, then // fetch that whole run of ancestors in one request instead of one // request per level. let run = self.cached_ancestor_run(volume_id, &parent_id).await; let details = self .get_link_details_ctx(volume_id, &run, for_photos) .await?; let mut by_id: HashMap = details .links .into_iter() .map(|details| (details.link.id.clone(), details.link)) .collect(); for ancestor_id in &run { let ancestor = by_id.remove(ancestor_id).ok_or_else(|| { ProtonError::invalid_operation(format!( "ancestor {} not found", NodeUid::new(volume_id.clone(), ancestor_id.clone()) )) })?; current = ancestor.parent_id.clone(); ancestry.push(ancestor); } } // Start from the resolved base (cached ancestor key, or the share key // for a root) and decrypt downward toward `link`'s parent. let mut key = match base_key { Some(key) => key, None if for_photos => self.photos_share_key().await?, None => { // The topmost ancestor (or `link` itself when it is parentless) // is a volume root. Usually that's My Files, but device roots // live on the same volume with their *own* share key, so pick // the share by matching the root link id rather than assuming // My Files (which would fail to decrypt: "missing key"). let root_id = ancestry.last().map_or(&link.id, |a| &a.id); self.root_link_share_key(volume_id, root_id).await? } }; for ancestor in ancestry.iter().rev() { let decrypted = decrypt_link(&key, ancestor)?; let uid = NodeUid::new(volume_id.clone(), ancestor.id.clone()); self.cache .lock() .await .folder_keys .put(uid, decrypted.node_key.clone()); key = decrypted.node_key; } Ok(key) } /// The run of ancestors starting at `first` that the entity cache can name /// without touching the network, so they can be fetched in one batch. /// /// The chain is only walked for *ids* — the cached node carries its /// `parent_uid`, never the encrypted material the key walk needs. It stops /// at the first ancestor the cache does not know, at a root, or at one whose /// folder key is already decrypted (the caller stops there anyway), so the /// returned run never contains an ancestor that would not have been fetched. /// With a cold entity cache it degenerates to `[first]` — exactly the /// one-level-at-a-time walk this replaced. async fn cached_ancestor_run(&self, volume_id: &VolumeId, first: &LinkId) -> Vec { let mut run = vec![first.clone()]; let mut current = first.clone(); while run.len() < MAX_BATCH_COUNT { let uid = NodeUid::new(volume_id.clone(), current.clone()); let Ok(Some(info)) = self.entities.try_get_node(&uid).await else { break; }; let Some(parent_uid) = info.node.parent_uid else { break; }; if self .cache .lock() .await .folder_keys .get(&parent_uid) .is_some() { break; } current = parent_uid.link_id; run.push(current.clone()); } run } async fn get_link_details( &self, volume_id: &VolumeId, link_ids: &[LinkId], ) -> Result { self.get_link_details_ctx(volume_id, link_ids, false).await } /// Link details, routed to the photos volume endpoint when `for_photos`. /// Mirrors C# `IPhotosApiClient.GetDetailsAsync` vs `ILinksApiClient` /// (`photos/volumes/{vid}/links` vs `v2/volumes/{vid}/links`). async fn get_link_details_ctx( &self, volume_id: &VolumeId, link_ids: &[LinkId], for_photos: bool, ) -> Result { let path = if for_photos { format!("photos/volumes/{volume_id}/links") } else { format!("v2/volumes/{volume_id}/links") }; let body = LinkDetailsRequest { link_ids }; self.http.post_query(&path, &body).await } async fn build_node( &self, volume_id: &VolumeId, details: &LinkDetailsDto, parent_key: &PrivateKey, detail: NodeDetail, ) -> Result { self.build_node_ctx(volume_id, details, parent_key, false, detail) .await } async fn build_node_ctx( &self, volume_id: &VolumeId, details: &LinkDetailsDto, parent_key: &PrivateKey, for_photos: bool, detail: NodeDetail, ) -> Result { let link = &details.link; // A file's node key is only needed to read its contents (content key, // extended attributes), so a `Light` build of a file skips unlocking it // — that unlock is an S2K derivation, and it dominates a walk over many // nodes. A folder's key is always unlocked: it is what its children are // decrypted with, so the walk cannot proceed without it. let want_node_key = detail == NodeDetail::Full || !matches!(link.parsed_type(), LinkType::File); let (name, node_key, mut verification) = if want_node_key { let (decrypted, verification) = decrypt_link_verified(&self.account, parent_key, link).await?; (decrypted.name, Some(decrypted.node_key), verification) } else { let (name, verification) = decrypt_link_name_verified(&self.account, parent_key, link).await?; (name, None, verification) }; let uid = NodeUid::new(volume_id.clone(), link.id.clone()); let parent_uid = link .parent_id .clone() .map(|pid| NodeUid::new(volume_id.clone(), pid)); // Album- and photo-only metadata, layered onto the folder/file node the // way C# layers `AlbumNode : FolderNode` / `PhotoNode : FileNode`. let mut album = None; let mut photo = None; let kind = match link.parsed_type() { LinkType::Folder | LinkType::Album => { let node_key = node_key.as_ref().ok_or_else(|| { ProtonError::invalid_operation("folder node built without its key") })?; // Cache the folder's node key for later child enumeration. self.cache .lock() .await .folder_keys .put(uid.clone(), node_key.clone()); album = details.album.as_ref().map(|album| AlbumProperties { photo_count: album.photo_count, cover_photo_uid: album .cover_link_id .clone() .map(|link_id| NodeUid::new(volume_id.clone(), link_id)), last_activity_time: (album.last_activity_time != 0) .then_some(album.last_activity_time), }); NodeKind::Folder } LinkType::File => { let file = details.file_properties().ok_or_else(|| { ProtonError::invalid_operation("file node missing file properties") })?; // Decrypt + verify the content key (`ContentKeyPacketSignature`). // Best-effort: a decode/decrypt failure leaves the status absent // rather than failing the whole node. if let Some(node_key) = node_key.as_ref() && let Some(packet_b64) = file.content_key_packet.as_deref() { match BASE64.decode(packet_b64) { Ok(packet) => { match decrypt_content_key_verified( &self.account, node_key, link.signature_email.as_deref(), &packet, file.content_key_signature.as_deref(), ) .await { Ok((_content_key, status)) => { verification.content_key = Some(status); } Err(e) => { tracing::warn!(link_id = %link.id, error = %e, "failed to decrypt content key"); } } } Err(e) => { tracing::warn!(link_id = %link.id, error = %e, "failed to decode content key packet"); } } } // Decrypt + verify the active revision's extended attributes // (best-effort, mirroring C# `DtoToMetadataConverter`): a failure // leaves the claimed metadata absent rather than failing the node. let mut claimed_size = None; let mut claimed_modification_time = None; let mut content_sha1 = None; let mut location = None; if let Some(node_key) = node_key.as_ref() && let Some(rev) = file.active_revision.as_ref() && let Some(xattr) = rev.extended_attributes.as_deref() { match decrypt_extended_attributes_verified( &self.account, node_key, rev.signature_email.as_deref(), xattr, ) .await { Ok((attrs, status)) => { verification.extended_attributes = Some(status); location = attrs .location .and_then(|l| PhotoLocation::from_claimed(l.latitude, l.longitude)); if let Some(common) = attrs.common { claimed_size = common.size; claimed_modification_time = common.modification_time; content_sha1 = common.digests.and_then(|d| d.sha1); } } Err(e) => { tracing::warn!(link_id = %link.id, error = %e, "failed to decrypt extended attributes"); } } } photo = details.photo.as_ref().map(|photo| PhotoProperties { capture_time: photo.capture_time, content_hash: photo.content_hash.clone(), main_photo_uid: photo .main_photo_link_id .clone() .map(|link_id| NodeUid::new(volume_id.clone(), link_id)), related_photo_uids: photo .related_photo_link_ids .iter() .map(|link_id| NodeUid::new(volume_id.clone(), link_id.clone())) .collect(), // An unknown tag discriminant is dropped: the server may add // tags this SDK predates, and that must not fail the node. tags: photo .tags .iter() .filter_map(|&t| PhotoTag::from_raw(t)) .collect(), album_uids: photo .album_inclusions .iter() .map(|album| NodeUid::new(volume_id.clone(), album.album_link_id.clone())) .collect(), location, }); NodeKind::File { media_type: file.media_type.clone(), total_size_on_storage: file.total_size_on_storage, active_revision_state: file .active_revision .as_ref() .map(|rev| RevisionState::from_raw(rev.state)), active_revision_id: file.active_revision.as_ref().map(|rev| rev.id.clone()), claimed_size, claimed_modification_time, content_sha1, } } LinkType::Unknown => { return Err(ProtonError::invalid_operation(format!( "unsupported link type {}", link.link_type ))); } }; // C# `DtoToMetadataConverter.BuildMembershipInfoAsync`: everything on // our own volumes is ours to administer; elsewhere the role is the // membership's, and a node below the shared root inherits. let direct_role = if self.is_own_volume(volume_id).await { MemberRole::Admin } else { MemberRole::from_permissions(details.membership.as_ref().map(|m| m.permissions)) }; let membership = match &details.membership { Some(dto) => Some(self.verified_membership(dto).await), None => None, }; // C# `DtoToMetadataConverter`: the `Sharing` block's presence marks the // node as shared; a `ShareURLID` inside it marks it as publicly shared. let node = Node { uid: uid.clone(), parent_uid, kind, name, creation_time: link.creation_time, modification_time: link.modification_time, trashed: link.is_trashed(), is_shared: details.sharing.is_some(), is_shared_publicly: details .sharing .as_ref() .is_some_and(|sharing| sharing.share_url_id.is_some()), signature_email: link.signature_email.clone(), // Present only when the node is shared *with* us; it is what says // whether we may write to it. membership, direct_role: Some(direct_role), share_id: details .sharing .as_ref() .map(|sharing| sharing.share_id.clone()), photo, album, verification, }; // Cache the node with its server-provided name hash + membership share so // later move/rename can supply `OriginalHash` without re-decrypting the // name (C# `DtoToMetadataConverter` → `SetNodeAsync`). Best-effort: a // cache failure must not fail the read. if let Some(name_hash) = &link.name_hash { let membership = if for_photos { self.photos_share_id().await } else { self.my_files_share_id().await }; if let Err(e) = self .entities .set_node(&uid, &node, membership.as_ref(), name_hash) .await { tracing::warn!(link_id = %link.id, error = %e, "failed to cache node metadata"); } } Ok(node) } /// The resolved My Files share id, if My Files has been resolved. async fn my_files_share_id(&self) -> Option { self.cache .lock() .await .my_files_share .as_ref() .map(|share| share.share_id.clone()) } /// A node's current name hash digest from the entity cache or the link DTO, /// without decrypting the name. `None` when neither source carries it. /// Mirrors the C# move/rename path reading `CachedNodeInfo.NameHashDigest`. async fn cached_original_name_hash( &self, uid: &NodeUid, link: &LinkDto, ) -> Result> { if let Some(info) = self.entities.try_get_node(uid).await? && !info.name_hash_digest.is_empty() { return Ok(Some(info.name_hash_digest)); } Ok(link.name_hash.clone()) } /// A node's current name hash digest, falling back to recomputing it from /// the decrypted name (HMAC-SHA256 under the parent's hash key) when neither /// the entity cache nor the link DTO supplies it. async fn original_name_hash( &self, uid: &NodeUid, link: &LinkDto, parent_key: &PrivateKey, parent_hash_key: &[u8], ) -> Result { if let Some(hash) = self.cached_original_name_hash(uid, link).await? { return Ok(hash); } let current_name = parent_key.decrypt_armored_message(&link.name)?; Ok(hex::encode(hmac_sha256(parent_hash_key, ¤t_name))) } /// Whether `volume_id` is our main or photos volume (C# /// `VolumeOperations.IsOwnVolumeAsync`). Both ids are cached after the first /// lookup; a lookup that fails counts as "not ours", which only costs a node /// its [`MemberRole::Admin`] and never fails the read. async fn is_own_volume(&self, volume_id: &VolumeId) -> bool { if self .main_volume_id() .await .is_ok_and(|main| &main == volume_id) { return true; } if !self.ensure_photos().await.unwrap_or(false) { return false; } self.cache .lock() .await .photos_root .as_ref() .is_some_and(|root| &root.volume_id == volume_id) } /// Our membership in a share, with the inviter's signature over our share /// passphrase key packet checked against the claimed inviter's keys (C# /// `BuildMembershipInfoAsync` + `NodeCrypto.VerifyMembershipInviter`). The /// check is metadata: the outcome is recorded, never raised. async fn verified_membership(&self, dto: &ShareMembershipSummaryDto) -> ShareMembership { let mut membership = share_membership_from_dto(dto); let status = match ( &dto.member_share_passphrase_key_packet, &dto.inviter_share_passphrase_key_packet_signature, ) { (Some(packet), Some(signature)) => match BASE64.decode(packet) { Ok(packet) => { let email = dto.inviter_email.as_deref().unwrap_or(""); let keys = if email.is_empty() { Vec::new() } else { self.account.public_keys(email).await }; verify_detached_with_context( signature, &packet, &VerificationKeyRing::from_public_keys(&keys), SHARING_INVITER_CONTEXT, ) } Err(_) => VerificationStatus::Failed, }, // The invitation carries nothing to verify. _ => VerificationStatus::NotSigned, }; membership.inviter_verification = Some(status); membership } } /// Verify a downloaded revision's content manifest signature, returning the /// non-fatal [`VerificationStatus`]. /// /// Mirrors C# `RevisionReader.VerifyManifestAsync`: an anonymous (empty /// `SignatureEmail`) signer is the node key itself; a named signer is an /// address key resolved via `core/v4/keys/all` (`account.public_keys`). A /// failed or unverifiable signature is logged but never fatal, matching the /// metadata-only verification policy. async fn verify_manifest( account: &AccountClient, revision: &RevisionDto, node_key: &PrivateKey, manifest: &[u8], ) -> VerificationStatus { let Some(signature) = &revision.manifest_signature else { tracing::debug!("revision has no manifest signature; skipping integrity check"); return VerificationStatus::NotSigned; }; let email = revision.signature_email.as_deref().unwrap_or(""); let ring = if email.is_empty() { VerificationKeyRing::from_private(node_key) } else { VerificationKeyRing::from_public_keys(&account.public_keys(email).await) }; let status = verify_detached(signature, manifest, &ring); match status { VerificationStatus::Ok => tracing::debug!("manifest signature verified"), VerificationStatus::NoVerifier => { tracing::warn!(email, "no verification key for manifest signature") } VerificationStatus::Failed => { tracing::warn!(email, "manifest signature verification failed") } VerificationStatus::NotSigned => {} } status } /// Fill `buf` from `reader`, returning the number of bytes read. Reads /// repeatedly until `buf` is full or EOF, so a short `read` mid-stream never /// splits a content block early; a return of `0` means clean EOF. fn read_full_block(reader: &mut R, buf: &mut [u8]) -> Result { let mut filled = 0; while filled < buf.len() { match reader.read(&mut buf[filled..]) { Ok(0) => break, Ok(n) => filled += n, Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, Err(e) => return Err(ProtonError::invalid_operation(format!("read block: {e}"))), } } Ok(filled) } /// Whether a listed revision is one a history view should show. /// /// Only active (1) and superseded (2) qualify — TS /// `NodeAPIService.getRevisions` filters the same pair. Drafts (0) are /// in-flight uploads with no readable content, and an absent state cannot be /// vouched for. /// /// This deliberately inspects the **wire** value rather than mapping through /// [`RevisionState::from_raw`] first: that mapping folds draft and unknown into /// `Active`, which is correct for a link's *active* revision (where the state is /// often omitted) but would silently promote a draft here. fn is_listable_revision_state(state: Option) -> bool { matches!(state, Some(1) | Some(2)) } /// Whether the user still has an active regular (My Files) volume. Mirrors the /// web client's check before it creates a replacement for a locked one: `Type` /// 1 is regular, `State` 1 is active. A volume without a `Type` counts as /// regular, since older responses omit it and Photos volumes always carry it. fn has_active_regular_volume(response: &VolumeListResponse) -> bool { response .volumes .iter() .any(|v| v.state == 1 && v.volume_type.unwrap_or(1) == 1) } /// Whether an API error means the requested entity simply is not there. /// /// Both `DoesNotExist` in the envelope and a bare HTTP 404 occur in the wild for /// the same condition, depending on the endpoint. fn is_not_found(error: &ProtonError) -> bool { matches!( error, ProtonError::Api(e) if e.code == proton_sdk::api::ResponseCode::DoesNotExist || e.http_status == 404 ) } /// base64-encode bytes with the standard alphabet. fn base64_encode(bytes: impl AsRef<[u8]>) -> String { BASE64.encode(bytes) } /// The alphabet for generated public-link passwords (JS `generatePublicLinkPassword`). const PUBLIC_LINK_PASSWORD_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; /// Generated public-link password length (JS `PUBLIC_LINK_GENERATED_PASSWORD_LENGTH`). const PUBLIC_LINK_PASSWORD_LEN: usize = 12; /// Generate a random 12-character alphanumeric public-link password. fn generate_public_link_password() -> String { let mut raw = [0u8; PUBLIC_LINK_PASSWORD_LEN]; getrandom::fill(&mut raw).expect("system RNG"); raw.iter() .map(|b| { PUBLIC_LINK_PASSWORD_CHARSET[*b as usize % PUBLIC_LINK_PASSWORD_CHARSET.len()] as char }) .collect() } /// Generate a fresh 16-byte key salt (Proton `generateKeySalt`). fn generate_key_salt() -> [u8; 16] { let mut salt = [0u8; 16]; getrandom::fill(&mut salt).expect("system RNG"); salt } /// Build a [`ShareInvitation`] model from its wire DTO. fn invitation_from_dto(share_id: &ShareId, dto: ShareInvitationDto) -> ShareInvitation { ShareInvitation { share_id: share_id.clone(), invitation_id: dto.invitation_id, invitee_email: dto.invitee_email, inviter_email: dto.inviter_email, role: MemberRole::from_permissions(dto.permissions), invitation_time: dto.create_time, } } fn external_invitation_from_dto( share_id: &ShareId, dto: ExternalInvitationResponseDto, ) -> ExternalInvitation { ExternalInvitation { share_id: share_id.clone(), invitation_id: dto.external_invitation_id, invitee_email: dto.invitee_email, inviter_email: dto.inviter_email, role: MemberRole::from_permissions(dto.permissions), invitation_time: dto.create_time, state: ExternalInvitationState::from_raw(dto.state), } } /// Split a public-link URL (`https://drive.proton.me/urls/{token}#{password}`) /// into its token and password. Mirrors JS `getTokenAndPasswordFromUrl`: the /// token is the last path segment, the password the fragment after `#`. /// Split a public share URL into its token and secret fragment. /// /// The format is `https://drive.proton.me/urls/{token}#{password}` /// (TS `getTokenAndPasswordFromUrl`). Both halves are required: a URL with no /// fragment carries no secret and opens nothing. /// /// The token is taken as the segment *after* `/urls/` rather than as the last /// path segment, so a URL with an empty token (`…/urls/#password`) is rejected /// instead of silently yielding the container segment `"urls"` as the token. pub(crate) fn parse_public_link_url(url: &str) -> Result<(String, String)> { const CONTAINER: &str = "/urls/"; let invalid = || ProtonError::invalid_operation("invalid public link url"); let (before_hash, password) = url.split_once('#').ok_or_else(invalid)?; let (_, after_container) = before_hash.rsplit_once(CONTAINER).ok_or_else(invalid)?; let token = after_container.trim_end_matches('/'); if token.is_empty() || token.contains('/') || password.is_empty() { return Err(invalid()); } Ok((token.to_string(), password.to_string())) } /// The current Unix epoch, in seconds. fn now_epoch_seconds() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } /// Format a Unix epoch (seconds, UTC) as an ISO-8601 `YYYY-MM-DDTHH:MM:SSZ` /// string for the `ExtendedAttributes.ModificationTime` field. C# writes the /// round-trip ("O") format; this drops the fractional-second component, which /// the consuming parser (`DateTimeOffset.TryParse`, RoundtripKind) tolerates. /// Uses the civil-from-days algorithm (Howard Hinnant) so no date dependency is /// needed. fn epoch_to_iso8601(secs: i64) -> String { let days = secs.div_euclid(86_400); let tod = secs.rem_euclid(86_400); let (hh, mm, ss) = (tod / 3600, (tod % 3600) / 60, tod % 60); // days since 1970-01-01 -> civil (year, month, day) let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = z - era * 146_097; // [0, 146096] let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] let mp = (5 * doy + 2) / 153; // [0, 11] let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] let y = if m <= 2 { y + 1 } else { y }; format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") } /// Split a caller-supplied path into its non-empty segments. /// /// Leading, trailing, and repeated separators are ignored, so `/a/b/`, `a/b`, /// and `//a//b` all yield `["a", "b"]`, and `""` / `"/"` yield nothing (the /// root itself). fn path_segments(path: &str) -> impl Iterator { path.split('/').filter(|segment| !segment.is_empty()) } /// Join node names into an absolute path. The empty chain is the root, `/`. fn join_node_path<'a>(names: impl Iterator) -> String { let mut path = String::new(); for name in names { path.push('/'); path.push_str(name); } if path.is_empty() { path.push('/'); } path } /// Which trash-family batch endpoint a node action hits. C# has one async /// iterator per action; the bodies differ only in route and verb. #[derive(Debug, Clone, Copy)] enum NodeAction { Trash, Restore, Delete, } impl NodeAction { fn path(self, volume_id: &VolumeId) -> String { match self { Self::Trash => format!("v2/volumes/{volume_id}/trash_multiple"), Self::Restore => format!("v2/volumes/{volume_id}/trash/restore_multiple"), Self::Delete => format!("v2/volumes/{volume_id}/trash/delete_multiple"), } } } /// Group node uids by volume, preserving order, so each volume's links are /// batched into a single request family (C# groups by `VolumeId`). fn group_by_volume(uids: &[NodeUid]) -> Vec<(VolumeId, Vec)> { let mut groups: Vec<(VolumeId, Vec)> = Vec::new(); for uid in uids { match groups.iter_mut().find(|(vid, _)| *vid == uid.volume_id) { Some((_, link_ids)) => link_ids.push(uid.link_id.clone()), None => groups.push((uid.volume_id.clone(), vec![uid.link_id.clone()])), } } groups } /// The key a link is decrypted with, named by where it comes from. /// /// A link with a parent is decrypted with that folder's node key. A parentless /// link — My Files, a device root, a share root — is decrypted with the share /// key of *its own* root, so two parentless links never share a key. Keying on /// `parent_id` alone puts every root of a volume on `None`, which handed the /// first root's share key to the rest ("missing key"; proton-sdk-rs#2). And /// keying a root on its own id, as a bare link id, collides with its children, /// whose parent key is that same folder's *node* key rather than its share key. #[derive(Debug, Clone, PartialEq, Eq, Hash)] enum ParentKeyId { /// The node key of this folder: the parent of a non-root link. Parent(LinkId), /// The share key of this root: what a parentless link decrypts against. Root(LinkId), } impl ParentKeyId { fn of(link: &LinkDto) -> Self { match &link.parent_id { Some(parent_id) => Self::Parent(parent_id.clone()), None => Self::Root(link.id.clone()), } } } /// Resolve the parent key of every link in a batch, once per distinct /// [`ParentKeyId`], in link order. Look a link's key up with /// [`ParentKeyId::of`]. /// /// Serial on purpose: siblings share a parent, and resolving concurrently would /// walk and decrypt the same ancestor chain many times over. A key that fails to /// resolve is logged and left out, so only the links that need it are skipped. /// /// Takes the batch as a slice rather than an iterator of links: a `map` closure /// projecting each link, held across the awaits, gives the future a /// higher-ranked lifetime that `tokio::spawn` rejects in callers /// (`tests/spawnable.rs` pins it). async fn resolve_parent_keys<'a, K, F, Fut>( links: &'a [LinkDetailsDto], mut resolve: F, ) -> HashMap where F: FnMut(&'a LinkDto) -> Fut, Fut: Future>, { let mut keys = HashMap::new(); for details in links { let link = &details.link; let id = ParentKeyId::of(link); if keys.contains_key(&id) { continue; } match resolve(link).await { Ok(key) => { keys.insert(id, key); } Err(e) => { tracing::warn!(link_id = %link.id, error = %e, "skipping node: parent key unavailable"); } } } keys } /// Map a wire `VolumeEventDto` to a public [`DriveEvent`]. C# /// `VolumeEventDtoExtensions.ToDriveEvent`: Create/Update/UpdateMetadata → /// `NodeUpdated`, Delete → `NodeDeleted`; any other type is rejected. fn to_drive_event(volume_id: &VolumeId, event: &VolumeEventDto) -> Result { let node_uid = NodeUid::new(volume_id.clone(), event.link.id.clone()); let parent_node_uid = event .link .parent_id .clone() .map(|parent| NodeUid::new(volume_id.clone(), parent)); // VolumeEventType: 0 Delete, 1 Create, 2 Update, 3 UpdateMetadata. match event.event_type { 1..=3 => Ok(DriveEvent::NodeUpdated { id: event.id.clone(), node_uid, parent_node_uid, is_trashed: event.link.is_trashed, is_shared: event.link.is_shared, }), 0 => Ok(DriveEvent::NodeDeleted { id: event.id.clone(), node_uid, parent_node_uid, }), other => Err(ProtonError::invalid_operation(format!( "unknown volume event type {other}" ))), } } /// Unbounded stream of alternate names for a taken file name: `name (1).ext`, /// `name (2).ext`, … (C# `AlternateFileNameGenerator.GetFileNames`). The /// extension is everything from the last `.`, matching .NET /// `Path.GetFileNameWithoutExtension`. fn alternate_file_names(original: &str) -> impl Iterator + Send + '_ { let (stem, ext) = match original.rfind('.') { Some(idx) => original.split_at(idx), None => (original, ""), }; (1..).map(move |i| format!("{stem} ({i}){ext}")) } /// Unbounded stream of alternate names for a taken folder name: `name (1)`, /// `name (2)`, … (C# `AlternateFileNameGenerator.GetFolderNames`). A folder has /// no extension, so a dot in its name is kept where it is. fn alternate_folder_names(original: &str) -> impl Iterator + Send + '_ { (1..).map(move |i| format!("{original} ({i})")) } /// The re-encrypted material a single node needs to move under a new parent, /// produced by `ProtonDriveClient::build_move_parts`. struct MoveParts { passphrase: String, encrypted_name: String, name_hash: String, original_hash: String, /// A fresh signature over the passphrase, for an anonymously signed node. anonymous_signature: Option, /// The name the node carries now, decrypted. current_name: Vec, } /// A batch move in progress: what is left to send, and what is ready to report. struct MoveRun { parent: NodeUid, /// The membership address names — and anonymous nodes' passphrases — are /// signed as. email: String, queue: VecDeque, outcomes: VecDeque<(NodeUid, Result<()>)>, } /// One node's move, encrypted and ready for a batch (C# `PreparedNodeMoveItem`). struct PreparedMove { uid: NodeUid, item: MoveMultipleLinksItem, /// Where the move was prepared from, and the name it carried then: what a /// retry checks the server's copy against. source_parent: Option, current_name: Vec, anonymous: bool, /// Whether `item.original_hash` already came from the server, which makes a /// second `InvalidRequirements` final. retried: bool, } /// The next request's worth of moves, taken from the front of `queue`: at most /// [`MAX_BATCH_COUNT`], all signed the same way, the rest left in order (C# /// `MoveBatch.TryAddToCurrentBatch`). An anonymous node's request names the /// mover as `SignatureEmail`, which a signed node's must not. fn take_move_batch(queue: &mut VecDeque) -> Vec { let Some(anonymous) = queue.front().map(|prepared| prepared.anonymous) else { return Vec::new(); }; let mut batch = Vec::new(); let mut rest = VecDeque::with_capacity(queue.len()); for prepared in queue.drain(..) { if batch.len() < MAX_BATCH_COUNT && prepared.anonymous == anonymous { batch.push(prepared); } else { rest.push_back(prepared); } } *queue = rest; batch } /// Split a `1001 MultipleResponses` batch envelope into one outcome per link, /// in the order the API listed them. A non-success per-link code becomes a /// [`ProtonApiError`] carrying that code — callers that need to branch on /// *why* a link failed (C# `MoveNodesAsync`'s per-node result) get the machine /// -readable code, not just a message. `http_status` is the batch request's own /// 200: the transport succeeded, the individual link did not. fn aggregate_outcomes(response: AggregateLinksResponse) -> Vec<(LinkId, Result<()>)> { response .responses .into_iter() .map(|pair| { let outcome = if pair.response.is_success() { Ok(()) } else { Err(ProtonError::Api(ProtonApiError { code: pair.response.code, http_status: 200, message: pair.response.error_message.unwrap_or_default(), details: pair.response.details, })) }; (pair.link_id, outcome) }) .collect() } /// Build the photo seal attributes from a completed block write + caller /// metadata. Mirrors C# `RevisionWriter.CreatePhotosRevisionUpdateRequest`: /// `CaptureTime` defaults to now when unset, and `ContentHash` is the lowercase /// hex HMAC-SHA256 of the lowercase-hex plaintext SHA-1, keyed by the parent /// folder's hash key. fn build_photos_attributes( parent_hash_key: &[u8], written: &BlockWriteResult, metadata: &PhotoUploadMetadata, ) -> PhotosAttributesDto { let capture_time = metadata.capture_time.unwrap_or_else(now_epoch_seconds); PhotosAttributesDto { capture_time, content_hash: hex::encode(hmac_sha256(parent_hash_key, written.sha1_hex.as_bytes())), main_photo_link_id: metadata.main_photo_uid.as_ref().map(|u| u.link_id.clone()), tags: metadata.tags.iter().map(|t| *t as i32).collect(), } } /// HMAC-SHA256 of `data` under `key` (the parent folder hash key). fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec { let mut mac = >::new_from_slice(key).expect("HMAC accepts any key length"); mac.update(data); 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 } /// Whether a failed one-request small upload should be retried on the regular /// block path. /// /// Upstream (C# `SmallFileUploadFallback`) falls back on exactly three /// conditions: the endpoint declaring itself not applicable, a node of the same /// name already existing, and a revision-draft conflict. The Proton API reports /// the latter two as the same envelope code, `AlreadyExists` (2500), which the /// regular path then resolves — it knows how to delete a stale draft of ours and /// retry (`create_file_draft` / `create_revision_draft`), while the small /// endpoint is one shot and cannot. /// /// Everything else propagates. A small upload is **not** idempotent: its single /// request creates the node, uploads the block and seals the revision together, /// so replaying it on a rate limit (429), a failed dependency (424), a 5xx or a /// dropped connection risks a duplicate file. Those are the caller's to retry, /// with the same knowledge of what already landed. fn small_upload_should_fall_back(error: &ProtonError) -> bool { matches!(error, ProtonError::Api(e) if e.code == ResponseCode::AlreadyExists) } /// Block verification token: `verificationCode XOR ciphertextPrefix`, with the /// ciphertext prefix zero-padded or truncated to the code length. Mirrors C# /// `VerificationToken.Create`. fn verification_token(code: &[u8], ciphertext: &[u8]) -> Vec { code.iter() .enumerate() .map(|(i, c)| c ^ ciphertext.get(i).copied().unwrap_or(0)) .collect() } /// Assemble a revision's content manifest: thumbnail digests in `ThumbnailType` /// order, then content-block digests in block-index order. /// /// That layout is what the manifest signature covers and what the download path /// re-derives when it verifies a revision, so it has to hold regardless of the /// order blocks actually reached storage in — which, with the upload pipelined, /// is no longer the order they were read in. fn assemble_manifest(thumbnails: &[[u8; 32]], mut blocks: Vec<(i32, [u8; 32])>) -> Vec { blocks.sort_by_key(|(index, _)| *index); let mut manifest = Vec::with_capacity((thumbnails.len() + blocks.len()) * 32); for digest in thumbnails { manifest.extend_from_slice(digest); } for (_, digest) in &blocks { manifest.extend_from_slice(digest); } manifest } /// Encrypt, digest and sign one content block, producing everything the upload /// and the manifest need. /// /// CPU-bound (PGP over 4 MiB plus a signature), so callers run it on the /// blocking pool — the download path offloads its counterpart the same way /// (`revision::decrypt_block_blocking`). fn encrypt_content_block( content_key: &ContentKey, node_key: &PrivateKey, signing_key: &PrivateKey, verification_code: &[u8], index: i32, plaintext: &[u8], ) -> Result<(Bytes, [u8; 32], BlockCreationRequest)> { let ciphertext = content_key.encrypt_block(plaintext)?; let digest: [u8; 32] = Sha256::digest(&ciphertext).into(); let token = verification_token(verification_code, &ciphertext); // Detached signature over the plaintext, then encrypted to the node key. let plaintext_signature = signing_key.sign_detached(plaintext)?; let encrypted_signature = node_key.encrypt(plaintext_signature.as_bytes())?; let request = BlockCreationRequest { index, size: ciphertext.len() as i32, encrypted_signature, hash: BASE64.encode(digest), verifier: BlockVerifier { token: BASE64.encode(&token), }, }; Ok((Bytes::from(ciphertext), digest, request)) } /// Encrypt and inline-sign a thumbnail under the content key, ready to ride the /// first block-token request. Mirrors C# `BlockUploader.UploadThumbnailAsync`'s /// crypto half. fn encrypt_thumbnail_block( draft: &RevisionDraft, thumbnail: &Thumbnail, ) -> Result { let ciphertext = draft .content_key .encrypt_thumbnail(&draft.signing_key, &thumbnail.content)?; let digest: [u8; 32] = Sha256::digest(&ciphertext).into(); let thumbnail_type = thumbnail.thumbnail_type.as_i32(); Ok(EncryptedThumbnail { thumbnail_type, request: ThumbnailCreationRequest { size: ciphertext.len() as i32, thumbnail_type, hash: BASE64.encode(digest), }, ciphertext: Bytes::from(ciphertext), digest, }) } /// `POST blocks`: ask for upload targets for a batch of encrypted blocks, plus /// this upload's thumbnails (only ever on its first request). async fn request_upload_targets( http: &ApiHttpClient, context: &UploadContext, blocks: Vec, thumbnails: Vec, ) -> Result { let request = BlockUploadPreparationRequest { address_id: context.address_id.clone(), volume_id: context.volume_id.clone(), link_id: context.link_id.clone(), revision_id: context.revision_id.clone(), blocks, thumbnails, }; http.post("blocks", &request).await } /// Take the target the server issued for content block `index`, falling back to /// request order when the response does not echo indices. fn take_block_target( targets: &mut Vec, index: i32, ) -> Option { let position = targets .iter() .position(|target| target.index == Some(index)) .or_else(|| targets.iter().position(|target| target.index.is_none()))?; Some(targets.remove(position)) } /// As [`take_block_target`], for the thumbnail of a given type. fn take_thumbnail_target( targets: &mut Vec, thumbnail_type: i32, ) -> Option { let position = targets .iter() .position(|target| target.thumbnail_type == Some(thumbnail_type)) .or_else(|| { targets .iter() .position(|target| target.thumbnail_type.is_none()) })?; Some(targets.remove(position)) } /// Store one encrypted blob, returning `(index, digest)` for a content block and /// `None` for a thumbnail. /// /// Mirrors TS `streamUploader.uploadBlock`: retry up to /// [`MAX_BLOCK_UPLOAD_ATTEMPTS`] times, re-preparing the block when its upload /// token has expired and serializing the whole file's uploads after a timeout. /// This sits *above* the HTTP client's own retry policy, which already covers /// transport failures, 5xx and 429 — what it cannot do is mint a new token, /// which needs the block's signature and verifier. async fn upload_one_block( http: &ApiHttpClient, context: &UploadContext, job: UploadJob, limiter: &Semaphore, downshifted: &AtomicBool, ) -> Result> { let UploadJob { index, ciphertext, digest, request, mut target, permit: _permit, } = job; let mut attempt = 0_usize; loop { attempt += 1; let outcome = { // Once downshifted, a block takes the entire allowance, which is how // the upload serializes itself for the rest of the file (TS // `limitUploadCapacity` waits for every other block instead). let wanted = if downshifted.load(Ordering::Relaxed) { MAX_CONCURRENT_BLOCK_UPLOADS as u32 } else { 1 }; let _slot = limiter.acquire_many(wanted).await.map_err(|e| { ProtonError::invalid_operation(format!("upload limiter closed: {e}")) })?; http.post_storage_blob(&target.bare_url, &target.token, ciphertext.clone()) .await }; let error = match outcome { Ok(()) => return Ok(index.map(|index| (index, digest))), Err(error) => error, }; if attempt >= MAX_BLOCK_UPLOAD_ATTEMPTS { return Err(error); } // A permanent rejection of the revision (C# `RetryPolicy.IsRetriable`) // fails the same way on every replay, so it ends the transfer here // rather than spending the remaining attempts on it. 404 stays // retriable: it is how an expired upload token arrives, and the branch // below re-mints one. if !error.is_retriable() { return Err(error); } if is_upload_timeout(&error) { downshifted.store(true, Ordering::Relaxed); } else if is_expired_upload_token(&error) && let Some(request) = request.as_ref() { // Retrying the same URL can only fail the same way, so re-prepare // the block and aim at the fresh target. let prepared = request_upload_targets(http, context, vec![request.clone()], Vec::new()).await?; target = prepared.upload_targets.into_iter().next().ok_or_else(|| { ProtonError::invalid_operation("block upload preparation returned no target") })?; } tracing::warn!( block = ?index, attempt, error = %error, "block upload failed, retrying" ); } } /// The storage host no longer knows this upload token — the block has to be /// re-prepared before it can be retried (TS `uploadBlock`'s `NOT_FOUND` branch). fn is_expired_upload_token(error: &ProtonError) -> bool { matches!( error, ProtonError::Api(e) if e.http_status == 404 || matches!(e.code, ResponseCode::DoesNotExist) ) } /// The upload ran out of time rather than failing outright, which TS treats as a /// signal that the link cannot carry the current concurrency. fn is_upload_timeout(error: &ProtonError) -> bool { matches!(error, ProtonError::Transport(e) if e.is_timeout()) } /// Run a request that may change node context shares. /// /// The write guard excludes context lookups for the full request. Invalidating /// both before and after handles ambiguous transport failures, where the server /// may commit despite returning an error. The caller acquires the guard and /// performs pre-invalidation before the request is detached, so cancellation /// while waiting cannot issue the request. Once detached, the task owns the /// guard, request, and cleanup so cancellation cannot skip post-invalidation. async fn run_context_share_mutation( gate: Arc>, cache: Arc>, request: F, ) -> Result where F: Future> + Send + 'static, T: Send + 'static, { let context_guard = gate.write_owned().await; cache.lock().await.context_share_ids.clear(); tokio::spawn(async move { let _context_guard = context_guard; let result = request.await; cache.lock().await.context_share_ids.clear(); result }) .await .map_err(|error| { ProtonError::invalid_operation(format!("context-changing request task failed: {error}")) })? } fn context_share_path(uid: &NodeUid) -> String { format!("volumes/{}/links/{}/context", uid.volume_id, uid.link_id) } /// Copy the already-fetched wire membership into the public read model. fn share_membership_from_dto(dto: &ShareMembershipSummaryDto) -> ShareMembership { ShareMembership { share_id: dto.share_id.clone(), membership_id: dto.membership_id.clone(), permissions: dto.permissions, invite_time: dto.invite_time, inviter_email: dto.inviter_email.clone(), // Needs the inviter's keys; `verified_membership` fills it in. inviter_verification: None, } } /// One `add-multiple` entry. It never carries a passphrase signature, not even /// for an anonymously signed photo: the endpoint rejects the field (C# /// `ToAlbumPhotoLinkItem`). fn album_entry(payload: &PhotoTargetPayload) -> AddPhotoToAlbumEntry { AddPhotoToAlbumEntry { link_id: payload.uid.link_id.clone(), name_hash: payload.name_hash.clone(), name: payload.name.clone(), name_signature_email: payload.name_signature_email.clone(), passphrase: payload.passphrase.clone(), content_hash: payload.content_hash.clone(), } } /// One `transfer-multiple` link item, or `None` when the photo's link carries /// no name hash for the parent it is leaving — the endpoint needs it to match. fn transfer_link_item(payload: &PhotoTargetPayload) -> Option { Some(TransferPhotoLinkItem { link_id: payload.uid.link_id.clone(), name_hash: payload.name_hash.clone(), original_name_hash: payload.original_name_hash.clone()?, name: payload.name.clone(), passphrase: payload.passphrase.clone(), content_hash: payload.content_hash.clone(), passphrase_signature: None, }) } /// The photos an album delete was refused for, from the error envelope's /// `Details.ChildLinkIDs` (C# `DeleteAlbumFailureDetails`), on the album's /// volume. fn photos_only_in_album(error: &ProtonApiError, volume_id: &VolumeId) -> Vec { error .details .as_ref() .and_then(|details| details.get("ChildLinkIDs")) .and_then(|links| links.as_array()) .map(|links| { links .iter() .filter_map(|link_id| link_id.as_str()) .map(|link_id| NodeUid::new(volume_id.clone(), LinkId::from(link_id.to_string()))) .collect() }) .unwrap_or_default() } /// The related photos an error envelope's `Details.Missing` names, on the volume /// the failing photo lives on (C# `MissingRelatedPhotosException`). fn missing_related_links(error: &ProtonApiError, volume_id: &VolumeId) -> Vec { error .details .as_ref() .and_then(|details| details.get("Missing")) .and_then(|missing| missing.as_array()) .map(|missing| { missing .iter() .filter_map(|link_id| link_id.as_str()) .map(|link_id| NodeUid::new(volume_id.clone(), LinkId::from(link_id.to_string()))) .collect() }) .unwrap_or_default() } fn missing_related_photos_error(missing: &[NodeUid]) -> ProtonError { let missing: Vec = missing.iter().map(|uid| uid.to_string()).collect(); ProtonError::invalid_operation(format!( "server wants related photos sent with it: {}", missing.join(", ") )) } /// Turn one photo's answer into an outcome, or re-queue it once when the server /// asked for its related photos (C# `PhotoOperations.RecordOutcome`). `failure` /// carries the related photos the server named, when it named any. fn record_transfer_outcome( uid: NodeUid, failure: Option<(Vec, ProtonError)>, queue: &mut VecDeque<(NodeUid, Vec)>, retried: &mut HashSet, outcomes: &mut Vec<(NodeUid, Result<()>)>, ) { match failure { None => outcomes.push((uid, Ok(()))), // `insert` is false once this photo has already been re-queued, which is // what stops a server that keeps asking from looping forever. Some((missing, _error)) if !missing.is_empty() && retried.insert(uid.clone()) => { queue.push_back((uid, missing)); } Some((_, error)) => outcomes.push((uid, Err(error))), } } /// Where same-volume photos go in, which decides the endpoint. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PhotoTransferKind { /// The timeline root: the photos are *moved* in through /// `transfer-multiple`. Timeline, /// An album: the photos are *added* through `add-multiple` and stay where /// they are. Album, } /// The node photos are brought into — the timeline root or an album — and the /// keys they are re-encrypted for on their way (C# /// `PhotoOperations.PhotoTransferTarget`). struct PhotoTransferTarget { uid: NodeUid, kind: PhotoTransferKind, key: PrivateKey, hash_key: Vec, /// The photos share membership address: what signs the re-encrypted names. email: String, signing_key: PrivateKey, } /// One photo re-encrypted for a target node, plus its related photos (C# /// `TransferEncryptedPhotoPayload`). Feeds both the album `add-multiple` entries /// and the timeline transfer/copy requests, which all want the same ciphertext /// and hashes in slightly different wire shapes. #[derive(Debug, Clone)] struct PhotoTargetPayload { uid: NodeUid, /// Where the photo lives now — the timeline path rejects a photo already /// parented to the target. parent_uid: Option, /// The photo's name hash under its *current* parent's hash key, straight /// from the link. Only the transfer endpoint needs it. original_name_hash: Option, name_hash: String, name: String, name_signature_email: String, passphrase: String, content_hash: String, /// Live-photo video and burst siblings, re-encrypted the same way. The /// server refuses a photo whose group is incomplete. related: Vec, } /// The Drive-owned items of one `v2/sharedwithme` page, in the order the API /// returned them. /// /// Albums and photos belong to [`ProtonPhotosClient`](crate::ProtonPhotosClient) /// and a `Root` target is not an item, so all three are dropped — as is a target /// type this build does not recognise, which is safer to skip than to guess at. /// Order is preserved: it is what a front-end lists, and reshuffling it would /// move rows around on every refresh. fn drive_items(page: &SharedWithMeResponse) -> Vec { page.links .iter() .filter(|link| { ShareTargetType::from_raw(link.share_target_type) .is_some_and(ShareTargetType::is_drive_item) }) .map(|link| SharedWithMeItem { uid: NodeUid::new(link.volume_id.clone(), link.link_id.clone()), share_id: link.share_id.clone(), }) .collect() } #[cfg(test)] mod tests { use super::{ CONTEXT_SHARE_CACHE_CAP, DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, MAX_NODE_NAME_LENGTH, NodeAction, ParentKeyId, aggregate_outcomes, alternate_file_names, alternate_folder_names, assemble_manifest, context_share_path, drive_items, epoch_to_iso8601, has_active_regular_volume, is_expired_upload_token, is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links, path_segments, photos_only_in_album, record_transfer_outcome, resolve_parent_keys, run_context_share_mutation, share_membership_from_dto, small_upload_applicable, small_upload_should_fall_back, take_block_target, take_thumbnail_target, to_drive_event, validate_node_name, }; use crate::dtos::{ AggregateLinksResponse, BlockUploadTarget, LinkDetailsDto, LinkDto, LinkIdResponsePair, ShareMembershipSummaryDto, SharedWithMeLinkDto, SharedWithMeResponse, VolumeEventDto, VolumeEventLinkDto, VolumeListResponse, }; use crate::node::RevisionState; use crate::sharing::MemberRole; use proton_sdk::api::{ApiResponse, ResponseCode}; use proton_sdk::error::{ProtonApiError, ProtonError}; use proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, ShareMembershipId, VolumeId}; use std::future::Future as _; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::{Mutex, Notify, RwLock}; /// A batch envelope with one successful link and one that failed with `code`. fn mixed_aggregate(code: ResponseCode) -> AggregateLinksResponse { AggregateLinksResponse { responses: vec![ LinkIdResponsePair { link_id: LinkId::new("ok-link"), response: ApiResponse { code: ResponseCode::Success, error_message: None, details: None, }, }, LinkIdResponsePair { link_id: LinkId::new("bad-link"), response: ApiResponse { code, error_message: Some("nope".to_string()), details: None, }, }, ], } } #[test] fn node_action_routes_match_upstream() { let volume = VolumeId::new("vol-1"); assert_eq!( NodeAction::Trash.path(&volume), "v2/volumes/vol-1/trash_multiple" ); assert_eq!( NodeAction::Restore.path(&volume), "v2/volumes/vol-1/trash/restore_multiple" ); assert_eq!( NodeAction::Delete.path(&volume), "v2/volumes/vol-1/trash/delete_multiple" ); } #[test] fn aggregate_outcomes_keeps_per_link_codes() { let outcomes = aggregate_outcomes(mixed_aggregate(ResponseCode::DoesNotExist)); assert_eq!(outcomes.len(), 2, "one outcome per link, in response order"); assert_eq!(outcomes[0].0, LinkId::new("ok-link")); assert!(outcomes[0].1.is_ok()); assert_eq!(outcomes[1].0, LinkId::new("bad-link")); // A failed link keeps the machine-readable code — the whole point of // reporting per-node move outcomes instead of one formatted message. match outcomes[1].1.as_ref().expect_err("bad-link must fail") { ProtonError::Api(e) => { assert_eq!(e.code, ResponseCode::DoesNotExist); assert_eq!(e.message, "nope"); assert_eq!(e.http_status, 200, "the batch request itself succeeded"); } other => panic!("expected an API error, got {other:?}"), } } #[test] fn shared_with_me_items_keep_share_ids_order_and_drive_targets() { let link = |link_id: &str, share_id: &str, target_type| SharedWithMeLinkDto { volume_id: VolumeId::new("shared-volume"), share_id: ShareId::new(share_id), link_id: LinkId::new(link_id), share_target_type: target_type, }; let page = SharedWithMeResponse { links: vec![ link("folder", "share-folder", 1), link("album", "share-album", 3), link("file", "share-file", 2), link("root", "share-root", 0), link("vendor", "share-vendor", 5), link("future", "share-future", 99), ], anchor_id: None, more: false, }; let items = drive_items(&page); assert_eq!(items.len(), 3); assert_eq!(items[0].uid.link_id, LinkId::new("folder")); assert_eq!(items[0].share_id, ShareId::new("share-folder")); assert_eq!(items[1].uid.link_id, LinkId::new("file")); assert_eq!(items[1].share_id, ShareId::new("share-file")); assert_eq!(items[2].uid.link_id, LinkId::new("vendor")); assert_eq!(items[2].share_id, ShareId::new("share-vendor")); } #[test] fn membership_dto_copies_raw_authority_into_the_public_shape() { let dto = ShareMembershipSummaryDto { share_id: ShareId::new("share-1"), membership_id: ShareMembershipId::new("membership-1"), permissions: 38, invite_time: Some(1_700_000_000), inviter_email: Some("owner@proton.me".into()), member_share_passphrase_key_packet: None, inviter_share_passphrase_key_packet_signature: None, }; let membership = share_membership_from_dto(&dto); assert_eq!(membership.share_id, dto.share_id); assert_eq!(membership.membership_id, dto.membership_id); assert_eq!(membership.permissions, 38); assert_eq!(membership.invite_time, Some(1_700_000_000)); assert_eq!(membership.inviter_email.as_deref(), Some("owner@proton.me")); assert_eq!(membership.inviter_verification, None); assert_eq!(membership.role(), MemberRole::Viewer); assert_eq!(membership.role_exact(), None); } #[test] fn only_sealed_revisions_are_listable() { assert!(is_listable_revision_state(Some(1)), "active"); assert!(is_listable_revision_state(Some(2)), "superseded"); // A draft has no readable content, and `RevisionState::from_raw` would // map it to Active — the exact trap this predicate exists to avoid. assert!(!is_listable_revision_state(Some(0)), "draft"); assert!(!is_listable_revision_state(None), "unstated"); assert!(!is_listable_revision_state(Some(99)), "unknown"); 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::>(); assert_eq!(split("a/b"), ["a", "b"]); assert_eq!(split("/a/b"), ["a", "b"]); assert_eq!(split("/a/b/"), ["a", "b"]); assert_eq!(split("//a///b//"), ["a", "b"]); // The root is reachable as any of these, and must not yield a segment. assert!(split("").is_empty()); assert!(split("/").is_empty()); assert!(split("///").is_empty()); } #[test] fn join_node_path_round_trips_through_path_segments() { // A name may hold any character but `/`, so joining then re-splitting // must recover exactly the names `get_node_by_path` will match on. let names = ["Documents", "a b", "weird: name?", "trailing "]; let path = join_node_path(names.iter().copied()); assert_eq!(path, "/Documents/a b/weird: name?/trailing "); assert_eq!(path_segments(&path).collect::>(), names); } #[test] fn join_node_path_of_the_root_is_a_bare_slash() { assert_eq!(join_node_path(std::iter::empty()), "/"); } #[test] fn folder_key_cache_is_bounded() { // The LRU that plan #9 introduced must actually carry the cap, or the // daemon leaks one decrypted folder key per folder ever visited. let cache = DriveCache::default(); assert_eq!(cache.folder_keys.cap().get(), FOLDER_KEY_CACHE_CAP); } #[test] fn context_share_cache_is_bounded_and_promotes_hits() { let mut cache = DriveCache::default(); assert_eq!(cache.context_share_ids.cap().get(), CONTEXT_SHARE_CACHE_CAP); for index in 0..CONTEXT_SHARE_CACHE_CAP { cache.context_share_ids.put( NodeUid::new( VolumeId::new("volume-1"), LinkId::new(format!("link-{index}")), ), ShareId::new(format!("share-{index}")), ); } let oldest = NodeUid::new(VolumeId::new("volume-1"), LinkId::new("link-0")); assert_eq!( cache.context_share_ids.get(&oldest), Some(&ShareId::new("share-0")) ); cache.context_share_ids.put( NodeUid::new(VolumeId::new("volume-1"), LinkId::new("overflow")), ShareId::new("overflow-share"), ); assert!( cache.context_share_ids.peek(&oldest).is_some(), "a hit must promote the entry" ); assert!( cache .context_share_ids .peek(&NodeUid::new( VolumeId::new("volume-1"), LinkId::new("link-1") )) .is_none(), "the least-recently-used entry must be evicted" ); } #[tokio::test] async fn context_share_gate_excludes_lookups_and_mutations() { let gate = RwLock::new(()); let lookup_guard = gate.read().await; assert!( gate.try_write().is_err(), "a lookup read guard must block a context mutation" ); drop(lookup_guard); let _mutation_guard = gate.write().await; assert!( gate.try_read().is_err(), "a mutation write guard must block a context lookup" ); } #[tokio::test] async fn cancelling_context_mutation_while_queued_does_not_start_request() { let gate = Arc::new(RwLock::new(())); let cache = Arc::new(Mutex::new(DriveCache::default())); let lookup_guard = gate.read().await; let request_started = Arc::new(AtomicBool::new(false)); let mut mutation = Box::pin(run_context_share_mutation(gate.clone(), cache, { let request_started = request_started.clone(); async move { request_started.store(true, Ordering::SeqCst); Ok(()) } })); let was_pending = std::future::poll_fn(|context| { std::task::Poll::Ready(mutation.as_mut().poll(context).is_pending()) }) .await; assert!(was_pending, "the mutation must be queued behind the lookup"); // If the helper detached before acquiring the gate, let that task run // now so it queues behind the held read guard. tokio::task::yield_now().await; drop(mutation); drop(lookup_guard); // Acquiring the gate proves no detached mutation remains queued. let completed_guard = gate.write().await; drop(completed_guard); assert!( !request_started.load(Ordering::SeqCst), "cancelling before gate acquisition must not start the request" ); } #[tokio::test] async fn context_mutation_finishes_post_invalidation_after_caller_cancellation() { let gate = Arc::new(RwLock::new(())); let cache = Arc::new(Mutex::new(DriveCache::default())); let uid = NodeUid::new(VolumeId::new("volume-1"), LinkId::new("link-1")); cache .lock() .await .context_share_ids .put(uid.clone(), ShareId::new("before-move")); let request_started = Arc::new(Notify::new()); let finish_request = Arc::new(Notify::new()); let caller = tokio::spawn({ let gate = gate.clone(); let cache = cache.clone(); let request_uid = uid.clone(); let request_started = request_started.clone(); let finish_request = finish_request.clone(); async move { let request_cache = cache.clone(); run_context_share_mutation(gate, cache, async move { request_started.notify_one(); finish_request.notified().await; // White-box marker: post-invalidation is otherwise // observationally indistinguishable from pre-invalidation. request_cache .lock() .await .context_share_ids .put(request_uid, ShareId::new("request-completed")); Ok(()) }) .await } }); request_started.notified().await; assert!( cache.lock().await.context_share_ids.peek(&uid).is_none(), "the detached mutation must invalidate before its request" ); caller.abort(); assert!( caller .await .expect_err("caller should be cancelled") .is_cancelled() ); finish_request.notify_one(); // The detached task owns the write guard until its post-invalidation. let completed_guard = gate.write().await; drop(completed_guard); let state = cache.lock().await; assert!( state.context_share_ids.peek(&uid).is_none(), "caller cancellation must not skip the post-request invalidation" ); } #[tokio::test] async fn context_mutation_preserves_request_error_variant() { let result = run_context_share_mutation( Arc::new(RwLock::new(())), Arc::new(Mutex::new(DriveCache::default())), async { Err::<(), _>(api_error(ResponseCode::DoesNotExist, 422)) }, ) .await; match result.expect_err("request should fail") { ProtonError::Api(error) => { assert_eq!(error.code, ResponseCode::DoesNotExist); assert_eq!(error.http_status, 422); } other => panic!("expected original API error, got {other:?}"), } } #[test] fn context_share_route_matches_the_drive_api() { let uid = NodeUid::new(VolumeId::new("volume-1"), LinkId::new("link-1")); assert_eq!( context_share_path(&uid), "volumes/volume-1/links/link-1/context" ); } #[test] fn epoch_formats_as_iso8601_utc() { // 2026-06-26T14:01:00Z assert_eq!(epoch_to_iso8601(1_782_482_460), "2026-06-26T14:01:00Z"); // Unix epoch and a pre-1970 (negative) instant. assert_eq!(epoch_to_iso8601(0), "1970-01-01T00:00:00Z"); assert_eq!(epoch_to_iso8601(-1), "1969-12-31T23:59:59Z"); // A leap-day timestamp: 2024-02-29T12:00:00Z. assert_eq!(epoch_to_iso8601(1_709_208_000), "2024-02-29T12:00:00Z"); } fn event(event_type: i32, parent: Option<&str>) -> VolumeEventDto { VolumeEventDto { id: DriveEventId::new("evt-1"), event_type, link: VolumeEventLinkDto { id: LinkId::new("link-1"), parent_id: parent.map(LinkId::new), is_shared: true, is_trashed: true, }, } } #[test] fn maps_update_event_to_node_updated() { let vid = VolumeId::new("vol-1"); // 1 = Create, 2 = Update, 3 = UpdateMetadata all map to NodeUpdated. for ty in [1, 2, 3] { let mapped = to_drive_event(&vid, &event(ty, Some("parent-1"))).unwrap(); match mapped { DriveEvent::NodeUpdated { node_uid, parent_node_uid, is_trashed, is_shared, .. } => { assert_eq!(node_uid.link_id, LinkId::new("link-1")); assert_eq!( parent_node_uid.map(|p| p.link_id), Some(LinkId::new("parent-1")) ); assert!(is_trashed && is_shared); } other => panic!("expected NodeUpdated, got {other:?}"), } } } #[test] fn maps_delete_event_to_node_deleted() { let vid = VolumeId::new("vol-1"); let mapped = to_drive_event(&vid, &event(0, None)).unwrap(); match mapped { DriveEvent::NodeDeleted { node_uid, parent_node_uid, .. } => { assert_eq!(node_uid.link_id, LinkId::new("link-1")); assert!(parent_node_uid.is_none()); } other => panic!("expected NodeDeleted, got {other:?}"), } } #[test] fn rejects_unknown_event_type() { let vid = VolumeId::new("vol-1"); assert!(to_drive_event(&vid, &event(99, None)).is_err()); } #[test] fn node_names_are_validated_before_they_are_sent() { // C# `NodeOperations.ValidateNodeName`: empty and over-long names are // rejected client-side rather than round-tripping to a server error. assert!(validate_node_name("report.txt").is_ok()); assert!(validate_node_name("").is_err()); let longest = "a".repeat(MAX_NODE_NAME_LENGTH); assert!(validate_node_name(&longest).is_ok()); assert!(validate_node_name(&format!("{longest}a")).is_err()); // Counted in characters, not bytes: a 255-emoji name is 1020 bytes and // still legal. assert!(validate_node_name(&"🙂".repeat(MAX_NODE_NAME_LENGTH)).is_ok()); } #[test] fn alternate_file_names_keeps_extension() { let got: Vec = alternate_file_names("report.txt").take(3).collect(); assert_eq!(got, ["report (1).txt", "report (2).txt", "report (3).txt"]); } #[test] fn alternate_file_names_no_extension() { let got: Vec = alternate_file_names("notes").take(2).collect(); assert_eq!(got, ["notes (1)", "notes (2)"]); } #[test] fn alternate_file_names_uses_last_dot_only() { // .NET `Path.GetFileNameWithoutExtension` strips only the final segment. let first = alternate_file_names("archive.tar.gz").next().unwrap(); assert_eq!(first, "archive.tar (1).gz"); } #[test] fn alternate_folder_names_keep_dots_in_place() { let got: Vec = alternate_folder_names("v1.2").take(2).collect(); assert_eq!(got, ["v1.2 (1)", "v1.2 (2)"]); } #[test] fn photos_attributes_content_hash_and_tags() { use super::{BlockWriteResult, build_photos_attributes, hmac_sha256}; use crate::photos::{PhotoTag, PhotoUploadMetadata}; use proton_sdk::ids::NodeUid; let hash_key = b"parent-folder-hash-key-bytes".to_vec(); let written = BlockWriteResult { manifest: Vec::new(), block_sizes: vec![10], total_size: 10, sha1_hex: "0123456789abcdef0123456789abcdef01234567".to_string(), }; let metadata = PhotoUploadMetadata { capture_time: Some(1_700_000_000), main_photo_uid: Some(NodeUid::new( VolumeId::new("vol-1"), LinkId::new("main-link"), )), tags: vec![PhotoTag::Video, PhotoTag::Selfie], }; let attrs = build_photos_attributes(&hash_key, &written, &metadata); // ContentHash = lowercase-hex HMAC-SHA256 over the lowercase-hex SHA-1. let expected = hex::encode(hmac_sha256(&hash_key, written.sha1_hex.as_bytes())); assert_eq!(attrs.content_hash, expected); assert_eq!(attrs.capture_time, 1_700_000_000); assert_eq!(attrs.main_photo_link_id, Some(LinkId::new("main-link"))); // Tags carry their `PhotoTag` discriminants. assert_eq!( attrs.tags, vec![PhotoTag::Video as i32, PhotoTag::Selfie as i32] ); } #[test] fn photos_attributes_default_capture_time_and_empty_tags() { use super::{BlockWriteResult, build_photos_attributes}; use crate::photos::PhotoUploadMetadata; let written = BlockWriteResult { manifest: Vec::new(), block_sizes: Vec::new(), total_size: 0, sha1_hex: "da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(), }; let attrs = build_photos_attributes(b"k", &written, &PhotoUploadMetadata::default()); // Unset capture time defaults to the (positive) upload time; no main // photo; tags serialize as an empty array. assert!(attrs.capture_time > 0); assert_eq!(attrs.main_photo_link_id, None); assert!(attrs.tags.is_empty()); } fn digest(byte: u8) -> [u8; 32] { [byte; 32] } /// The pipeline stores blocks out of order; the manifest must not be. #[test] fn manifest_is_thumbnails_then_blocks_in_index_order() { let manifest = assemble_manifest( &[digest(0xaa), digest(0xbb)], vec![(3, digest(3)), (1, digest(1)), (2, digest(2))], ); assert_eq!(manifest.len(), 5 * 32); let entries: Vec = manifest.chunks(32).map(|entry| entry[0]).collect(); assert_eq!(entries, vec![0xaa, 0xbb, 1, 2, 3]); } #[test] fn manifest_of_a_thumbnail_less_empty_file_is_empty() { assert!(assemble_manifest(&[], Vec::new()).is_empty()); } fn target(index: Option, thumbnail_type: Option, token: &str) -> BlockUploadTarget { BlockUploadTarget { bare_url: "https://storage.example/blob".to_string(), token: token.to_string(), index, thumbnail_type, } } #[test] fn upload_targets_are_matched_by_index_not_position() { let mut targets = vec![ target(Some(2), None, "second"), target(Some(1), None, "first"), ]; assert_eq!( take_block_target(&mut targets, 1) .expect("target for 1") .token, "first" ); assert_eq!( take_block_target(&mut targets, 2) .expect("target for 2") .token, "second" ); assert!(take_block_target(&mut targets, 3).is_none()); } /// A response that does not echo indices is consumed in request order. #[test] fn upload_targets_without_indices_fall_back_to_request_order() { let mut targets = vec![target(None, None, "first"), target(None, None, "second")]; assert_eq!( take_block_target(&mut targets, 7) .expect("first target") .token, "first" ); assert_eq!( take_block_target(&mut targets, 9) .expect("second target") .token, "second" ); } #[test] fn thumbnail_targets_are_matched_by_type() { let mut targets = vec![ target(None, Some(2), "preview"), target(None, Some(1), "tiny"), ]; assert_eq!( take_thumbnail_target(&mut targets, 1) .expect("target for type 1") .token, "tiny" ); assert_eq!( take_thumbnail_target(&mut targets, 2) .expect("target for type 2") .token, "preview" ); } fn api_error(code: ResponseCode, http_status: u16) -> ProtonError { ProtonError::Api(ProtonApiError { code, http_status, message: String::new(), details: None, }) } #[test] fn expired_upload_tokens_are_recognized_by_status_or_code() { assert!(is_expired_upload_token(&api_error( ResponseCode::Success, 404 ))); assert!(is_expired_upload_token(&api_error( ResponseCode::DoesNotExist, 422 ))); // Anything else is a plain failure: retried, but against the same token. assert!(!is_expired_upload_token(&api_error( ResponseCode::Success, 500 ))); assert!(!is_expired_upload_token(&ProtonError::invalid_operation( "boom" ))); assert!(!is_upload_timeout(&api_error(ResponseCode::Success, 504))); } #[test] fn a_photo_the_server_wants_related_photos_for_is_requeued_once() { let uid = NodeUid::new(VolumeId::new("vol"), LinkId::new("photo")); let related = NodeUid::new(VolumeId::new("vol"), LinkId::new("related")); let mut queue = std::collections::VecDeque::new(); let mut retried = std::collections::HashSet::new(); let mut outcomes = Vec::new(); let failure = || { Some(( vec![related.clone()], ProtonError::invalid_operation("missing"), )) }; // First answer: re-queued with the related photo, no outcome yet. record_transfer_outcome( uid.clone(), failure(), &mut queue, &mut retried, &mut outcomes, ); assert_eq!( queue.pop_front(), Some((uid.clone(), vec![related.clone()])) ); assert!(outcomes.is_empty()); // Second: the retry is spent, so it becomes a failed outcome instead of // going round again. record_transfer_outcome( uid.clone(), failure(), &mut queue, &mut retried, &mut outcomes, ); assert!(queue.is_empty()); assert_eq!(outcomes.len(), 1); assert!(outcomes[0].1.is_err()); } #[test] fn a_photo_that_succeeds_records_one_successful_outcome() { let uid = NodeUid::new(VolumeId::new("vol"), LinkId::new("photo")); let mut queue = std::collections::VecDeque::new(); let mut retried = std::collections::HashSet::new(); let mut outcomes = Vec::new(); record_transfer_outcome(uid.clone(), None, &mut queue, &mut retried, &mut outcomes); assert!(queue.is_empty()); assert_eq!(outcomes.len(), 1); assert!(outcomes[0].1.is_ok()); } #[test] fn photos_only_in_album_reads_the_error_details() { let volume_id = VolumeId::new("vol"); let error = ProtonApiError { code: ResponseCode::from_raw(200_302), http_status: 422, message: "Album contains photos not in timeline".into(), details: Some(serde_json::json!({ "ChildLinkIDs": ["a", "b"] })), }; assert_eq!(error.code, ResponseCode::AlbumContainsPhotosNotInTimeline); assert_eq!( photos_only_in_album(&error, &volume_id), vec![ NodeUid::new(volume_id.clone(), LinkId::new("a")), NodeUid::new(volume_id.clone(), LinkId::new("b")), ] ); } #[test] fn missing_related_links_reads_the_error_details() { let volume_id = VolumeId::new("vol"); let error = ProtonApiError { code: ResponseCode::InvalidRequirements, http_status: 422, message: "missing related photos".into(), details: Some(serde_json::json!({ "Missing": ["a", "b"] })), }; assert_eq!( missing_related_links(&error, &volume_id), vec![ NodeUid::new(volume_id.clone(), LinkId::new("a")), NodeUid::new(volume_id.clone(), LinkId::new("b")), ] ); // An error that names nothing is an ordinary failure, not a retry. let plain = ProtonApiError { details: None, ..error }; assert!(missing_related_links(&plain, &volume_id).is_empty()); } /// The one condition the regular block path can resolve and the one-request /// endpoint cannot: a name/draft conflict, which the block path clears by /// deleting our own stale draft. #[test] fn a_name_or_draft_conflict_falls_back_to_the_block_path() { assert!(small_upload_should_fall_back(&api_error( ResponseCode::AlreadyExists, 422 ))); } /// A small upload is not idempotent, so a transient failure must not be /// replayed by us — it could duplicate the file. #[test] fn transient_failures_do_not_fall_back() { for (status, code) in [ (429, ResponseCode::TooManyRequests), (424, ResponseCode::Unknown), (503, ResponseCode::ServiceUnavailable), (422, ResponseCode::InsufficientQuota), ] { assert!( !small_upload_should_fall_back(&api_error(code, status)), "http {status} should not fall back" ); } assert!(!small_upload_should_fall_back( &ProtonError::invalid_operation("bug") )); } /// A folder link with only the fields parent-key resolution reads. fn key_test_link(id: &str, parent_id: Option<&str>) -> LinkDetailsDto { let link = LinkDto { id: LinkId::new(id), link_type: 1, parent_id: parent_id.map(LinkId::new), state: 1, creation_time: 0, modification_time: 0, trash_time: None, trashed_legacy: None, name: String::new(), name_hash: None, key: String::new(), passphrase: String::new(), passphrase_signature: None, signature_email: None, name_signature_email: None, third_party: false, sdk: false, }; LinkDetailsDto { link, folder: None, file: None, photo: None, album: None, sharing: None, membership: None, } } /// Resolve `links` against a fake resolver that names each key after the /// link it was resolved for, recording every resolution. Roots resolve to /// their share key, other links to their parent's node key — the two /// distinct keys the real resolver returns. `fail` names links whose key /// resolution errors. async fn resolve_fake_keys( links: &[LinkDetailsDto], fail: &[&str], ) -> (std::collections::HashMap, Vec) { let mut calls = Vec::new(); let keys = resolve_parent_keys(links, |link: &LinkDto| { calls.push(link.id.to_string()); let outcome = if fail.contains(&link.id.as_str()) { Err(ProtonError::invalid_operation("missing key")) } else { Ok(match &link.parent_id { Some(parent_id) => format!("node-key:{parent_id}"), None => format!("share-key:{}", link.id), }) }; std::future::ready(outcome) }) .await; (keys, calls) } fn key_for<'k>( keys: &'k std::collections::HashMap, details: &LinkDetailsDto, ) -> Option<&'k str> { keys.get(&ParentKeyId::of(&details.link)) .map(String::as_str) } #[test] fn parent_key_id_tells_roots_from_parents() { let root = key_test_link("x", None).link; let child = key_test_link("c", Some("x")).link; assert_eq!(ParentKeyId::of(&root), ParentKeyId::Root(LinkId::new("x"))); assert_eq!( ParentKeyId::of(&child), ParentKeyId::Parent(LinkId::new("x")) ); // A root's share key and its children's parent key are different keys // of the same link; the single-flight key must keep them apart. assert_ne!(ParentKeyId::of(&root), ParentKeyId::of(&child)); assert_ne!( ParentKeyId::of(&root), ParentKeyId::of(&key_test_link("y", None).link) ); } /// proton-sdk-rs#2: several share roots on one volume, enumerated together. /// Each must get its own share key, not the first root's. Full and light /// enumeration both resolve their keys through this function. #[tokio::test] async fn parent_keys_resolve_each_root_separately() { let links = [ key_test_link("a", None), key_test_link("b", None), key_test_link("c", None), ]; let (keys, calls) = resolve_fake_keys(&links, &[]).await; assert_eq!(calls, ["a", "b", "c"]); assert_eq!(key_for(&keys, &links[0]), Some("share-key:a")); assert_eq!(key_for(&keys, &links[1]), Some("share-key:b")); assert_eq!(key_for(&keys, &links[2]), Some("share-key:c")); } #[tokio::test] async fn parent_keys_keep_a_root_apart_from_its_children() { let links = [key_test_link("x", None), key_test_link("c", Some("x"))]; let (keys, calls) = resolve_fake_keys(&links, &[]).await; assert_eq!(calls, ["x", "c"]); assert_eq!(key_for(&keys, &links[0]), Some("share-key:x")); assert_eq!(key_for(&keys, &links[1]), Some("node-key:x")); } #[tokio::test] async fn parent_keys_resolve_a_shared_parent_once() { let links = [ key_test_link("a", Some("p")), key_test_link("b", Some("p")), key_test_link("c", Some("q")), key_test_link("d", Some("p")), ]; let (keys, calls) = resolve_fake_keys(&links, &[]).await; assert_eq!(calls, ["a", "c"], "one resolution per distinct parent"); assert_eq!(key_for(&keys, &links[1]), Some("node-key:p")); assert_eq!(key_for(&keys, &links[2]), Some("node-key:q")); assert_eq!(key_for(&keys, &links[3]), Some("node-key:p")); } #[tokio::test] async fn parent_keys_skip_only_the_links_whose_key_failed() { let links = [ key_test_link("a", None), key_test_link("b", None), key_test_link("c", None), ]; let (keys, calls) = resolve_fake_keys(&links, &["b"]).await; assert_eq!(calls, ["a", "b", "c"]); assert_eq!(key_for(&keys, &links[0]), Some("share-key:a")); assert_eq!(key_for(&keys, &links[1]), None); assert_eq!(key_for(&keys, &links[2]), Some("share-key:c")); } fn volumes(json: &str) -> VolumeListResponse { serde_json::from_str(json).expect("volume list parses") } #[test] fn a_locked_main_volume_is_not_an_active_regular_one() { // After a password reset: the old regular volume is locked (3), and // an active Photos volume (2) does not stand in for My Files. let locked = volumes( r#"{"Code":1000,"Volumes":[ {"VolumeID":"v1","State":3,"Type":1}, {"VolumeID":"v2","State":1,"Type":2} ]}"#, ); assert!(!has_active_regular_volume(&locked)); assert!(!has_active_regular_volume(&volumes(r#"{"Volumes":[]}"#))); } #[test] fn an_active_regular_volume_is_found_with_or_without_a_type() { let typed = volumes( r#"{"Volumes":[ {"VolumeID":"v1","State":3,"Type":1}, {"VolumeID":"v3","State":1,"Type":1} ]}"#, ); assert!(has_active_regular_volume(&typed)); assert!(has_active_regular_volume(&volumes( r#"{"Volumes":[{"VolumeID":"v1","State":1}]}"# ))); } }