Files
proton-sdk-rs/crates/proton-drive-sdk/src/client.rs
T

2464 lines
98 KiB
Rust

//! The high-level Drive client and its read operations.
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::sync::Arc;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use proton_sdk::account::AccountClient;
use proton_sdk::cache::{CacheRepository, InMemoryCacheRepository};
use proton_sdk::crypto::PrivateKey;
use proton_sdk::error::{ProtonError, Result};
use proton_sdk::http::ApiHttpClient;
use proton_sdk::crypto::{
generate_node_hash_key, generate_node_key, verify_detached, ContentKey, VerificationKeyRing,
VerificationStatus,
};
use proton_sdk::ids::{AddressId, DriveEventId, LinkId, NodeUid, ShareId, VolumeId};
use proton_sdk::session::ProtonApiSession;
use proton_sdk::telemetry::{NoopTelemetry, Telemetry, TelemetryExt};
use hmac::{Hmac, Mac};
use sha1::Sha1;
use crate::crypto::{
decrypt_content_key_verified, decrypt_extended_attributes_verified, decrypt_link,
decrypt_link_verified, decrypt_share_key,
};
use crate::dtos::{
AggregateLinksResponse, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest,
BlockUploadPreparationResponse, BlockVerificationInputResponse, BlockVerifier,
CommonExtendedAttributes, ExtendedAttributes, FileContentDigests, FileCreationRequest,
FileCreationResponse, FolderChildrenResponse, FolderCreationRequest, FolderCreationResponse,
LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, LinkType, MoveLinkRequest,
MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest,
NodeNameAvailabilityResponse, RenameLinkRequest, RevisionCreationRequest,
RevisionCreationResponse, RevisionDto, RevisionResponse, RevisionUpdateRequest,
LatestVolumeEventResponse, PhotosAttributesDto, ThumbnailBlockListRequest,
ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse,
VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
};
use crate::photos::{PhotoUploadMetadata, PhotosTimelineItem};
use crate::cache::DriveEntityCache;
use crate::events::DriveEvent;
use crate::node::{Node, NodeKind, Thumbnail, ThumbnailType};
/// Content blocks are 4 MiB of plaintext each (C# `RevisionWriter.DefaultBlockSize`).
const DEFAULT_BLOCK_SIZE: usize = 1 << 22;
/// Maximum links per batch trash/restore/delete request (C#
/// `NodeOperations.MaximumBatchCount`).
const MAX_BATCH_COUNT: usize = 150;
/// 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;
/// Candidate name hashes checked per `checkAvailableHashes` request (C#
/// `NodeOperations.GetAvailableNameAsync` `batchSize`).
const NAME_AVAILABILITY_BATCH: usize = 10;
/// 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>,
}
#[derive(Default)]
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.
folder_keys: HashMap<NodeUid, PrivateKey>,
}
/// 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,
}
#[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().clone(),
account: AccountClient::new(session, mailbox_password),
cache: Arc::new(Mutex::new(DriveCache::default())),
entities: DriveEntityCache::new(entity_repository),
telemetry: NoopTelemetry::shared(),
}
}
/// 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
}
/// 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"))
}
/// Fetch a single node's decrypted metadata, or `None` if it does not exist.
pub async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>> {
let mut timer = self.telemetry.start("get_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.
timer.success();
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).await?;
timer.success();
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)
}
/// 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>> {
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(&volume_id, chunk).await?;
for detail in &details.links {
let parent_key = match self.resolve_parent_key(&volume_id, &detail.link).await {
Ok(key) => key,
Err(e) => {
tracing::warn!(link_id = %detail.link.id, error = %e, "skipping node: parent key unavailable");
continue;
}
};
match self.build_node(&volume_id, detail, &parent_key).await {
Ok(node) => nodes.push(node),
Err(e) => {
tracing::warn!(link_id = %detail.link.id, error = %e, "skipping undecryptable node");
}
}
}
}
}
Ok(nodes)
}
/// 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.
///
/// The caller persists the last returned event's [`id`](DriveEvent::id) as
/// the next cursor.
pub async fn enumerate_events(
&self,
volume_id: &VolumeId,
cursor: Option<&DriveEventId>,
) -> Result<Vec<DriveEvent>> {
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)
}
/// 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 currently best-effort: it is enforced
/// only when the node key is the signer; address-key signatures require
/// public-key resolution (`core/v4/keys/all`), not yet implemented.
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
.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);
}
}
for block in &blocks {
let ciphertext = self
.http
.get_storage_blob(&block.bare_url, &block.token)
.await?;
let digest = Sha256::digest(&ciphertext);
let plaintext = content_key.decrypt_block(&ciphertext)?;
output
.write_all(&plaintext)
.map_err(|e| ProtonError::invalid_operation(format!("write block: {e}")))?;
manifest.extend_from_slice(&digest);
}
verify_manifest(&self.account, &revision, &node_key, &manifest).await;
timer.success();
Ok(())
}
/// 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>>> {
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
.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(&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)?;
// 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());
let thumbnail_id = match thumbnail_id {
Some(id) => id,
None => return Ok(None),
};
let response: ThumbnailBlockListResponse = self
.http
.post(
&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_block(&ciphertext)?;
Ok(Some(plaintext))
}
/// 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> {
self.upload_file_from(
parent_uid,
name,
media_type,
Cursor::new(contents),
contents.len() as i64,
Vec::new(),
false,
)
.await
}
/// 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.
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>,
aead: bool,
) -> Result<NodeUid> {
let mut timer = self.telemetry.start("upload_file");
timer.attr("aead", aead);
let draft = self
.create_file_draft(parent_uid, name, media_type, intended_size, aead, 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);
self.seal_revision(&draft, &written, 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<()> {
self.upload_new_revision_from(
file_uid,
Cursor::new(contents),
contents.len() as i64,
Vec::new(),
)
.await
}
/// 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>,
) -> 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, 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.
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(&parent_uid, name, media_type, intended_size, aead, true)
.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, 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) -> Result<NodeUid> {
let mut timer = self.telemetry.start("create_folder");
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)?;
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,
};
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 (`name`, `name (1)`, `name (2)`, …) 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. 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) -> 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 `name (1)`, `name (2)`…
let mut candidates = std::iter::once(name.to_string()).chain(alternate_names(name));
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(&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`. `MIMEType` is the file's
/// media type (unchanged) or `null` for a folder. Live validation pending.
pub async fn rename_node(&self, uid: &NodeUid, new_name: &str) -> Result<()> {
let mut timer = self.telemetry.start("rename_node");
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 = detail.file.as_ref().map(|f| f.media_type.clone());
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 needs `NewShareID` +
/// re-signing). 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 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 move the root node"))?;
let source_parent_uid = NodeUid::new(uid.volume_id.clone(), parent_id);
// Source side: decrypt the current name and hash it under the source's
// hash key (the request's `OriginalHash`).
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 name = source_parent_key.decrypt_armored_message(&link.name)?;
// Prefer the cached/server name hash (C# `CachedNodeInfo.NameHashDigest`);
// the decrypted name is needed below for re-encryption regardless, so the
// fallback recompute here is free.
let original_hash = match self.cached_original_name_hash(uid, &link).await? {
Some(hash) => hash,
None => hex::encode(hmac_sha256(&source_hash_key, &name)),
};
// Destination side: rewrap the passphrase, re-encrypt + sign the name,
// and hash it under the destination's hash key.
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 passphrase = source_parent_key.rewrap_message_to(&link.passphrase, &dest_parent_key)?;
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));
let request = MoveLinkRequest {
parent_link_id: new_parent.link_id.clone(),
passphrase,
// The rewrap preserves the plaintext, so the signature is unchanged.
passphrase_signature: link.passphrase_signature,
name: encrypted_name,
name_signature_email: email,
name_hash,
original_hash,
};
let path = format!("v2/volumes/{}/links/{}/move", uid.volume_id, uid.link_id);
let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?;
timer.success();
Ok(())
}
/// Move `uids` to the trash. Mirrors C# `NodeOperations.TrashAsync`
/// (`POST v2/volumes/{vid}/trash_multiple`). Live validation pending.
pub async fn trash_nodes(&self, uids: &[NodeUid]) -> Result<()> {
let mut timer = self.telemetry.start("trash_nodes");
timer.attr("node_count", uids.len());
for (volume_id, link_ids) in group_by_volume(uids) {
for chunk in link_ids.chunks(MAX_BATCH_COUNT) {
let path = format!("v2/volumes/{volume_id}/trash_multiple");
let body = MultipleLinksRequest { link_ids: chunk };
let response: AggregateLinksResponse = self.http.post(&path, &body).await?;
check_aggregate("trash", response)?;
}
}
timer.success();
Ok(())
}
/// Restore `uids` from the trash. Mirrors C#
/// `NodeOperations.RestoreFromTrashAsync`
/// (`PUT v2/volumes/{vid}/trash/restore_multiple`). Live validation pending.
pub async fn restore_nodes(&self, uids: &[NodeUid]) -> Result<()> {
let mut timer = self.telemetry.start("restore_nodes");
timer.attr("node_count", uids.len());
for (volume_id, link_ids) in group_by_volume(uids) {
for chunk in link_ids.chunks(MAX_BATCH_COUNT) {
let path = format!("v2/volumes/{volume_id}/trash/restore_multiple");
let body = MultipleLinksRequest { link_ids: chunk };
let response: AggregateLinksResponse = self.http.put(&path, &body).await?;
check_aggregate("restore", response)?;
}
}
timer.success();
Ok(())
}
/// Permanently delete `uids` (which must already be in the trash). Mirrors
/// C# `NodeOperations.DeleteFromTrashAsync`
/// (`POST v2/volumes/{vid}/trash/delete_multiple`). Live validation pending.
pub async fn delete_nodes(&self, uids: &[NodeUid]) -> Result<()> {
let mut timer = self.telemetry.start("delete_nodes");
timer.attr("node_count", uids.len());
for (volume_id, link_ids) in group_by_volume(uids) {
for chunk in link_ids.chunks(MAX_BATCH_COUNT) {
let path = format!("v2/volumes/{volume_id}/trash/delete_multiple");
let body = MultipleLinksRequest { link_ids: chunk };
let response: AggregateLinksResponse = self.http.post(&path, &body).await?;
check_aggregate("delete", response)?;
}
}
timer.success();
Ok(())
}
/// 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(())
}
// ---- internals ---------------------------------------------------------
/// 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,
parent_uid: &NodeUid,
name: &str,
media_type: &str,
intended_upload_size: i64,
aead: bool,
for_photos: bool,
) -> Result<RevisionDraft> {
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.
let node = generate_node_key()?;
let content_key = if aead {
ContentKey::generate_aead()
} else {
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 created: FileCreationResponse = self.http.post(&create_path, &create_request).await?;
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 request = RevisionCreationRequest {
current_revision_id: active_revision_id,
client_uid: Some(self.http.session_id().to_string()),
intended_upload_size,
};
let path = format!("v2/volumes/{volume_id}/files/{}/revisions", file_uid.link_id);
let created: RevisionCreationResponse = self.http.post(&path, &request).await?;
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(),
})
}
/// Encrypt, sign, verify and upload every content block of a draft revision,
/// accumulating the content manifest and extended-attribute metadata.
///
/// Thumbnails (if any) are uploaded first, in `ThumbnailType` order, and
/// their ciphertext digests lead the manifest — mirroring C#
/// `RevisionWriter.UploadBlocksAsync` (thumbnails then content blocks) and
/// the download path's manifest ordering. Then `reader` is streamed one
/// [`DEFAULT_BLOCK_SIZE`] block at a time: only a single plaintext block
/// (plus its ciphertext) is held in memory, and the SHA-1 digest and
/// total-size counter are folded incrementally. 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?;
let mut manifest = Vec::new();
// Thumbnail digests lead the manifest, ordered by thumbnail type to
// match the download path's `sort_by_key(thumbnail_type)`.
thumbnails.sort_by_key(|t| t.thumbnail_type);
for thumbnail in &thumbnails {
let digest = self.upload_thumbnail(draft, thumbnail).await?;
manifest.extend_from_slice(&digest);
}
let mut block_sizes = Vec::new();
let mut sha1 = Sha1::new();
let mut total_size: i64 = 0;
let mut buf = vec![0u8; DEFAULT_BLOCK_SIZE];
// Block indices are 1-based (C# `blockNumber = i + 1`).
let mut index = 1_i32;
loop {
let n = read_full_block(&mut reader, &mut buf)?;
if n == 0 {
break;
}
let chunk = &buf[..n];
sha1.update(chunk);
total_size += n as i64;
let ciphertext = draft.content_key.encrypt_block(chunk)?;
let digest = Sha256::digest(&ciphertext);
let token = verification_token(&verification_code, &ciphertext);
// Detached signature over the plaintext, then encrypted to the node key.
let plaintext_signature = draft.signing_key.sign_detached(chunk)?;
let encrypted_signature = draft.node_key.encrypt(plaintext_signature.as_bytes())?;
let prepare = BlockUploadPreparationRequest {
address_id: draft.address_id.clone(),
volume_id: draft.volume_id.clone(),
link_id: draft.link_id.clone(),
revision_id: draft.revision_id.clone(),
blocks: vec![BlockCreationRequest {
index,
size: ciphertext.len() as i32,
encrypted_signature,
hash: BASE64.encode(digest),
verifier: BlockVerifier {
token: BASE64.encode(&token),
},
}],
thumbnails: Vec::new(),
};
let prepared: BlockUploadPreparationResponse =
self.http.post("blocks", &prepare).await?;
let target = prepared.upload_targets.into_iter().next().ok_or_else(|| {
ProtonError::invalid_operation("block upload preparation returned no target")
})?;
self.http
.post_storage_blob(&target.bare_url, &target.token, ciphertext)
.await?;
manifest.extend_from_slice(&digest);
block_sizes.push(n as i32);
index += 1;
}
Ok(BlockWriteResult {
manifest,
block_sizes,
total_size,
sha1_hex: hex::encode(sha1.finalize()),
})
}
/// Encrypt, hash and upload a single thumbnail block, returning its
/// ciphertext SHA-256 digest (a manifest entry).
///
/// Mirrors C# `BlockUploader.UploadThumbnailAsync`: the thumbnail is
/// encrypt-and-inline-signed under the content key, prepared as its own
/// upload request (no content blocks), and uploaded to the returned
/// thumbnail target.
async fn upload_thumbnail(
&self,
draft: &RevisionDraft,
thumbnail: &Thumbnail,
) -> Result<[u8; 32]> {
let ciphertext = draft
.content_key
.encrypt_thumbnail(&draft.signing_key, &thumbnail.content)?;
let digest = Sha256::digest(&ciphertext);
let prepare = BlockUploadPreparationRequest {
address_id: draft.address_id.clone(),
volume_id: draft.volume_id.clone(),
link_id: draft.link_id.clone(),
revision_id: draft.revision_id.clone(),
blocks: Vec::new(),
thumbnails: vec![ThumbnailCreationRequest {
size: ciphertext.len() as i32,
thumbnail_type: thumbnail.thumbnail_type.as_i32(),
hash: BASE64.encode(digest),
}],
};
let prepared: BlockUploadPreparationResponse = self.http.post("blocks", &prepare).await?;
let target = prepared
.thumbnail_upload_targets
.into_iter()
.next()
.ok_or_else(|| {
ProtonError::invalid_operation("thumbnail upload preparation returned no target")
})?;
self.http
.post_storage_blob(&target.bare_url, &target.token, ciphertext)
.await?;
Ok(digest.into())
}
/// 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,
photos_attributes: Option<PhotosAttributesDto>,
) -> Result<()> {
let manifest_signature = draft.signing_key.sign_detached(&written.manifest)?;
let extended_attributes = ExtendedAttributes {
common: CommonExtendedAttributes {
size: written.total_size,
block_sizes: written.block_sizes.clone(),
digests: 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
}
/// 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 folder = details
.links
.into_iter()
.next()
.and_then(|d| d.folder)
.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.
async fn fetch_revision_blocks(
&self,
volume_id: &VolumeId,
link_id: &LinkId,
revision_id: &str,
) -> Result<(RevisionDto, Vec<BlockDto>)> {
const PAGE_SIZE: i32 = 50;
let mut blocks: Vec<BlockDto> = Vec::new();
let mut metadata: Option<RevisionDto> = None;
let mut from_index: i32 = 1;
loop {
let path = format!(
"v2/volumes/{volume_id}/files/{link_id}/revisions/{revision_id}?FromBlockIndex={from_index}&PageSize={PAGE_SIZE}&NoBlockUrls=0"
);
let response: RevisionResponse = self.http.get(&path).await?;
let mut revision = response.revision;
let page = std::mem::take(&mut revision.blocks);
let page_len = page.len();
if metadata.is_none() {
metadata = Some(revision);
}
blocks.extend(page);
if page_len < PAGE_SIZE as usize {
break;
}
from_index = blocks.iter().map(|b| b.index).max().unwrap_or(from_index) + 1;
}
blocks.sort_by_key(|b| b.index);
for (offset, block) in blocks.iter().enumerate() {
if block.index != offset as i32 + 1 {
return Err(ProtonError::invalid_operation(
"file contents are incomplete (non-contiguous blocks)",
));
}
}
let metadata = metadata
.ok_or_else(|| ProtonError::invalid_operation("revision returned no metadata"))?;
Ok((metadata, blocks))
}
async fn ensure_my_files(&self) -> Result<()> {
if self.cache.lock().await.my_files_share.is_some() {
return Ok(());
}
let response: MyFilesShareResponse = self.http.get("v2/shares/my-files").await?;
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?;
// 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
.insert(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(())
}
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())
}
/// 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);
}
}
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.insert(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)
.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)
.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)
}
/// 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
.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);
}
}
for block in &blocks {
let ciphertext = self
.http
.get_storage_blob(&block.bare_url, &block.token)
.await?;
let digest = Sha256::digest(&ciphertext);
let plaintext = content_key.decrypt_block(&ciphertext)?;
output
.write_all(&plaintext)
.map_err(|e| ProtonError::invalid_operation(format!("write block: {e}")))?;
manifest.extend_from_slice(&digest);
}
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
.insert(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> {
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 {
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;
}
let details = self
.get_link_details_ctx(volume_id, std::slice::from_ref(&parent_id), for_photos)
.await?;
let ancestor = details
.links
.into_iter()
.next()
.ok_or_else(|| ProtonError::invalid_operation(format!("ancestor {uid} not found")))?
.link;
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 => self.root_share_key().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
.insert(uid, decrypted.node_key.clone());
key = decrypted.node_key;
}
Ok(key)
}
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(&path, &body).await
}
async fn build_node(
&self,
volume_id: &VolumeId,
details: &LinkDetailsDto,
parent_key: &PrivateKey,
) -> Result<Node> {
self.build_node_ctx(volume_id, details, parent_key, false)
.await
}
async fn build_node_ctx(
&self,
volume_id: &VolumeId,
details: &LinkDetailsDto,
parent_key: &PrivateKey,
for_photos: bool,
) -> Result<Node> {
let link = &details.link;
let (decrypted, mut verification) =
decrypt_link_verified(&self.account, parent_key, link).await?;
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));
let kind = match link.parsed_type() {
LinkType::Folder | LinkType::Album => {
// Cache the folder's node key for later child enumeration.
self.cache
.lock()
.await
.folder_keys
.insert(uid.clone(), decrypted.node_key.clone());
NodeKind::Folder
}
LinkType::File => {
let file = details.file.as_ref().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(packet_b64) = file.content_key_packet.as_deref() {
match BASE64.decode(packet_b64) {
Ok(packet) => {
match decrypt_content_key_verified(
&self.account,
&decrypted.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;
if let Some(rev) = file.active_revision.as_ref() {
if let Some(xattr) = rev.extended_attributes.as_deref() {
match decrypt_extended_attributes_verified(
&self.account,
&decrypted.node_key,
rev.signature_email.as_deref(),
xattr,
)
.await
{
Ok((attrs, status)) => {
verification.extended_attributes = Some(status);
if let Some(common) = attrs.common {
claimed_size = common.size;
claimed_modification_time = common.modification_time;
}
}
Err(e) => {
tracing::warn!(link_id = %link.id, error = %e, "failed to decrypt extended attributes");
}
}
}
}
NodeKind::File {
media_type: file.media_type.clone(),
total_size_on_storage: file.total_size_on_storage,
claimed_size,
claimed_modification_time,
}
}
LinkType::Unknown => {
return Err(ProtonError::invalid_operation(format!(
"unsupported link type {}",
link.link_type
)));
}
};
let node = Node {
uid: uid.clone(),
parent_uid,
kind,
name: decrypted.name,
creation_time: link.creation_time,
modification_time: link.modification_time,
trashed: link.is_trashed(),
signature_email: link.signature_email.clone(),
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? {
if !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, &current_name)))
}
}
/// 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)
}
/// 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
}
/// 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 | 2 | 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 name: `name (1).ext`,
/// `name (2).ext`, … (C# `AlternateFileNameGenerator.GetNames`). The extension
/// is everything from the last `.`, matching .NET `Path.GetFileNameWithoutExtension`.
fn alternate_names(original: &str) -> impl Iterator<Item = String> + '_ {
let (stem, ext) = match original.rfind('.') {
Some(idx) => original.split_at(idx),
None => (original, ""),
};
(1..).map(move |i| format!("{stem} ({i}){ext}"))
}
/// Fail if any per-link response in a batch aggregate carries a non-success
/// code (the top-level envelope is `MultipleResponses`, so the real status is
/// per link). `op` names the operation for the error message.
fn check_aggregate(op: &str, response: AggregateLinksResponse) -> Result<()> {
let failures: Vec<String> = response
.responses
.iter()
.filter(|pair| !pair.response.is_success())
.map(|pair| format!("{} ({:?})", pair.link_id, pair.response.code))
.collect();
if failures.is_empty() {
Ok(())
} else {
Err(ProtonError::invalid_operation(format!(
"{op} failed for {} link(s): {}",
failures.len(),
failures.join(", ")
)))
}
}
/// 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(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
});
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()
}
/// 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()
}
#[cfg(test)]
mod tests {
use super::{alternate_names, to_drive_event, DriveEvent};
use crate::dtos::{VolumeEventDto, VolumeEventLinkDto};
use proton_sdk::ids::{DriveEventId, LinkId, VolumeId};
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 alternate_names_keeps_extension() {
let got: Vec<String> = alternate_names("report.txt").take(3).collect();
assert_eq!(got, ["report (1).txt", "report (2).txt", "report (3).txt"]);
}
#[test]
fn alternate_names_no_extension() {
let got: Vec<String> = alternate_names("folder").take(2).collect();
assert_eq!(got, ["folder (1)", "folder (2)"]);
}
#[test]
fn alternate_names_uses_last_dot_only() {
// .NET `Path.GetFileNameWithoutExtension` strips only the final segment.
let first = alternate_names("archive.tar.gz").next().unwrap();
assert_eq!(first, "archive.tar (1).gz");
}
#[test]
fn photos_attributes_content_hash_and_tags() {
use super::{build_photos_attributes, hmac_sha256, BlockWriteResult};
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::{build_photos_attributes, BlockWriteResult};
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());
}
}