mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
Ports upstream a36cc423. add_photos_to_album now uses the timeline transfer driver, generalised over a PhotoTransferTarget. A photo on another volume, from an album shared with us, is copied into the album instead of failing. A photo whose group the server reports incomplete is sent once more with the related photos it named. Outcomes stay in input order. New set_album_cover, and delete_album_saving_photos, which saves the photos that exist only in the album (code 200302, now AlbumContainsPhotosNotInTimeline) before deleting it. remove_photos_from_album fails a photo on another volume without sending it. AlbumUpdateRequest leaves out a missing cover or name, as C# does. Pins upstream to b68d64bd. The search commit e861ef99 is not ported: its engine source is not public.
9835 lines
403 KiB
Rust
9835 lines
403 KiB
Rust
//! 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<Mutex<DriveCache>>,
|
|
/// 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<dyn Telemetry>,
|
|
/// 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<Semaphore>,
|
|
/// Explicit substitute for upstream's `DriveSmallFileUpload` remote flag.
|
|
small_file_upload: Arc<AtomicBool>,
|
|
/// 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<Bootstrap>,
|
|
/// 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<SingleFlight<NodeUid, Option<Node>>>,
|
|
/// 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<SingleFlight<(VolumeId, ParentKeyId, bool), PrivateKey>>,
|
|
/// 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<RwLock<()>>,
|
|
}
|
|
|
|
/// 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<VolumeId>,
|
|
my_files_share: Option<ShareKey>,
|
|
my_files_root: Option<NodeUid>,
|
|
/// 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<ShareKey>,
|
|
photos_root: Option<NodeUid>,
|
|
/// `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<bool>,
|
|
/// 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<NodeUid, PrivateKey>,
|
|
/// 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<HashMap<NodeUid, ShareId>>,
|
|
/// 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<NodeUid, ShareId>,
|
|
}
|
|
|
|
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<u8>,
|
|
}
|
|
|
|
/// 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<u8>,
|
|
block_sizes: Vec<i32>,
|
|
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<i32>,
|
|
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<BlockCreationRequest>,
|
|
target: BlockUploadTarget,
|
|
permit: Option<OwnedSemaphorePermit>,
|
|
}
|
|
|
|
/// 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<Vec<u8>>) -> 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<Vec<u8>>,
|
|
entity_repository: Arc<dyn CacheRepository>,
|
|
) -> 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<Vec<u8>>,
|
|
key_salts: Vec<KeySalt>,
|
|
) -> 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<Option<String>>)> {
|
|
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<proton_sdk::account::Quota> {
|
|
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<dyn CacheRepository>) -> 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<dyn Telemetry>) -> 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<Semaphore> {
|
|
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<Node> {
|
|
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<ShareId> {
|
|
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<Option<Node>> {
|
|
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<Option<Node>> {
|
|
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<Vec<NodeUid>> {
|
|
let mut timer = self.telemetry.start("enumerate_folder_children_node_uids");
|
|
|
|
let mut uids = Vec::new();
|
|
let mut anchor: Option<LinkId> = 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<Vec<Node>> {
|
|
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<Vec<Node>> {
|
|
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<Vec<Node>> {
|
|
let mut nodes = Vec::new();
|
|
|
|
for (volume_id, link_ids) in group_by_volume(uids) {
|
|
let chunks: Vec<Vec<LinkId>> = 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<Option<Vec<Node>>> {
|
|
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<NodeUid> = 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<Option<String>> {
|
|
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<Option<Node>> {
|
|
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<NodeUid> {
|
|
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<Option<NodeUid>> {
|
|
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<Option<NodeUid>> {
|
|
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<FolderSize> {
|
|
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<Vec<NodeUid>> {
|
|
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<VolumeId> {
|
|
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<DriveEventId> {
|
|
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<Vec<DriveEvent>> {
|
|
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<Vec<u8>> {
|
|
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<W: std::io::Write>(
|
|
&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<RevisionReader> {
|
|
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<RevisionReader> {
|
|
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<Vec<u8>> {
|
|
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<Vec<Revision>> {
|
|
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<Option<Revision>> {
|
|
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<proton_sdk::api::ApiResponse> =
|
|
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<RevisionReader> {
|
|
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<Vec<u8>> {
|
|
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<W: std::io::Write>(
|
|
&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<PrivateKey> {
|
|
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<Vec<u64>> {
|
|
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<Option<Vec<u8>>> {
|
|
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<Option<Vec<u8>>> {
|
|
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<Vec<FileThumbnail>> {
|
|
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<Vec<FileThumbnail>> {
|
|
let mut results: Vec<FileThumbnail> = Vec::new();
|
|
|
|
// Group link ids by volume, preserving first-seen volume order.
|
|
let mut volume_order: Vec<VolumeId> = Vec::new();
|
|
let mut by_volume: HashMap<VolumeId, Vec<LinkId>> = 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<String, (NodeUid, ContentKey)> = 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<String> = 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<String> =
|
|
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<String>)> {
|
|
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<NodeUid> {
|
|
// 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<NodeUid> {
|
|
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<R: Read + Send>(
|
|
&self,
|
|
parent_uid: &NodeUid,
|
|
name: &str,
|
|
media_type: &str,
|
|
reader: R,
|
|
intended_size: i64,
|
|
thumbnails: Vec<Thumbnail>,
|
|
last_modification_time: Option<i64>,
|
|
aead: bool,
|
|
) -> Result<NodeUid> {
|
|
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<R: Read + Send>(
|
|
&self,
|
|
parent_uid: &NodeUid,
|
|
name: &str,
|
|
media_type: &str,
|
|
reader: R,
|
|
intended_size: i64,
|
|
thumbnails: Vec<Thumbnail>,
|
|
last_modification_time: Option<i64>,
|
|
aead: bool,
|
|
) -> Result<NodeUid> {
|
|
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<R: Read + Send>(
|
|
&self,
|
|
parent_uid: &NodeUid,
|
|
name: &str,
|
|
media_type: &str,
|
|
reader: R,
|
|
intended_size: i64,
|
|
thumbnails: Vec<Thumbnail>,
|
|
last_modification_time: Option<i64>,
|
|
aead: bool,
|
|
override_existing_draft: bool,
|
|
) -> Result<NodeUid> {
|
|
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<i64>,
|
|
) -> 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<R: Read + Send>(
|
|
&self,
|
|
file_uid: &NodeUid,
|
|
reader: R,
|
|
intended_size: i64,
|
|
thumbnails: Vec<Thumbnail>,
|
|
last_modification_time: Option<i64>,
|
|
) -> 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<R: Read + Send>(
|
|
&self,
|
|
name: &str,
|
|
media_type: &str,
|
|
reader: R,
|
|
intended_size: i64,
|
|
thumbnails: Vec<Thumbnail>,
|
|
metadata: &PhotoUploadMetadata,
|
|
aead: bool,
|
|
) -> Result<NodeUid> {
|
|
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<i64>,
|
|
) -> Result<NodeUid> {
|
|
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<String> {
|
|
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<dyn Iterator<Item = String> + 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<String, String> = HashMap::new();
|
|
let mut order: Vec<String> = 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<NodeUid> {
|
|
let mut timer = self.telemetry.start("copy_node");
|
|
if let Some(name) = name {
|
|
validate_node_name(name)?;
|
|
}
|
|
|
|
let details = self
|
|
.get_link_details(&uid.volume_id, std::slice::from_ref(&uid.link_id))
|
|
.await?;
|
|
let link = details
|
|
.links
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| ProtonError::invalid_operation(format!("node {uid} not found")))?
|
|
.link;
|
|
|
|
let dest_parent_key = self.folder_node_key(new_parent).await?;
|
|
let dest_hash_key = self.parent_hash_key(new_parent, &dest_parent_key).await?;
|
|
let (_address_id, email, signing_key) = self.membership_address().await?;
|
|
|
|
let parts = self
|
|
.build_move_parts(
|
|
uid,
|
|
&link,
|
|
name,
|
|
&dest_parent_key,
|
|
&dest_hash_key,
|
|
&signing_key,
|
|
)
|
|
.await?;
|
|
|
|
let request = CopyLinkRequest {
|
|
target_volume_id: new_parent.volume_id.clone(),
|
|
target_parent_link_id: new_parent.link_id.clone(),
|
|
passphrase: parts.passphrase,
|
|
signature_email: parts.anonymous_signature.as_ref().map(|_| email.clone()),
|
|
passphrase_signature: parts.anonymous_signature,
|
|
name: parts.encrypted_name,
|
|
name_signature_email: email,
|
|
name_hash: parts.name_hash,
|
|
};
|
|
let path = format!("volumes/{}/links/{}/copy", uid.volume_id, uid.link_id);
|
|
let response: CopyLinkResponse = self.http.post(&path, &request).await?;
|
|
timer.success();
|
|
// The copy lives with its new parent, whichever volume the source was
|
|
// on. (TS builds the uid from the source volume, which only holds for
|
|
// a copy within one volume.)
|
|
Ok(NodeUid::new(new_parent.volume_id.clone(), response.link_id))
|
|
}
|
|
|
|
/// Move several nodes under a single destination parent, keeping their
|
|
/// names. Collects [`move_nodes_streaming`](Self::move_nodes_streaming) —
|
|
/// see it for batching, retries and anonymously signed nodes. Mirrors C#
|
|
/// `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<Vec<(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<NodeMoveItem> = uids
|
|
.iter()
|
|
.filter(|uid| seen.insert(*uid))
|
|
.map(|uid| NodeMoveItem {
|
|
uid: uid.clone(),
|
|
target_name: None,
|
|
})
|
|
.collect();
|
|
let mut outcomes: HashMap<NodeUid, Result<()>> = 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<String>> = 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<NodeMoveItem>,
|
|
new_parent: NodeUid,
|
|
) -> impl futures::Stream<Item = Result<(NodeUid, Result<()>)>> + '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<NodeMoveItem>, parent: NodeUid) -> Result<MoveRun> {
|
|
let mut outcomes = VecDeque::new();
|
|
let mut seen: HashSet<LinkId> = 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<LinkId> = targets
|
|
.iter()
|
|
.map(|item| item.uid.link_id.clone())
|
|
.collect();
|
|
let mut links: HashMap<LinkId, LinkDto> = 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<Item = Result<(NodeUid, Result<()>)>> + '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<PreparedMove>) {
|
|
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<AggregateLinksResponse> = 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<LinkId, Result<()>> =
|
|
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<String> {
|
|
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<MoveParts> {
|
|
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<Vec<(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<NodeActionResult>`.
|
|
/// A failed request yields one `Err` and ends the stream.
|
|
pub fn trash_nodes_streaming<'a>(
|
|
&'a self,
|
|
uids: &[NodeUid],
|
|
) -> impl futures::Stream<Item = Result<(NodeUid, Result<()>)>> + '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<Vec<(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<Item = Result<(NodeUid, Result<()>)>> + '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<Vec<(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<Item = Result<(NodeUid, Result<()>)>> + '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<Item = Result<(NodeUid, Result<()>)>> + 'a {
|
|
let batches: VecDeque<(VolumeId, Vec<LinkId>)> = 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::<Vec<_>>()
|
|
})
|
|
.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<AggregateLinksResponse> = 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<Vec<NodeUid>> {
|
|
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<Vec<SharedWithMeItem>> {
|
|
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<Vec<SharedWithMeItem>> {
|
|
let mut items = Vec::new();
|
|
let mut shares = HashMap::new();
|
|
let mut anchor: Option<String> = 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<HashMap<NodeUid, ShareId>> {
|
|
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<Vec<NodeUid>> {
|
|
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<Vec<NodeUid>> {
|
|
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<Vec<NodeUid>> {
|
|
let mut uids = Vec::new();
|
|
let mut anchor: Option<String> = 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<Vec<ShareInvitation>> {
|
|
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<String> = 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<Vec<ShareMember>> {
|
|
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<Vec<ShareInvitation>> {
|
|
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<i64>,
|
|
) -> Result<PublicLink> {
|
|
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<Option<PublicLink>> {
|
|
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<String> {
|
|
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<Vec<IncomingInvitation>> {
|
|
let mut timer = self.telemetry.start("list_incoming_invitations");
|
|
let mut ids = Vec::new();
|
|
let mut anchor: Option<String> = 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<Vec<ExternalInvitation>> {
|
|
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<String> = 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<Vec<ExternalInvitation>> {
|
|
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<Vec<Bookmark>> {
|
|
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<InvitationDetailsResponse> {
|
|
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<IncomingInvitation> {
|
|
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<String> {
|
|
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>, 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<Vec<ExternalInvitation>> {
|
|
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<ModulusResponse> {
|
|
// 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<Option<ShareId>> {
|
|
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<Vec<ShareMember>> {
|
|
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<Vec<ShareInvitation>> {
|
|
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<Vec<Device>> {
|
|
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<Device> {
|
|
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<Device> {
|
|
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<proton_sdk::api::ApiResponse> =
|
|
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<Vec<DeviceMetadata>> {
|
|
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<DeviceMetadata> {
|
|
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<String> {
|
|
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<AddressKeyId> {
|
|
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<RevisionDraft> {
|
|
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<FileCreationResponse> = 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::<RevisionConflict>(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<RevisionDraft> {
|
|
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<RevisionCreationResponse> = 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::<RevisionConflict>(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<String> = 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<R: Read + Send>(
|
|
&self,
|
|
draft: &RevisionDraft,
|
|
mut reader: R,
|
|
mut thumbnails: Vec<Thumbnail>,
|
|
) -> Result<BlockWriteResult> {
|
|
// 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::<Result<Vec<_>>>()?;
|
|
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<R: Read + Send>(
|
|
&self,
|
|
draft: &RevisionDraft,
|
|
reader: &mut R,
|
|
verification_code: &[u8],
|
|
tx: mpsc::Sender<PreparedBlock>,
|
|
) -> Result<(Vec<i32>, 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<PreparedBlock>,
|
|
thumbnails: Vec<EncryptedThumbnail>,
|
|
) -> Result<Vec<(i32, [u8; 32])>> {
|
|
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<PreparedBlock> = 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<Semaphore>,
|
|
downshifted: &Arc<AtomicBool>,
|
|
batch: Vec<PreparedBlock>,
|
|
thumbnails: Vec<EncryptedThumbnail>,
|
|
) -> Result<Vec<(i32, [u8; 32])>> {
|
|
let requests: Vec<BlockCreationRequest> =
|
|
batch.iter().map(|block| block.request.clone()).collect();
|
|
let thumbnail_requests: Vec<ThumbnailCreationRequest> = 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<Option<(i32, [u8; 32])>> = 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<i64>,
|
|
photos_attributes: Option<PhotosAttributesDto>,
|
|
) -> 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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<W: std::io::Write>(
|
|
&self,
|
|
targets: Arc<BlockTargets>,
|
|
block_count: usize,
|
|
content_key: &ContentKey,
|
|
manifest: &mut Vec<u8>,
|
|
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<BlockDto>)> {
|
|
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<PrivateKey> {
|
|
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<PrivateKey> {
|
|
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<Option<PrivateKey>> {
|
|
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<bool> {
|
|
{
|
|
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<PrivateKey> {
|
|
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<ShareId> {
|
|
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<Option<Node>> {
|
|
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<Option<Node>> {
|
|
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<Vec<Node>> {
|
|
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<Vec<PhotosTimelineItem>> {
|
|
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<LinkId> = 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<Vec<NodeUid>> {
|
|
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<Vec<Vec<NodeUid>>> {
|
|
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<String, Vec<usize>> = HashMap::new();
|
|
let mut name_hashes: Vec<String> = Vec::new();
|
|
let mut content_hashes: Vec<String> = 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<Vec<bool>> {
|
|
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<String, Vec<usize>> = HashMap::new();
|
|
let mut name_hashes: Vec<String> = 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<NodeUid> {
|
|
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<Vec<(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<PhotoTargetPayload> {
|
|
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<Vec<(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<Option<Result<()>>> = 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<usize> = (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<proton_sdk::api::ApiResponse> =
|
|
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<Vec<(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<Vec<(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<NodeUid>)> = photo_uids
|
|
.iter()
|
|
.cloned()
|
|
.map(|uid| (uid, Vec::new()))
|
|
.collect();
|
|
let mut retried: HashSet<NodeUid> = HashSet::new();
|
|
|
|
while !queue.is_empty() {
|
|
let mut same_volume: Vec<PhotoTargetPayload> = Vec::new();
|
|
let mut cross_volume: Vec<PhotoTargetPayload> = 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<PhotoTargetPayload> = 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<PhotoTargetPayload> {
|
|
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<NodeUid> = 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<NodeUid>)>,
|
|
retried: &mut HashSet<NodeUid>,
|
|
outcomes: &mut Vec<(NodeUid, Result<()>)>,
|
|
) -> Result<()> {
|
|
let response: TransferPhotosResponse = match target.kind {
|
|
PhotoTransferKind::Timeline => {
|
|
let mut links: Vec<TransferPhotoLinkItem> = 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<Vec<TransferPhotoLinkItem>> = 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<AddPhotoToAlbumEntry> = 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<NodeUid>)>,
|
|
retried: &mut HashSet<NodeUid>,
|
|
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<String, (Vec<NodeUid>, 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<NodeUid> = 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<NodeUid>)>,
|
|
retried: &mut HashSet<NodeUid>,
|
|
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<Vec<NodeUid>> {
|
|
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<LinkId> = 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<Vec<AlbumItem>> {
|
|
let mut timer = self.telemetry.start("enumerate_album");
|
|
let mut items = Vec::new();
|
|
let mut anchor: Option<LinkId> = 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<Vec<NodeUid>> {
|
|
let mut timer = self.telemetry.start("enumerate_shared_with_me_album_uids");
|
|
let mut uids = Vec::new();
|
|
let mut anchor: Option<LinkId> = 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<Vec<NodeUid>> {
|
|
let mut timer = self
|
|
.telemetry
|
|
.start("enumerate_photos_shared_with_me_node_uids");
|
|
let mut uids = Vec::new();
|
|
let mut anchor: Option<String> = 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<Vec<(NodeUid, 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<PhotoUpdateResult>`). 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<Item = Result<(NodeUid, Result<()>)>> + 'a {
|
|
let updates: VecDeque<PhotoTagsUpdate> = 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<Option<VolumeId>> = None;
|
|
|
|
stream::unfold(
|
|
(updates, own_photos_volume),
|
|
move |(mut updates, own_photos_volume): (VecDeque<_>, Option<Option<VolumeId>>)| 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<i32> = 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<i32> = 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<W: std::io::Write>(
|
|
&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<PrivateKey> {
|
|
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<PrivateKey> {
|
|
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<PrivateKey> {
|
|
// 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<PrivateKey> {
|
|
let mut ancestry: Vec<LinkDto> = Vec::new();
|
|
let mut current = link.parent_id.clone();
|
|
let mut base_key: Option<PrivateKey> = 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<LinkId, LinkDto> = 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<LinkId> {
|
|
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<LinkDetailsResponse> {
|
|
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<LinkDetailsResponse> {
|
|
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<Node> {
|
|
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<Node> {
|
|
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<ShareId> {
|
|
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<Option<String>> {
|
|
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<String> {
|
|
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<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
|
|
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<i32>) -> 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<Item = &str> {
|
|
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<Item = &'a str>) -> 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<LinkId>)> {
|
|
let mut groups: Vec<(VolumeId, Vec<LinkId>)> = 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<ParentKeyId, K>
|
|
where
|
|
F: FnMut(&'a LinkDto) -> Fut,
|
|
Fut: Future<Output = Result<K>>,
|
|
{
|
|
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<DriveEvent> {
|
|
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<Item = String> + 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<Item = String> + 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<String>,
|
|
/// The name the node carries now, decrypted.
|
|
current_name: Vec<u8>,
|
|
}
|
|
|
|
/// 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<PreparedMove>,
|
|
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<LinkId>,
|
|
current_name: Vec<u8>,
|
|
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<PreparedMove>) -> Vec<PreparedMove> {
|
|
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<u8> {
|
|
let mut mac = <Hmac<Sha256>>::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<u8> {
|
|
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<u8> {
|
|
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<EncryptedThumbnail> {
|
|
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<BlockCreationRequest>,
|
|
thumbnails: Vec<ThumbnailCreationRequest>,
|
|
) -> Result<BlockUploadPreparationResponse> {
|
|
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<BlockUploadTarget>,
|
|
index: i32,
|
|
) -> Option<BlockUploadTarget> {
|
|
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<BlockUploadTarget>,
|
|
thumbnail_type: i32,
|
|
) -> Option<BlockUploadTarget> {
|
|
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<Option<(i32, [u8; 32])>> {
|
|
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<F, T>(
|
|
gate: Arc<RwLock<()>>,
|
|
cache: Arc<Mutex<DriveCache>>,
|
|
request: F,
|
|
) -> Result<T>
|
|
where
|
|
F: Future<Output = Result<T>> + 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<TransferPhotoLinkItem> {
|
|
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<NodeUid> {
|
|
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<NodeUid> {
|
|
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<String> = 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<NodeUid>, ProtonError)>,
|
|
queue: &mut VecDeque<(NodeUid, Vec<NodeUid>)>,
|
|
retried: &mut HashSet<NodeUid>,
|
|
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<u8>,
|
|
/// 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<NodeUid>,
|
|
/// 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<String>,
|
|
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<PhotoTargetPayload>,
|
|
}
|
|
|
|
/// 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<SharedWithMeItem> {
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>(), 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<String> = 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<String> = 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<String> = 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<u8> = 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<i32>, thumbnail_type: Option<i32>, 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<ParentKeyId, String>, Vec<String>) {
|
|
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<ParentKeyId, String>,
|
|
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}]}"#
|
|
)));
|
|
}
|
|
}
|