Files
proton-sdk-rs/crates/proton-drive-rs/src/cache.rs
T
nvrl d72065418b feat(drive,sdk): port upstream C# SDK up to ce000edd
- Moves go through `move-multiple` in batches of 150, with an optional
  target name per node (`move_nodes_streaming` / `NodeMoveItem`), a single
  `InvalidRequirements` retry against the server's current name hash, and
  passphrase signing for anonymously created nodes. A batch rejected as a
  whole keeps its API error code on every node.
- Share memberships carry invite time and inviter email, and the inviter's
  `drive.share-member.inviter` signature is verified as non-fatal metadata
  (`verify_detached_with_context`).
- `Node` gains `direct_role` and `share_id`.
- Trashed detection reads `TrashTime` as well as the legacy `Trashed`.

Breaking: new public struct fields on `Node` and `ShareMembership`.
2026-09-15 13:54:04 +02:00

239 lines
7.9 KiB
Rust

//! 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,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
direct_role: None,
share_id: None,
photo: None,
album: 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());
}
}