From d67c294a4bec56b86c413aec604b824a140e8296 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 25 Jun 2026 15:54:03 +0200 Subject: [PATCH] feat(drive/models): implement caching, sync events, and photos models --- crates/proton-drive-sdk/src/cache.rs | 220 ++++++++++++++++++++++++ crates/proton-drive-sdk/src/crypto.rs | 199 ++++++++++++++++++++- crates/proton-drive-sdk/src/dtos.rs | 238 +++++++++++++++++++++++++- crates/proton-drive-sdk/src/events.rs | 55 ++++++ crates/proton-drive-sdk/src/lib.rs | 9 + crates/proton-drive-sdk/src/node.rs | 61 ++++++- crates/proton-drive-sdk/src/photos.rs | 235 +++++++++++++++++++++++++ 7 files changed, 1012 insertions(+), 5 deletions(-) create mode 100644 crates/proton-drive-sdk/src/cache.rs create mode 100644 crates/proton-drive-sdk/src/events.rs create mode 100644 crates/proton-drive-sdk/src/photos.rs diff --git a/crates/proton-drive-sdk/src/cache.rs b/crates/proton-drive-sdk/src/cache.rs new file mode 100644 index 0000000..c1f537a --- /dev/null +++ b/crates/proton-drive-sdk/src/cache.rs @@ -0,0 +1,220 @@ +//! Drive entity cache — typed, persistable view over a [`CacheRepository`]. +//! +//! Mirrors the C# `Proton.Drive.Sdk.Caching.DriveEntityCache`: it serializes +//! typed Drive entities (the client UID, the main volume id, the My Files share +//! id, and per-node [`CachedNodeInfo`]) to JSON strings stored under stable keys +//! in a generic [`CacheRepository`]. Backing the repository with an +//! [`EncryptedCacheRepository`](proton_sdk::cache::EncryptedCacheRepository) or +//! an on-disk implementation makes the cache persistent without changing this +//! layer. +//! +//! The decrypted node *secrets* (PGP node keys, hash keys) are **not** stored +//! here — they live in the client's in-memory secret cache, matching the split +//! between C# `DriveEntityCache` and `DriveSecretCache`. + +use std::sync::Arc; + +use proton_sdk::cache::CacheRepository; +use proton_sdk::error::Result; +use proton_sdk::ids::{NodeUid, ShareId, VolumeId}; +use serde::{Deserialize, Serialize}; + +use crate::node::Node; + +/// A cached node plus the two derived values that node-mutating operations +/// (move / rename) would otherwise recompute from the decrypted name. +/// +/// Mirrors C# `CachedNodeInfo`: the node itself, the id of the share whose +/// membership signs operations on it, and its name-hash digest under its +/// parent's hash key (the `OriginalHash` of a subsequent move/rename). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedNodeInfo { + pub node: Node, + /// Share whose membership address signs operations on this node, if known. + pub membership_share_id: Option, + /// Lowercase-hex HMAC-SHA256 name hash under the parent's hash key. + pub name_hash_digest: String, +} + +const CLIENT_UID_KEY: &str = "client:id"; +const MAIN_VOLUME_ID_KEY: &str = "volume:main:id"; +const MY_FILES_SHARE_ID_KEY: &str = "share:my-files:id"; + +fn node_key(uid: &NodeUid) -> String { + format!("node:{uid}") +} + +/// Typed entity cache over a shared [`CacheRepository`]. Cloning shares the +/// underlying store. +#[derive(Clone)] +pub struct DriveEntityCache { + repo: Arc, +} + +impl DriveEntityCache { + /// Build an entity cache over `repo`. + pub fn new(repo: Arc) -> Self { + Self { repo } + } + + /// The client UID used to tag this client's own writes (C# + /// `TryGetClientUidAsync`). + pub async fn client_uid(&self) -> Result> { + self.repo.get(CLIENT_UID_KEY).await + } + + /// Persist the client UID. + pub async fn set_client_uid(&self, client_uid: &str) -> Result<()> { + self.repo.set(CLIENT_UID_KEY, client_uid, &[]).await + } + + /// The cached main (My Files) volume id. + pub async fn main_volume_id(&self) -> Result> { + self.get_json(MAIN_VOLUME_ID_KEY).await + } + + /// Persist the main volume id. + pub async fn set_main_volume_id(&self, volume_id: &VolumeId) -> Result<()> { + self.set_json(MAIN_VOLUME_ID_KEY, volume_id).await + } + + /// The cached My Files share id. + pub async fn my_files_share_id(&self) -> Result> { + self.get_json(MY_FILES_SHARE_ID_KEY).await + } + + /// Persist the My Files share id. + pub async fn set_my_files_share_id(&self, share_id: &ShareId) -> Result<()> { + self.set_json(MY_FILES_SHARE_ID_KEY, share_id).await + } + + /// Cache a node together with its membership share and name-hash digest + /// (C# `SetNodeAsync`). + pub async fn set_node( + &self, + uid: &NodeUid, + node: &Node, + membership_share_id: Option<&ShareId>, + name_hash_digest: &str, + ) -> Result<()> { + let info = CachedNodeInfo { + node: node.clone(), + membership_share_id: membership_share_id.cloned(), + name_hash_digest: name_hash_digest.to_owned(), + }; + self.set_json(&node_key(uid), &info).await + } + + /// Fetch a cached node (C# `TryGetNodeAsync`). + pub async fn try_get_node(&self, uid: &NodeUid) -> Result> { + self.get_json(&node_key(uid)).await + } + + /// Evict a node (C# `RemoveNodeAsync`). + pub async fn remove_node(&self, uid: &NodeUid) -> Result<()> { + self.repo.remove(&node_key(uid)).await + } + + /// Drop every cached entry. + pub async fn clear(&self) -> Result<()> { + self.repo.clear().await + } + + async fn get_json Deserialize<'de>>(&self, key: &str) -> Result> { + let Some(raw) = self.repo.get(key).await? else { + return Ok(None); + }; + match serde_json::from_str(&raw) { + Ok(value) => Ok(Some(value)), + // A malformed entry is treated as a miss and evicted, matching C# + // `TryGetDeserializedValueAsync`. + Err(_) => { + self.repo.remove(key).await?; + Ok(None) + } + } + } + + async fn set_json(&self, key: &str, value: &T) -> Result<()> { + let serialized = serde_json::to_string(value)?; + self.repo.set(key, &serialized, &[]).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proton_sdk::cache::InMemoryCacheRepository; + use proton_sdk::ids::LinkId; + + use crate::node::NodeKind; + + fn uid(v: &str, l: &str) -> NodeUid { + NodeUid::new(VolumeId::from(v), LinkId::from(l)) + } + + fn folder_node(uid: &NodeUid) -> Node { + Node { + uid: uid.clone(), + parent_uid: None, + kind: NodeKind::Folder, + name: "Docs".into(), + creation_time: 1, + modification_time: 2, + trashed: false, + signature_email: None, + verification: crate::node::NodeVerification::default(), + } + } + + #[tokio::test] + async fn node_round_trips_with_membership_and_hash() { + let cache = DriveEntityCache::new(InMemoryCacheRepository::shared()); + let node_uid = uid("vol", "link"); + let node = folder_node(&node_uid); + let share = ShareId::from("share-1"); + + cache + .set_node(&node_uid, &node, Some(&share), "abc123") + .await + .unwrap(); + + let cached = cache.try_get_node(&node_uid).await.unwrap().unwrap(); + assert_eq!(cached.node.name, "Docs"); + assert_eq!(cached.membership_share_id, Some(share)); + assert_eq!(cached.name_hash_digest, "abc123"); + + cache.remove_node(&node_uid).await.unwrap(); + assert!(cache.try_get_node(&node_uid).await.unwrap().is_none()); + } + + #[tokio::test] + async fn scalar_entities_round_trip() { + let cache = DriveEntityCache::new(InMemoryCacheRepository::shared()); + assert!(cache.main_volume_id().await.unwrap().is_none()); + + cache.set_client_uid("client-xyz").await.unwrap(); + cache.set_main_volume_id(&VolumeId::from("vol-1")).await.unwrap(); + cache + .set_my_files_share_id(&ShareId::from("mf-share")) + .await + .unwrap(); + + assert_eq!(cache.client_uid().await.unwrap().as_deref(), Some("client-xyz")); + assert_eq!(cache.main_volume_id().await.unwrap(), Some(VolumeId::from("vol-1"))); + assert_eq!( + cache.my_files_share_id().await.unwrap(), + Some(ShareId::from("mf-share")) + ); + } + + #[tokio::test] + async fn malformed_entry_is_evicted_as_miss() { + let repo = InMemoryCacheRepository::shared(); + repo.set(&node_key(&uid("v", "l")), "not json", &[]).await.unwrap(); + let cache = DriveEntityCache::new(repo.clone()); + assert!(cache.try_get_node(&uid("v", "l")).await.unwrap().is_none()); + // The bad entry was removed. + assert!(repo.get(&node_key(&uid("v", "l"))).await.unwrap().is_none()); + } +} diff --git a/crates/proton-drive-sdk/src/crypto.rs b/crates/proton-drive-sdk/src/crypto.rs index 24fb067..d098c24 100644 --- a/crates/proton-drive-sdk/src/crypto.rs +++ b/crates/proton-drive-sdk/src/crypto.rs @@ -5,10 +5,14 @@ //! read milestone. use proton_sdk::account::AccountClient; -use proton_sdk::crypto::{decrypt_armored_with_keys, PrivateKey}; +use proton_sdk::crypto::{ + decrypt_armored_with_keys, verify_detached, ContentKey, PrivateKey, PublicKey, + VerificationKeyRing, VerificationStatus, +}; use proton_sdk::error::{ProtonError, Result}; -use crate::dtos::{LinkDto, ShareDto}; +use crate::dtos::{DecryptedExtendedAttributes, LinkDto, ShareDto}; +use crate::node::NodeVerification; /// Decrypt a share key: decrypt the share passphrase with the membership /// address keys, then unlock the share's secret key with it. @@ -31,6 +35,10 @@ pub struct DecryptedLink { /// Decrypt a link's passphrase and name using the parent node/share key, and /// unlock the node's key. +/// +/// Verification-free: used by parent-key resolution and the download path, +/// where only the unlocked node key (and name) is needed. Node-building goes +/// through [`decrypt_link_verified`] instead. pub fn decrypt_link(parent_key: &PrivateKey, link: &LinkDto) -> Result { let passphrase = parent_key.decrypt_armored_message(&link.passphrase)?; let node_key = PrivateKey::from_armored(&link.key, &passphrase)?; @@ -41,3 +49,190 @@ pub fn decrypt_link(parent_key: &PrivateKey, link: &LinkDto) -> Result, + keys: Vec, +} + +impl AuthorshipClaim { + /// Resolve the claimed author's verification keys (`core/v4/keys/all`). + /// Resolution failures are folded into an empty key set by the account + /// client, yielding [`VerificationStatus::NoVerifier`] rather than an error. + pub async fn create(account: &AccountClient, email: Option<&str>) -> Self { + match email { + Some(email) if !email.is_empty() => Self { + author: Some(email.to_string()), + keys: account.public_keys(email).await, + }, + _ => Self { + author: None, + keys: Vec::new(), + }, + } + } + + /// Verification ring for name/passphrase/xattr signatures: the author's + /// keys, or — when anonymous — the `fallback` (decryption) key's public half + /// (C# `AuthorshipClaim.GetKeyRing`). + fn ring(&self, fallback: &PrivateKey) -> VerificationKeyRing { + if self.author.is_some() { + VerificationKeyRing::from_public_keys(&self.keys) + } else { + VerificationKeyRing::from_private(fallback) + } + } + + /// Verification ring for content-key/hash-key signatures: the node key plus + /// any author keys (C# `GetContentKeyAndHashKeyVerificationKeyRing`). + fn content_ring(&self, node_key: &PrivateKey) -> VerificationKeyRing { + VerificationKeyRing::from_private_and_public_keys(node_key, &self.keys) + } +} + +/// Decrypt a link and verify its name + passphrase signatures. +/// +/// Mirrors the link half of C# `NodeCrypto.DecryptLinkAsync`: the node/passphrase +/// authorship is claimed by `SignatureEmail`, the name by `NameSignatureEmail` +/// (reusing the former when they match). Verification is non-fatal metadata. +pub async fn decrypt_link_verified( + account: &AccountClient, + parent_key: &PrivateKey, + link: &LinkDto, +) -> Result<(DecryptedLink, NodeVerification)> { + let node_claim = AuthorshipClaim::create(account, link.signature_email.as_deref()).await; + let name_claim = if link.name_signature_email == link.signature_email { + node_claim.clone() + } else { + AuthorshipClaim::create(account, link.name_signature_email.as_deref()).await + }; + + // Passphrase: decrypted with the parent key, signed (detached) by the node + // author; verify against the node claim (fallback = parent key). + let passphrase = parent_key.decrypt_armored_message(&link.passphrase)?; + let node_key = PrivateKey::from_armored(&link.key, &passphrase)?; + let passphrase_status = match &link.passphrase_signature { + Some(sig) => verify_detached(sig, &passphrase, &node_claim.ring(parent_key)), + None => VerificationStatus::NotSigned, + }; + + // Name: an inline-signed message addressed to the parent key. + let (name_bytes, name_status) = + parent_key.decrypt_armored_verify(&link.name, &name_claim.ring(parent_key))?; + let name = String::from_utf8(name_bytes) + .map_err(|e| ProtonError::invalid_operation(format!("node name is not valid UTF-8: {e}")))?; + + let verification = NodeVerification { + name: name_status, + passphrase: passphrase_status, + content_key: None, + extended_attributes: None, + }; + Ok((DecryptedLink { node_key, name }, verification)) +} + +/// Decrypt a file's content key and verify its `ContentKeyPacketSignature`. +/// +/// The signature is over the exported session key; the verification ring is the +/// node key plus any node-author keys (C# `NodeCrypto.DecryptContentKey`). +/// Returns the status alongside the key; verification is non-fatal. +pub async fn decrypt_content_key_verified( + account: &AccountClient, + node_key: &PrivateKey, + node_signature_email: Option<&str>, + content_key_packet: &[u8], + content_key_signature: Option<&str>, +) -> Result<(ContentKey, VerificationStatus)> { + let content_key = node_key.decrypt_content_key(content_key_packet)?; + let status = match content_key_signature { + Some(sig) => { + let claim = AuthorshipClaim::create(account, node_signature_email).await; + let exported = content_key.export()?; + verify_detached(sig, &exported, &claim.content_ring(node_key)) + } + None => VerificationStatus::NotSigned, + }; + Ok((content_key, status)) +} + +/// Decrypt a revision's extended attributes (`XAttr`) with the node key and +/// verify the inline signature against the revision's authorship claim +/// (`SignatureEmail`, fallback = node key). Returns the decoded attributes plus +/// the non-fatal status. +/// +/// Mirrors C# `NodeCrypto.DecryptExtendedAttributes`: the payload is a PGP +/// message encrypted to the node key (and signed); signature verification is +/// captured as metadata, not enforced. +pub async fn decrypt_extended_attributes_verified( + account: &AccountClient, + node_key: &PrivateKey, + revision_signature_email: Option<&str>, + armored_xattr: &str, +) -> Result<(DecryptedExtendedAttributes, VerificationStatus)> { + let claim = AuthorshipClaim::create(account, revision_signature_email).await; + let (json, status) = node_key.decrypt_armored_verify(armored_xattr, &claim.ring(node_key))?; + let attrs = serde_json::from_slice(&json).map_err(|e| { + ProtonError::invalid_operation(format!("deserialize extended attributes: {e}")) + })?; + Ok((attrs, status)) +} + +#[cfg(test)] +mod tests { + use super::*; + use proton_sdk::crypto::generate_node_key; + + /// Decrypt + deserialize XAttr without verification (the production path + /// uses [`decrypt_extended_attributes_verified`], which needs an account to + /// resolve author keys). + fn decrypt_xattr(node_key: &PrivateKey, armored: &str) -> DecryptedExtendedAttributes { + let json = node_key + .decrypt_armored_message(armored) + .expect("decrypt xattr"); + serde_json::from_slice(&json).expect("deserialize xattr") + } + + #[test] + fn extended_attributes_round_trip() { + let node = generate_node_key().expect("generate node key"); + + // Shape mirrors what other Proton clients write: Common.{Size, + // ModificationTime, BlockSizes, Digests.SHA1}. + let xattr = r#"{"Common":{"Size":4194321,"ModificationTime":"2024-01-02T03:04:05Z","BlockSizes":[4194304,17],"Digests":{"SHA1":"da39a3ee5e6b4b0d3255bfef95601890afd80709"}}}"#; + let armored = node + .key + .encrypt_and_sign(&node.key, xattr.as_bytes(), false, true) + .expect("encrypt xattr"); + + let decoded = decrypt_xattr(&node.key, &armored); + let common = decoded.common.expect("common present"); + assert_eq!(common.size, Some(4194321)); + assert_eq!( + common.modification_time.as_deref(), + Some("2024-01-02T03:04:05Z") + ); + assert_eq!(common.block_sizes, Some(vec![4194304, 17])); + assert_eq!( + common.digests.and_then(|d| d.sha1).as_deref(), + Some("da39a3ee5e6b4b0d3255bfef95601890afd80709") + ); + } + + #[test] + fn extended_attributes_tolerates_missing_fields() { + let node = generate_node_key().expect("generate node key"); + let armored = node + .key + .encrypt_and_sign(&node.key, b"{}", false, true) + .expect("encrypt xattr"); + + let decoded = decrypt_xattr(&node.key, &armored); + assert!(decoded.common.is_none()); + } +} diff --git a/crates/proton-drive-sdk/src/dtos.rs b/crates/proton-drive-sdk/src/dtos.rs index d3bcb8d..f1ac4f6 100644 --- a/crates/proton-drive-sdk/src/dtos.rs +++ b/crates/proton-drive-sdk/src/dtos.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; -use proton_sdk::ids::{AddressId, LinkId, ShareId, VolumeId}; +use proton_sdk::ids::{AddressId, DriveEventId, LinkId, ShareId, VolumeId}; /// `GET v2/shares/my-files` #[derive(Debug, Deserialize)] @@ -53,6 +53,45 @@ pub struct LinkDetailsResponse { pub links: Vec, } +/// `GET volumes/{vid}/photos` response (C# `TimelinePhotoListResponse`). +#[derive(Debug, Deserialize)] +pub struct TimelinePhotoListResponse { + #[serde(rename = "Photos")] + pub photos: Vec, +} + +/// One timeline entry (C# `TimelinePhotoDto`). Only the id + capture time are +/// consumed; the remaining fields are kept for wire fidelity. +#[derive(Debug, Deserialize)] +pub struct TimelinePhotoDto { + #[serde(rename = "LinkID")] + pub id: LinkId, + #[serde(rename = "CaptureTime")] + pub capture_time: i64, + #[serde(rename = "Hash")] + pub name_hash: Option, + #[serde(rename = "ContentHash")] + pub content_hash: Option, +} + +/// `GET volumes/{vid}/trash` response. Trashed links are grouped by the share +/// they belong to (C# `VolumeTrashResponse` / `ShareTrashDto`). +#[derive(Debug, Deserialize)] +pub struct VolumeTrashResponse { + #[serde(rename = "Trash")] + pub trash_by_share: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ShareTrashDto { + #[serde(rename = "ShareID")] + pub share_id: ShareId, + #[serde(rename = "LinkIDs")] + pub link_ids: Vec, + #[serde(rename = "ParentIDs", default)] + pub parent_ids: Vec, +} + #[derive(Debug, Deserialize)] pub struct LinkDetailsDto { #[serde(rename = "Link")] @@ -81,6 +120,11 @@ pub struct LinkDto { pub trash_time: Option, #[serde(rename = "Name")] pub name: String, + /// Lowercase-hex HMAC-SHA256 name hash under the parent's hash key (C# + /// `LinkDto.NameHashDigest`, JSON `NameHash`). Cached as a node's + /// `OriginalHash` for later move/rename without re-decrypting the name. + #[serde(rename = "NameHash", default)] + pub name_hash: Option, #[serde(rename = "NodeKey")] pub key: String, #[serde(rename = "NodePassphrase")] @@ -135,6 +179,45 @@ pub struct ActiveRevisionDto { pub creation_time: i64, #[serde(rename = "EncryptedSize")] pub encrypted_size: i64, + /// Email of the revision signer; empty/absent means the node key signed. + /// Resolves the `XAttr` authorship claim (C# `SignatureEmailAddress`). + #[serde(rename = "SignatureEmail", default)] + pub signature_email: Option, + /// Armored PGP message (encrypted to the node key, signed) carrying the + /// revision's extended attributes. Decrypts to [`DecryptedExtendedAttributes`]. + #[serde(rename = "XAttr")] + pub extended_attributes: Option, +} + +/// The decrypted `XAttr` JSON payload, read side. Mirrors C# `ExtendedAttributes` +/// / `CommonExtendedAttributes`; every field is optional because the payload is +/// produced by heterogeneous clients (the upload-side [`ExtendedAttributes`] +/// struct only writes a subset). +#[derive(Debug, Default, Deserialize)] +pub struct DecryptedExtendedAttributes { + #[serde(rename = "Common", default)] + pub common: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct DecryptedCommonExtendedAttributes { + /// Authoritative plaintext file size, in bytes. + #[serde(rename = "Size", default)] + pub size: Option, + /// ISO-8601 modification timestamp, as written by the uploading client. + #[serde(rename = "ModificationTime", default)] + pub modification_time: Option, + #[serde(rename = "BlockSizes", default)] + pub block_sizes: Option>, + #[serde(rename = "Digests", default)] + pub digests: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct DecryptedFileContentDigests { + /// Lowercase-hex SHA-1 of the full plaintext. + #[serde(rename = "SHA1", default)] + pub sha1: Option, } /// `GET v2/volumes/{vid}/files/{lid}/revisions/{rid}` — one page of a revision's @@ -178,6 +261,10 @@ pub struct BlockDto { #[derive(Debug, Deserialize)] pub struct ThumbnailDto { + /// Server-assigned thumbnail block id (C# `ThumbnailDto.Id`); resolved to a + /// download URL via the `volumes/{vid}/thumbnails` endpoint. + #[serde(rename = "ThumbnailID")] + pub id: Option, #[serde(rename = "Type")] pub thumbnail_type: i32, /// Base64 SHA-256 digest of the thumbnail's ciphertext (manifest input). @@ -185,6 +272,46 @@ pub struct ThumbnailDto { pub hash_digest: Option, } +/// `POST volumes/{vid}/thumbnails` request: resolve thumbnail ids to download +/// URLs (C# `ThumbnailBlockListRequest`). +#[derive(Debug, Serialize)] +pub struct ThumbnailBlockListRequest { + #[serde(rename = "ThumbnailIDs")] + pub thumbnail_ids: Vec, +} + +/// `POST volumes/{vid}/thumbnails` response (C# `ThumbnailBlockListResponse`). +#[derive(Debug, Deserialize)] +pub struct ThumbnailBlockListResponse { + #[serde(rename = "Thumbnails", default)] + pub blocks: Vec, + #[serde(rename = "Errors", default)] + pub errors: Vec, +} + +/// A resolved thumbnail block: where to fetch it and the storage token to use +/// (C# `ThumbnailBlock`). +#[derive(Debug, Deserialize)] +pub struct ThumbnailBlock { + #[serde(rename = "ThumbnailID")] + pub thumbnail_id: String, + #[serde(rename = "BareURL")] + pub bare_url: String, + #[serde(rename = "Token")] + pub token: String, +} + +/// Per-thumbnail resolution error (C# `ThumbnailBlockError`). +#[derive(Debug, Deserialize)] +pub struct ThumbnailBlockError { + #[serde(rename = "ThumbnailID")] + pub thumbnail_id: String, + #[serde(rename = "Error")] + pub error: String, + #[serde(rename = "Code", default)] + pub code: i32, +} + /// `GET v2/volumes/{vid}/folders/{lid}/children` #[derive(Debug, Deserialize)] pub struct FolderChildrenResponse { @@ -360,6 +487,39 @@ pub struct MoveLinkRequest { pub original_hash: String, } +/// `POST v2/volumes/{vid}/links/{folderId}/checkAvailableHashes` request: ask +/// which of a batch of candidate name hashes are free in a folder (C# +/// `NodeNameAvailabilityRequest`). +#[derive(Debug, Serialize)] +pub struct NodeNameAvailabilityRequest { + #[serde(rename = "Hashes")] + pub name_hashes: Vec, + #[serde(rename = "ClientUID")] + pub client_uid: Vec, +} + +/// Response to `checkAvailableHashes` (C# `NodeNameAvailabilityResponse`): the +/// subset of the requested hashes that are available, plus the taken ones. +#[derive(Debug, Deserialize)] +pub struct NodeNameAvailabilityResponse { + #[serde(rename = "AvailableHashes", default)] + pub available_hashes: Vec, + #[serde(rename = "PendingHashes", default)] + pub unavailable_hashes: Vec, +} + +/// One taken name hash and the node that holds it (C# +/// `NameHashDigestUnavailabilityDto`). +#[derive(Debug, Deserialize)] +pub struct NameHashUnavailabilityDto { + #[serde(rename = "Hash")] + pub name_hash: String, + #[serde(rename = "LinkID")] + pub link_id: LinkId, + #[serde(rename = "ClientUID")] + pub client_uid: Option, +} + /// `{ LinkIDs: [...] }` — batch link-id body for trash / restore / delete. /// Mirrors C# `MultipleLinksNullaryRequest`. #[derive(Debug, Serialize)] @@ -507,6 +667,32 @@ pub struct RevisionUpdateRequest { pub checksum_verified: bool, #[serde(rename = "XAttr", skip_serializing_if = "Option::is_none")] pub extended_attributes: Option, + /// Photo-specific seal metadata (capture time, content hash, tags). Present + /// only for photo uploads. Mirrors C# `RevisionUpdateRequest.PhotosAttributes` + /// (`[JsonPropertyName("Photo")]`). + #[serde(rename = "Photo", skip_serializing_if = "Option::is_none")] + pub photos_attributes: Option, +} + +/// Photo-specific revision attributes, attached to the seal request for photo +/// uploads. Mirrors C# `PhotosAttributesDto`. +#[derive(Debug, Serialize)] +pub struct PhotosAttributesDto { + /// Capture time in seconds since the Unix epoch (C# `EpochSecondsJsonConverter`). + #[serde(rename = "CaptureTime")] + pub capture_time: i64, + /// Lowercase-hex HMAC-SHA256 of the lowercase-hex plaintext SHA-1, keyed by + /// the parent folder's hash key (C# `ContentHashDigest`, + /// `ForgivingBytesToHexJsonConverter`). + #[serde(rename = "ContentHash")] + pub content_hash: String, + /// Link id of the main photo, when this is a related photo (live/burst). + #[serde(rename = "MainPhotoLinkID", skip_serializing_if = "Option::is_none")] + pub main_photo_link_id: Option, + /// Photo classification tags (their `PhotoTag` discriminants); always + /// present, empty when none (C# `Tags ?? []`). + #[serde(rename = "Tags")] + pub tags: Vec, } /// The decrypted `XAttr` JSON payload for a revision (encrypted to the node key @@ -533,3 +719,53 @@ pub struct FileContentDigests { #[serde(rename = "SHA1")] pub sha1: String, } + +/// `GET volumes/{vid}/events/latest` — seeds the enumeration cursor. +/// C# `LatestVolumeEventResponse`. +#[derive(Debug, Deserialize)] +pub struct LatestVolumeEventResponse { + #[serde(rename = "EventID")] + pub event_id: DriveEventId, +} + +/// `GET v2/volumes/{vid}/events/{cursor}` — one page of volume events. +/// C# `VolumeEventListResponse`. +#[derive(Debug, Deserialize)] +pub struct VolumeEventListResponse { + /// Cursor to use for the next request (the last event id in this page). + #[serde(rename = "EventID")] + pub last_event_id: DriveEventId, + #[serde(rename = "Events", default)] + pub events: Vec, + /// More pages exist beyond this one. + #[serde(rename = "More")] + pub more_entries_exist: bool, + /// Continuity lost — caller must resync from server state. + #[serde(rename = "Refresh")] + pub refresh_required: bool, +} + +/// A single volume event. C# `VolumeEventDto`. +#[derive(Debug, Deserialize)] +pub struct VolumeEventDto { + #[serde(rename = "EventID")] + pub id: DriveEventId, + /// `VolumeEventType`: 0 = Delete, 1 = Create, 2 = Update, 3 = UpdateMetadata. + #[serde(rename = "EventType")] + pub event_type: i32, + #[serde(rename = "Link")] + pub link: VolumeEventLinkDto, +} + +/// The affected link of a volume event. C# `VolumeEventLinkDto`. +#[derive(Debug, Deserialize)] +pub struct VolumeEventLinkDto { + #[serde(rename = "LinkID")] + pub id: LinkId, + #[serde(rename = "ParentLinkID")] + pub parent_id: Option, + #[serde(rename = "IsShared", default)] + pub is_shared: bool, + #[serde(rename = "IsTrashed", default)] + pub is_trashed: bool, +} diff --git a/crates/proton-drive-sdk/src/events.rs b/crates/proton-drive-sdk/src/events.rs new file mode 100644 index 0000000..2c5a72d --- /dev/null +++ b/crates/proton-drive-sdk/src/events.rs @@ -0,0 +1,55 @@ +//! Drive event model — incremental-sync primitives. +//! +//! Mirrors the C# `Proton.Drive.Sdk.Events` hierarchy (`DriveEvent` and its +//! `NodeUpdatedEvent` / `NodeDeletedEvent` / `EventsCursorAdvancedEvent` / +//! `EventsContinuityLostEvent` subclasses). A consumer enumerates events from a +//! cursor, applies each one, and persists the event's [`id`](DriveEvent::id) as +//! the next cursor. + +use proton_sdk::ids::{DriveEventId, NodeUid}; + +/// A remote data-update event in a volume's event scope. +/// +/// C# models this as a class hierarchy; Rust uses an enum. Every variant +/// carries the [`DriveEventId`] that should be persisted as the next cursor. +#[derive(Debug, Clone)] +pub enum DriveEvent { + /// A node was created, updated, trashed, or became newly accessible. + /// C# `NodeUpdatedEvent` (maps `VolumeEventType` Create/Update/UpdateMetadata). + NodeUpdated { + id: DriveEventId, + node_uid: NodeUid, + /// Parent node, if reported. + parent_node_uid: Option, + /// Whether the node is in the trash. + is_trashed: bool, + /// Whether the node is shared with others. + is_shared: bool, + }, + /// A node was permanently deleted or is no longer accessible. + /// C# `NodeDeletedEvent` (maps `VolumeEventType.Delete`). + NodeDeleted { + id: DriveEventId, + node_uid: NodeUid, + parent_node_uid: Option, + }, + /// The cursor advanced with no substantive change; persist `id` to stay + /// in sync. C# `EventsCursorAdvancedEvent`. Also the sole event yielded + /// when seeding from a `None` cursor. + CursorAdvanced { id: DriveEventId }, + /// Event continuity was lost; the caller must mark local state stale and + /// resync from the current server state. C# `EventsContinuityLostEvent`. + ContinuityLost { id: DriveEventId }, +} + +impl DriveEvent { + /// The event id; persist it as the next enumeration cursor. + pub fn id(&self) -> &DriveEventId { + match self { + DriveEvent::NodeUpdated { id, .. } + | DriveEvent::NodeDeleted { id, .. } + | DriveEvent::CursorAdvanced { id } + | DriveEvent::ContinuityLost { id } => id, + } + } +} diff --git a/crates/proton-drive-sdk/src/lib.rs b/crates/proton-drive-sdk/src/lib.rs index 6a59ded..56f1ecd 100644 --- a/crates/proton-drive-sdk/src/lib.rs +++ b/crates/proton-drive-sdk/src/lib.rs @@ -31,12 +31,21 @@ //! # } //! ``` +mod cache; mod client; mod crypto; mod dtos; +mod events; mod node; +mod photos; +pub use cache::{CachedNodeInfo, DriveEntityCache}; pub use client::ProtonDriveClient; +pub use events::DriveEvent; +pub use photos::{PhotoTag, PhotosTimelineItem, ProtonPhotosClient}; +pub use proton_sdk::cache::{ + CacheRepository, EncryptedCacheRepository, InMemoryCacheRepository, +}; pub use node::{Node, NodeKind, Thumbnail, ThumbnailType}; pub use proton_sdk; diff --git a/crates/proton-drive-sdk/src/node.rs b/crates/proton-drive-sdk/src/node.rs index b93ed92..14c3b13 100644 --- a/crates/proton-drive-sdk/src/node.rs +++ b/crates/proton-drive-sdk/src/node.rs @@ -1,9 +1,11 @@ //! Public, decrypted node model returned to callers. +use proton_sdk::crypto::VerificationStatus; use proton_sdk::ids::NodeUid; +use serde::{Deserialize, Serialize}; /// A decrypted Drive node (folder or file). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { pub uid: NodeUid, pub parent_uid: Option, @@ -17,16 +19,71 @@ pub struct Node { pub trashed: bool, /// Email address that signed the node, if present. pub signature_email: Option, + /// Per-field signature-verification results gathered while decrypting the + /// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`): + /// the node is always returned; the caller inspects this to decide trust. + #[serde(default)] + pub verification: NodeVerification, +} + +/// Outcome of verifying the signatures encountered while decrypting a node. +/// +/// Each field carries the [`VerificationStatus`] of one signed artifact. The +/// file-only fields are `None` for folders (and when the artifact is absent). +/// Mirrors the set of authorship checks C# `NodeCrypto` records. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct NodeVerification { + /// The node name (inline-signed to the parent key). + pub name: VerificationStatus, + /// The node passphrase (detached `NodePassphraseSignature`). + pub passphrase: VerificationStatus, + /// The file content key (`ContentKeyPacketSignature`); `None` for folders. + pub content_key: Option, + /// The active revision's extended attributes; `None` when absent. + pub extended_attributes: Option, +} + +impl Default for NodeVerification { + fn default() -> Self { + Self { + name: VerificationStatus::NotSigned, + passphrase: VerificationStatus::NotSigned, + content_key: None, + extended_attributes: None, + } + } +} + +impl NodeVerification { + /// Whether every signature that was present verified successfully. + /// + /// `NotSigned` is treated as acceptable (Proton metadata is not always + /// signed); only `NoVerifier`/`Failed` count against trust. + pub fn is_fully_verified(&self) -> bool { + let ok = + |s: VerificationStatus| matches!(s, VerificationStatus::Ok | VerificationStatus::NotSigned); + ok(self.name) + && ok(self.passphrase) + && self.content_key.map_or(true, ok) + && self.extended_attributes.map_or(true, ok) + } } /// Folder- or file-specific node data. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum NodeKind { Folder, File { media_type: String, /// Total encrypted size on cloud storage, in bytes. total_size_on_storage: i64, + /// Authoritative plaintext size from the active revision's decrypted + /// extended attributes (C# `ClaimedSize`). `None` when the revision has + /// no `XAttr` or it failed to decrypt. + claimed_size: Option, + /// ISO-8601 modification time from the decrypted extended attributes + /// (C# `ClaimedModificationTime`), verbatim as written by the uploader. + claimed_modification_time: Option, }, } diff --git a/crates/proton-drive-sdk/src/photos.rs b/crates/proton-drive-sdk/src/photos.rs new file mode 100644 index 0000000..176f527 --- /dev/null +++ b/crates/proton-drive-sdk/src/photos.rs @@ -0,0 +1,235 @@ +//! The high-level Proton **Photos** client (Rust port of `ProtonPhotosClient`). +//! +//! Wraps a [`ProtonDriveClient`] and routes node/revision lookups through the +//! photos endpoints (`v2/shares/photos`, `photos/volumes/{vid}/links`, +//! `volumes/{vid}/photos`). This milestone covers the read surface: resolving +//! the photos root, enumerating the timeline, fetching photo node metadata, +//! downloading photo content and **uploading** photos +//! ([`upload_photo`](ProtonPhotosClient::upload_photo)). Photos-volume +//! **creation** reuses the (large) volume-create crypto and is not yet ported; +//! [`find_duplicates`](ProtonPhotosClient::find_duplicates) is unimplemented +//! upstream (C# throws `NotImplementedException`) and mirrors that here. + +use std::io::{Cursor, Read}; + +use serde::{Deserialize, Serialize}; + +use proton_sdk::error::{ProtonError, Result}; +use proton_sdk::ids::NodeUid; +use proton_sdk::session::ProtonApiSession; + +use crate::client::ProtonDriveClient; +use crate::node::{Node, Thumbnail}; + +/// One photos-timeline entry: a photo node and its capture time (epoch +/// seconds). C# `PhotosTimelineItem(NodeUid Uid, DateTime CaptureTime)`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PhotosTimelineItem { + pub uid: NodeUid, + /// Capture time in seconds since the Unix epoch (server `CaptureTime`). + pub capture_time: i64, +} + +/// Photo classification tags. C# `Proton.Drive.Sdk.Nodes.PhotoTag`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(i32)] +pub enum PhotoTag { + Favorite = 0, + Screenshot = 1, + Video = 2, + LivePhoto = 3, + MotionPhoto = 4, + Selfie = 5, + Portrait = 6, + Burst = 7, + Panorama = 8, + Raw = 9, +} + +/// Caller-supplied metadata for a photo upload. Mirrors C# +/// `PhotosFileUploadMetadata`: all fields are optional. `capture_time` defaults +/// to the upload time when unset; `main_photo_uid` links a related photo +/// (live/burst) to its main photo; `tags` classify the photo. +#[derive(Debug, Clone, Default)] +pub struct PhotoUploadMetadata { + /// Capture time in seconds since the Unix epoch. + pub capture_time: Option, + /// The main photo this one is related to (live photo / burst grouping). + pub main_photo_uid: Option, + pub tags: Vec, +} + +/// High-level Proton Photos client. +/// +/// Holds a [`ProtonDriveClient`]; like the Drive client it needs the mailbox +/// password to decrypt the photos share and node keys. +#[derive(Clone)] +pub struct ProtonPhotosClient { + drive: ProtonDriveClient, +} + +impl ProtonPhotosClient { + /// Build a Photos client from a resumed session and the mailbox password + /// (C# `ProtonPhotosClient(ProtonApiSession, ...)`). + pub fn new(session: &ProtonApiSession, mailbox_password: impl Into>) -> Self { + Self { + drive: ProtonDriveClient::new(session, mailbox_password), + } + } + + /// Wrap an existing [`ProtonDriveClient`] (shares its caches and session). + pub fn from_drive_client(drive: ProtonDriveClient) -> Self { + Self { drive } + } + + /// The underlying Drive client, for non-photos operations. + pub fn drive_client(&self) -> &ProtonDriveClient { + &self.drive + } + + /// The photos root folder, or `None` when the account has no photos volume. + /// Read-only: unlike C# `GetOrCreatePhotosFolderAsync`, it does not create + /// one (volume creation is not yet ported). + pub async fn get_photos_root(&self) -> Result> { + self.drive.get_photos_root().await + } + + /// Fetch a single photo node's decrypted metadata, or `None` if it does not + /// exist. C# `ProtonPhotosClient.GetNodeAsync`. + pub async fn get_node(&self, uid: &NodeUid) -> Result> { + self.drive.get_photos_node(uid).await + } + + /// Fetch decrypted metadata for many photo nodes in one pass. + /// C# `ProtonPhotosClient.EnumerateNodesAsync`. + pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result> { + self.drive.enumerate_photos_nodes(uids).await + } + + /// Enumerate the photos timeline newest-first. + /// C# `ProtonPhotosClient.EnumerateTimelineAsync`. + pub async fn enumerate_timeline(&self) -> Result> { + self.drive.enumerate_photos_timeline().await + } + + /// Download and decrypt a photo's active revision, returning its plaintext. + /// C# `PhotosFileDownloader`. + pub async fn download_photo(&self, uid: &NodeUid) -> Result> { + let mut buf = Vec::new(); + self.drive.download_photo_to(uid, &mut buf).await?; + Ok(buf) + } + + /// Download and decrypt a photo's active revision into `output`. + pub async fn download_photo_to( + &self, + uid: &NodeUid, + output: &mut W, + ) -> Result<()> { + self.drive.download_photo_to(uid, output).await + } + + /// Upload `contents` as a new photo named `name`, returning its [`NodeUid`]. + /// + /// Buffered, legacy SEIPDv1, no thumbnails. C# + /// `ProtonPhotosClient.GetFileUploaderAsync`. Errors when the account has no + /// photos volume. + pub async fn upload_photo( + &self, + name: &str, + media_type: &str, + contents: &[u8], + metadata: PhotoUploadMetadata, + ) -> Result { + self.upload_photo_from( + name, + media_type, + Cursor::new(contents), + contents.len() as i64, + Vec::new(), + metadata, + false, + ) + .await + } + + /// Streaming photo upload. See [`ProtonDriveClient::upload_file_from`] for the + /// `reader` / `intended_size` / `thumbnails` / `aead` semantics; the seal + /// additionally records the photo metadata (capture time, content hash, + /// tags). + pub async fn upload_photo_from( + &self, + name: &str, + media_type: &str, + reader: R, + intended_size: i64, + thumbnails: Vec, + metadata: PhotoUploadMetadata, + aead: bool, + ) -> Result { + self.drive + .upload_photo_from( + name, + media_type, + reader, + intended_size, + thumbnails, + &metadata, + aead, + ) + .await + } + + /// Find existing photos that duplicate `name` (server-side name-hash match). + /// + /// **Unimplemented**, mirroring the upstream C# `FindDuplicatesAsync`, which + /// throws `NotImplementedException`. Kept on the public surface so callers + /// can compile against it once the duplicate-find endpoint is ported. + pub async fn find_duplicates(&self, _name: &str) -> Result> { + Err(ProtonError::invalid_operation( + "find_duplicates is not implemented (parity with C# FindDuplicatesAsync)", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dtos::TimelinePhotoListResponse; + use proton_sdk::ids::VolumeId; + + #[test] + fn timeline_item_round_trips() { + let item = PhotosTimelineItem { + uid: NodeUid::new(VolumeId::from("vol-1"), "link-9".into()), + capture_time: 1_700_000_000, + }; + let json = serde_json::to_string(&item).unwrap(); + let back: PhotosTimelineItem = serde_json::from_str(&json).unwrap(); + assert_eq!(item, back); + } + + #[test] + fn photo_tag_discriminants_match_csharp() { + assert_eq!(PhotoTag::Favorite as i32, 0); + assert_eq!(PhotoTag::Video as i32, 2); + assert_eq!(PhotoTag::Raw as i32, 9); + } + + #[test] + fn timeline_response_deserializes_server_shape() { + // `LinkID` + epoch-seconds `CaptureTime` + hex `Hash`, mirroring the + // wire shape of `GET volumes/{vid}/photos`. + let raw = r#"{ + "Photos": [ + { "LinkID": "abc", "CaptureTime": 1700000000, "Hash": "deadbeef" }, + { "LinkID": "def", "CaptureTime": 1700000100, "Hash": "cafe", "ContentHash": "ff" } + ] + }"#; + let parsed: TimelinePhotoListResponse = serde_json::from_str(raw).unwrap(); + assert_eq!(parsed.photos.len(), 2); + assert_eq!(parsed.photos[0].id.to_string(), "abc"); + assert_eq!(parsed.photos[0].capture_time, 1_700_000_000); + assert_eq!(parsed.photos[1].content_hash.as_deref(), Some("ff")); + } +}