feat(drive/models): implement caching, sync events, and photos models

This commit is contained in:
nvrl committed 2026-06-25 15:54:03 +02:00
1 parent fdfab3df3f
commit d67c294a4b
7 files changed
+1012 -5

No files matched your search

+220
View File
@@ -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<ShareId>,
/// 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<dyn CacheRepository>,
}
impl DriveEntityCache {
/// Build an entity cache over `repo`.
pub fn new(repo: Arc<dyn CacheRepository>) -> Self {
Self { repo }
}
/// The client UID used to tag this client's own writes (C#
/// `TryGetClientUidAsync`).
pub async fn client_uid(&self) -> Result<Option<String>> {
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<Option<VolumeId>> {
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<Option<ShareId>> {
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<Option<CachedNodeInfo>> {
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<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<Option<T>> {
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<T: Serialize>(&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());
}
}
+197 -2
View File
@@ -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<DecryptedLink> {
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<Decrypted
Ok(DecryptedLink { node_key, name })
}
/// A claimed authorship: the signer's email (if any) and the public keys that
/// can verify their signatures. Mirrors C# `AuthorshipClaim`.
///
/// An empty/absent email is *anonymous*: signatures are then expected to come
/// from the decryption key itself, so the verification ring falls back to it.
#[derive(Clone)]
pub struct AuthorshipClaim {
author: Option<String>,
keys: Vec<PublicKey>,
}
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());
}
}
+237 -1
View File
@@ -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<LinkDetailsDto>,
}
/// `GET volumes/{vid}/photos` response (C# `TimelinePhotoListResponse`).
#[derive(Debug, Deserialize)]
pub struct TimelinePhotoListResponse {
#[serde(rename = "Photos")]
pub photos: Vec<TimelinePhotoDto>,
}
/// 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<String>,
#[serde(rename = "ContentHash")]
pub content_hash: Option<String>,
}
/// `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<ShareTrashDto>,
}
#[derive(Debug, Deserialize)]
pub struct ShareTrashDto {
#[serde(rename = "ShareID")]
pub share_id: ShareId,
#[serde(rename = "LinkIDs")]
pub link_ids: Vec<LinkId>,
#[serde(rename = "ParentIDs", default)]
pub parent_ids: Vec<LinkId>,
}
#[derive(Debug, Deserialize)]
pub struct LinkDetailsDto {
#[serde(rename = "Link")]
@@ -81,6 +120,11 @@ pub struct LinkDto {
pub trash_time: Option<i64>,
#[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<String>,
#[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<String>,
/// 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<String>,
}
/// 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<DecryptedCommonExtendedAttributes>,
}
#[derive(Debug, Default, Deserialize)]
pub struct DecryptedCommonExtendedAttributes {
/// Authoritative plaintext file size, in bytes.
#[serde(rename = "Size", default)]
pub size: Option<i64>,
/// ISO-8601 modification timestamp, as written by the uploading client.
#[serde(rename = "ModificationTime", default)]
pub modification_time: Option<String>,
#[serde(rename = "BlockSizes", default)]
pub block_sizes: Option<Vec<i64>>,
#[serde(rename = "Digests", default)]
pub digests: Option<DecryptedFileContentDigests>,
}
#[derive(Debug, Default, Deserialize)]
pub struct DecryptedFileContentDigests {
/// Lowercase-hex SHA-1 of the full plaintext.
#[serde(rename = "SHA1", default)]
pub sha1: Option<String>,
}
/// `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<String>,
#[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<String>,
}
/// `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<String>,
}
/// `POST volumes/{vid}/thumbnails` response (C# `ThumbnailBlockListResponse`).
#[derive(Debug, Deserialize)]
pub struct ThumbnailBlockListResponse {
#[serde(rename = "Thumbnails", default)]
pub blocks: Vec<ThumbnailBlock>,
#[serde(rename = "Errors", default)]
pub errors: Vec<ThumbnailBlockError>,
}
/// 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<String>,
#[serde(rename = "ClientUID")]
pub client_uid: Vec<String>,
}
/// 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<String>,
#[serde(rename = "PendingHashes", default)]
pub unavailable_hashes: Vec<NameHashUnavailabilityDto>,
}
/// 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<String>,
}
/// `{ 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<String>,
/// 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<PhotosAttributesDto>,
}
/// 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<LinkId>,
/// Photo classification tags (their `PhotoTag` discriminants); always
/// present, empty when none (C# `Tags ?? []`).
#[serde(rename = "Tags")]
pub tags: Vec<i32>,
}
/// 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<VolumeEventDto>,
/// 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<LinkId>,
#[serde(rename = "IsShared", default)]
pub is_shared: bool,
#[serde(rename = "IsTrashed", default)]
pub is_trashed: bool,
}
+55
View File
@@ -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<NodeUid>,
/// 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<NodeUid>,
},
/// 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,
}
}
}
+9
View File
@@ -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;
+59 -2
View File
@@ -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<NodeUid>,
@@ -17,16 +19,71 @@ pub struct Node {
pub trashed: bool,
/// Email address that signed the node, if present.
pub signature_email: Option<String>,
/// 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<VerificationStatus>,
/// The active revision's extended attributes; `None` when absent.
pub extended_attributes: Option<VerificationStatus>,
}
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<i64>,
/// ISO-8601 modification time from the decrypted extended attributes
/// (C# `ClaimedModificationTime`), verbatim as written by the uploader.
claimed_modification_time: Option<String>,
},
}
+235
View File
@@ -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<i64>,
/// The main photo this one is related to (live photo / burst grouping).
pub main_photo_uid: Option<NodeUid>,
pub tags: Vec<PhotoTag>,
}
/// 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<Vec<u8>>) -> 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<Option<Node>> {
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<Option<Node>> {
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<Vec<Node>> {
self.drive.enumerate_photos_nodes(uids).await
}
/// Enumerate the photos timeline newest-first.
/// C# `ProtonPhotosClient.EnumerateTimelineAsync`.
pub async fn enumerate_timeline(&self) -> Result<Vec<PhotosTimelineItem>> {
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<Vec<u8>> {
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<W: std::io::Write>(
&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<NodeUid> {
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<R: Read + Send>(
&self,
name: &str,
media_type: &str,
reader: R,
intended_size: i64,
thumbnails: Vec<Thumbnail>,
metadata: PhotoUploadMetadata,
aead: bool,
) -> Result<NodeUid> {
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<Vec<String>> {
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"));
}
}