mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
feat(drive/models): implement caching, sync events, and photos models
This commit is contained in:
1 parent
fdfab3df3f
commit
d67c294a4b
7 files changed
+1012
-5
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user