Skip to main content

proton_drive_rs/
cache.rs

1//! Drive entity cache — typed, persistable view over a [`CacheRepository`].
2//!
3//! Mirrors the C# `Proton.Drive.Sdk.Caching.DriveEntityCache`: it serializes
4//! typed Drive entities (the client UID, the main volume id, the My Files share
5//! id, and per-node [`CachedNodeInfo`]) to JSON strings stored under stable keys
6//! in a generic [`CacheRepository`]. Backing the repository with an
7//! [`EncryptedCacheRepository`](proton_sdk::cache::EncryptedCacheRepository) or
8//! an on-disk implementation makes the cache persistent without changing this
9//! layer.
10//!
11//! The decrypted node *secrets* (PGP node keys, hash keys) are **not** stored
12//! here — they live in the client's in-memory secret cache, matching the split
13//! between C# `DriveEntityCache` and `DriveSecretCache`.
14
15use std::sync::Arc;
16
17use proton_sdk::cache::CacheRepository;
18use proton_sdk::error::Result;
19use proton_sdk::ids::{NodeUid, ShareId, VolumeId};
20use serde::{Deserialize, Serialize};
21
22use crate::node::Node;
23
24/// A cached node plus the two derived values that node-mutating operations
25/// (move / rename) would otherwise recompute from the decrypted name.
26///
27/// Mirrors C# `CachedNodeInfo`: the node itself, the id of the share whose
28/// membership signs operations on it, and its name-hash digest under its
29/// parent's hash key (the `OriginalHash` of a subsequent move/rename).
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CachedNodeInfo {
32    pub node: Node,
33    /// Share whose membership address signs operations on this node, if known.
34    pub membership_share_id: Option<ShareId>,
35    /// Lowercase-hex HMAC-SHA256 name hash under the parent's hash key.
36    pub name_hash_digest: String,
37}
38
39const CLIENT_UID_KEY: &str = "client:id";
40const MAIN_VOLUME_ID_KEY: &str = "volume:main:id";
41const MY_FILES_SHARE_ID_KEY: &str = "share:my-files:id";
42
43fn node_key(uid: &NodeUid) -> String {
44    format!("node:{uid}")
45}
46
47/// Typed entity cache over a shared [`CacheRepository`]. Cloning shares the
48/// underlying store.
49#[derive(Clone)]
50pub struct DriveEntityCache {
51    repo: Arc<dyn CacheRepository>,
52}
53
54impl DriveEntityCache {
55    /// Build an entity cache over `repo`.
56    pub fn new(repo: Arc<dyn CacheRepository>) -> Self {
57        Self { repo }
58    }
59
60    /// The client UID used to tag this client's own writes (C#
61    /// `TryGetClientUidAsync`).
62    pub async fn client_uid(&self) -> Result<Option<String>> {
63        self.repo.get(CLIENT_UID_KEY).await
64    }
65
66    /// Persist the client UID.
67    pub async fn set_client_uid(&self, client_uid: &str) -> Result<()> {
68        self.repo.set(CLIENT_UID_KEY, client_uid, &[]).await
69    }
70
71    /// The cached main (My Files) volume id.
72    pub async fn main_volume_id(&self) -> Result<Option<VolumeId>> {
73        self.get_json(MAIN_VOLUME_ID_KEY).await
74    }
75
76    /// Persist the main volume id.
77    pub async fn set_main_volume_id(&self, volume_id: &VolumeId) -> Result<()> {
78        self.set_json(MAIN_VOLUME_ID_KEY, volume_id).await
79    }
80
81    /// The cached My Files share id.
82    pub async fn my_files_share_id(&self) -> Result<Option<ShareId>> {
83        self.get_json(MY_FILES_SHARE_ID_KEY).await
84    }
85
86    /// Persist the My Files share id.
87    pub async fn set_my_files_share_id(&self, share_id: &ShareId) -> Result<()> {
88        self.set_json(MY_FILES_SHARE_ID_KEY, share_id).await
89    }
90
91    /// Cache a node together with its membership share and name-hash digest
92    /// (C# `SetNodeAsync`).
93    pub async fn set_node(
94        &self,
95        uid: &NodeUid,
96        node: &Node,
97        membership_share_id: Option<&ShareId>,
98        name_hash_digest: &str,
99    ) -> Result<()> {
100        let info = CachedNodeInfo {
101            node: node.clone(),
102            membership_share_id: membership_share_id.cloned(),
103            name_hash_digest: name_hash_digest.to_owned(),
104        };
105        self.set_json(&node_key(uid), &info).await
106    }
107
108    /// Fetch a cached node (C# `TryGetNodeAsync`).
109    pub async fn try_get_node(&self, uid: &NodeUid) -> Result<Option<CachedNodeInfo>> {
110        self.get_json(&node_key(uid)).await
111    }
112
113    /// Evict a node (C# `RemoveNodeAsync`).
114    pub async fn remove_node(&self, uid: &NodeUid) -> Result<()> {
115        self.repo.remove(&node_key(uid)).await
116    }
117
118    /// Drop every cached entry.
119    pub async fn clear(&self) -> Result<()> {
120        self.repo.clear().await
121    }
122
123    async fn get_json<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<Option<T>> {
124        let Some(raw) = self.repo.get(key).await? else {
125            return Ok(None);
126        };
127        match serde_json::from_str(&raw) {
128            Ok(value) => Ok(Some(value)),
129            // A malformed entry is treated as a miss and evicted, matching C#
130            // `TryGetDeserializedValueAsync`.
131            Err(_) => {
132                self.repo.remove(key).await?;
133                Ok(None)
134            }
135        }
136    }
137
138    async fn set_json<T: Serialize>(&self, key: &str, value: &T) -> Result<()> {
139        let serialized = serde_json::to_string(value)?;
140        self.repo.set(key, &serialized, &[]).await
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use proton_sdk::cache::InMemoryCacheRepository;
148    use proton_sdk::ids::LinkId;
149
150    use crate::node::NodeKind;
151
152    fn uid(v: &str, l: &str) -> NodeUid {
153        NodeUid::new(VolumeId::from(v), LinkId::from(l))
154    }
155
156    fn folder_node(uid: &NodeUid) -> Node {
157        Node {
158            uid: uid.clone(),
159            parent_uid: None,
160            kind: NodeKind::Folder,
161            name: "Docs".into(),
162            creation_time: 1,
163            modification_time: 2,
164            trashed: false,
165            is_shared: false,
166            is_shared_publicly: false,
167            signature_email: None,
168            verification: crate::node::NodeVerification::default(),
169        }
170    }
171
172    #[tokio::test]
173    async fn node_round_trips_with_membership_and_hash() {
174        let cache = DriveEntityCache::new(InMemoryCacheRepository::shared());
175        let node_uid = uid("vol", "link");
176        let node = folder_node(&node_uid);
177        let share = ShareId::from("share-1");
178
179        cache
180            .set_node(&node_uid, &node, Some(&share), "abc123")
181            .await
182            .unwrap();
183
184        let cached = cache.try_get_node(&node_uid).await.unwrap().unwrap();
185        assert_eq!(cached.node.name, "Docs");
186        assert_eq!(cached.membership_share_id, Some(share));
187        assert_eq!(cached.name_hash_digest, "abc123");
188
189        cache.remove_node(&node_uid).await.unwrap();
190        assert!(cache.try_get_node(&node_uid).await.unwrap().is_none());
191    }
192
193    #[tokio::test]
194    async fn scalar_entities_round_trip() {
195        let cache = DriveEntityCache::new(InMemoryCacheRepository::shared());
196        assert!(cache.main_volume_id().await.unwrap().is_none());
197
198        cache.set_client_uid("client-xyz").await.unwrap();
199        cache
200            .set_main_volume_id(&VolumeId::from("vol-1"))
201            .await
202            .unwrap();
203        cache
204            .set_my_files_share_id(&ShareId::from("mf-share"))
205            .await
206            .unwrap();
207
208        assert_eq!(
209            cache.client_uid().await.unwrap().as_deref(),
210            Some("client-xyz")
211        );
212        assert_eq!(
213            cache.main_volume_id().await.unwrap(),
214            Some(VolumeId::from("vol-1"))
215        );
216        assert_eq!(
217            cache.my_files_share_id().await.unwrap(),
218            Some(ShareId::from("mf-share"))
219        );
220    }
221
222    #[tokio::test]
223    async fn malformed_entry_is_evicted_as_miss() {
224        let repo = InMemoryCacheRepository::shared();
225        repo.set(&node_key(&uid("v", "l")), "not json", &[])
226            .await
227            .unwrap();
228        let cache = DriveEntityCache::new(repo.clone());
229        assert!(cache.try_get_node(&uid("v", "l")).await.unwrap().is_none());
230        // The bad entry was removed.
231        assert!(repo.get(&node_key(&uid("v", "l"))).await.unwrap().is_none());
232    }
233}