From 7de667b386a7396ec5b8bb48c82ad2782519a236 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 29 Jul 2026 17:04:42 +0200 Subject: [PATCH] implemented primary mount shared with me --- crates/pdfs-core/src/db/mod.rs | 2 +- crates/pdfs-core/src/db/nodes.rs | 416 +++++++++++++++++---- crates/pdfs-core/src/db/tests.rs | 382 ++++++++++++++++++++ crates/pdfs-fuse/src/background.rs | 255 ++++++++++++- crates/pdfs-fuse/src/devices.rs | 1 + crates/pdfs-fuse/src/lib.rs | 559 ++++++++++++++++++++++++++++- crates/pdfs-fuse/src/mount.rs | 5 + crates/pdfs-fuse/src/state.rs | 159 +++++++- crates/pdfs-fuse/src/sweep.rs | 6 +- crates/pdfs-fuse/src/upload.rs | 37 +- crates/pdfs-fuse/src/virtual.rs | 318 ++++++++++++++++ docs/BUGS.md | 10 +- 12 files changed, 2018 insertions(+), 132 deletions(-) create mode 100644 crates/pdfs-fuse/src/virtual.rs diff --git a/crates/pdfs-core/src/db/mod.rs b/crates/pdfs-core/src/db/mod.rs index c90f671..d35a566 100644 --- a/crates/pdfs-core/src/db/mod.rs +++ b/crates/pdfs-core/src/db/mod.rs @@ -41,7 +41,7 @@ pub use cache::CacheEntryInput; pub use devices::StoredDevice; pub use local::LocalFileHit; pub use maintenance::{DbStats, VacuumOutcome}; -pub use nodes::{SearchHit, StoredNode}; +pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode}; pub use ops::{ AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes, diff --git a/crates/pdfs-core/src/db/nodes.rs b/crates/pdfs-core/src/db/nodes.rs index ab33228..0ae75bb 100644 --- a/crates/pdfs-core/src/db/nodes.rs +++ b/crates/pdfs-core/src/db/nodes.rs @@ -1,11 +1,13 @@ //! Node rows: the persisted mirror of the remote tree, plus the trigram //! full-text index over names and paths that backs `Request::Search`. -use rusqlite::{OptionalExtension, params}; +use std::collections::HashSet; + +use rusqlite::{OptionalExtension, Transaction, params}; use super::Db; -use crate::Result; -use proton_drive_rs::proton_sdk::ids::NodeUid; +use crate::{Access, Result}; +use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use proton_drive_rs::{Node, NodeKind}; use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of}; @@ -22,6 +24,11 @@ pub struct SearchHit { pub path: String, } +pub struct PublishedSharedRoot { + pub node: Node, + pub access: Access, +} + impl Db { pub fn upsert_node(&self, node: &Node) -> Result<()> { self.upsert_nodes(std::slice::from_ref(node)) @@ -42,87 +49,176 @@ impl Db { let mut conn = self.conn.lock(); let tx = conn.transaction()?; for node in nodes { - let json = serde_json::to_string(node)?; - let uid = node.uid.to_string(); - tx.execute( - "INSERT INTO nodes - (uid, parent_uid, name, is_dir, size, mtime, trashed, node_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(uid) DO UPDATE SET - parent_uid = excluded.parent_uid, - name = excluded.name, - is_dir = excluded.is_dir, - size = excluded.size, - mtime = excluded.mtime, - trashed = excluded.trashed, - node_json = excluded.node_json", - params![ - uid, - node.parent_uid.as_ref().map(|u| u.to_string()), - node.name, - node.is_folder() as i64, - node_size(node), - node.modification_time, - node.trashed as i64, - json, - ], - )?; - // FTS5 has no UPSERT, so refresh the row by delete-then-insert. - // Trashed nodes are kept out of the index entirely so they never - // surface in search results. - // - // Keyed by the node's rowid, never by its uid: the uid is not an - // indexed FTS column, so deleting by it scans the entire index once - // per node written (B12). The rowid is stable across the upsert - // above — `ON CONFLICT DO UPDATE` keeps the existing row — so it is - // the same key before and after. - let rowid: i64 = tx.query_row( - "SELECT rowid FROM nodes WHERE uid = ?1", - params![uid], - |row| row.get(0), - )?; - // A folder rename/move changes every descendant's searchable path. - // Refresh the affected subtree, while retaining efficient rowid - // deletes for the overwhelmingly common one-file case. - let mut affected = vec![rowid]; - if node.is_folder() { - let mut stmt = tx.prepare( - "WITH RECURSIVE descendants(rowid, uid) AS ( - SELECT rowid, uid FROM nodes WHERE parent_uid = ?1 - UNION ALL - SELECT n.rowid, n.uid FROM nodes n - JOIN descendants d ON n.parent_uid = d.uid - ) SELECT rowid FROM descendants", - )?; - for descendant in stmt.query_map(params![uid], |row| row.get(0))? { - affected.push(descendant?); - } - } - for affected_rowid in affected { - tx.execute( - "DELETE FROM nodes_fts WHERE rowid = ?1", - params![affected_rowid], - )?; - let indexed: Option<(String, String)> = tx - .query_row( - "SELECT name, uid FROM nodes WHERE rowid = ?1 AND trashed = 0", - params![affected_rowid], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((name, indexed_uid)) = indexed { - let path = path_of(&tx, &indexed_uid)?; - tx.execute( - "INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)", - params![affected_rowid, name, path], - )?; - } - } + upsert_node_tx(&tx, node)?; } tx.commit()?; Ok(()) } + /// Return visible direct children without consulting the parent's `listed` + /// flag. Synthetic shared listings use this to serve the last completed + /// snapshot while offline, including after a live event expired its TTL. + pub fn visible_children(&self, parent: &NodeUid) -> Result> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare( + "SELECT node_json FROM nodes + WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL + ORDER BY name, uid", + )?; + let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?; + let mut nodes = Vec::new(); + for row in rows { + nodes.push(serde_json::from_str(&row?)?); + } + Ok(nodes) + } + + /// Atomically publish one completed `Shared with me` root listing. + /// + /// Roots absent from `accepted` and every persisted descendant are + /// tombstoned, not deleted. Accepted roots omitted from materialization keep + /// their visible snapshot but are downgraded to Viewer until membership is + /// verified again. Both cases preserve queued operations and staged writes. + pub fn publish_shared_roots( + &self, + parent: &NodeUid, + accepted: &[NodeUid], + roots: &[PublishedSharedRoot], + ) -> Result> { + let parent = parent.to_string(); + let present: HashSet = accepted.iter().map(NodeUid::to_string).collect(); + let materialized: HashSet = roots + .iter() + .map(|published| published.node.uid.to_string()) + .collect(); + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + + let existing = direct_child_uids_tx(&tx, &parent)?; + let removed: Vec = existing + .into_iter() + .filter(|uid| !present.contains(uid)) + .collect(); + + for published in roots { + if !present.contains(&published.node.uid.to_string()) { + continue; + } + upsert_node_tx(&tx, &published.node)?; + upsert_share_access_tx(&tx, &published.node.uid.to_string(), published.access)?; + } + for uid in present.difference(&materialized) { + upsert_share_access_tx(&tx, uid, Access::Viewer)?; + } + for root in &removed { + tombstone_subtree_tx(&tx, root)?; + upsert_share_access_tx(&tx, root, Access::Viewer)?; + } + tx.execute( + "UPDATE nodes SET listed = 1 WHERE uid = ?1", + params![parent], + )?; + tx.commit()?; + + Ok(removed + .into_iter() + .filter_map(|uid| parse_node_uid(&uid)) + .collect()) + } + + /// Atomically withdraw a deleted foreign subtree and deny stale handles. + /// + /// Node rows and queued operations remain so retries and staged writes keep + /// their references. FTS and completed-listing state are withdrawn for the + /// whole subtree, and the deleted UID becomes a fail-closed authority. + pub fn tombstone_foreign_subtree(&self, uid: &NodeUid) -> Result<()> { + let uid = uid.to_string(); + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + tombstone_subtree_tx(&tx, &uid)?; + upsert_share_access_tx(&tx, &uid, Access::Viewer)?; + tx.commit()?; + Ok(()) + } + + /// Publish a completed foreign-folder listing from its authoritative UID + /// list and the subset that materialized successfully. Accepted-but-omitted + /// children retain their previous snapshot; only absent UIDs are tombstoned. + pub fn publish_foreign_children( + &self, + parent: &NodeUid, + accepted: &[NodeUid], + materialized: &[Node], + ) -> Result> { + let parent = parent.to_string(); + let present: HashSet = accepted.iter().map(NodeUid::to_string).collect(); + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + let removed: Vec = direct_child_uids_tx(&tx, &parent)? + .into_iter() + .filter(|uid| !present.contains(uid)) + .collect(); + for node in materialized { + if present.contains(&node.uid.to_string()) { + upsert_node_tx(&tx, node)?; + } + } + for uid in &removed { + tombstone_subtree_tx(&tx, uid)?; + } + tx.execute( + "UPDATE nodes SET listed = 1 WHERE uid = ?1", + params![parent], + )?; + tx.commit()?; + Ok(removed + .into_iter() + .filter_map(|uid| parse_node_uid(&uid)) + .collect()) + } + + /// Persist the synthetic root and its Viewer authority only when its + /// presentation state changed. Returning `false` guarantees no node/FTS + /// write occurred, which keeps every cached root lookup O(1). + pub fn ensure_virtual_root(&self, node: &Node) -> Result { + let uid = node.uid.to_string(); + let desired_parent = node.parent_uid.as_ref().map(NodeUid::to_string); + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + let stored: Option<(Option, String, i64, i64)> = tx + .query_row( + "SELECT parent_uid, name, is_dir, trashed FROM nodes WHERE uid = ?1", + params![uid], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + let access: Option = tx + .query_row( + "SELECT access FROM share_access WHERE root_uid = ?1", + params![uid], + |row| row.get(0), + ) + .optional()?; + let unchanged = stored.is_some_and(|(parent, name, is_dir, trashed)| { + parent == desired_parent + && name == node.name + && is_dir == 1 + && trashed == node.trashed as i64 + }) && access.as_deref() == Some(Access::Viewer.as_db_str()); + if unchanged { + tx.commit()?; + return Ok(false); + } + tx.execute( + "INSERT INTO share_access (root_uid, access) VALUES (?1, ?2) + ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access", + params![uid, Access::Viewer.as_db_str()], + )?; + upsert_node_tx(&tx, node)?; + tx.commit()?; + Ok(true) + } + /// Drop a node row (delete or trash from the hot cache). Children rows are /// not cascaded here; the daemon forgets a whole subtree node-by-node. pub fn delete_node(&self, uid: &NodeUid) -> Result<()> { @@ -413,6 +509,168 @@ impl Db { // in sync on every store/read/evict, so it is authoritative for eviction. } +fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> { + let json = serde_json::to_string(node)?; + let uid = node.uid.to_string(); + tx.execute( + "INSERT INTO nodes + (uid, parent_uid, name, is_dir, size, mtime, trashed, node_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(uid) DO UPDATE SET + parent_uid = excluded.parent_uid, + name = excluded.name, + is_dir = excluded.is_dir, + size = excluded.size, + mtime = excluded.mtime, + trashed = excluded.trashed, + node_json = excluded.node_json", + params![ + uid, + node.parent_uid.as_ref().map(|u| u.to_string()), + node.name, + node.is_folder() as i64, + node_size(node), + node.modification_time, + node.trashed as i64, + json, + ], + )?; + // FTS5 has no UPSERT. Folder moves also change every descendant path, so + // refresh the whole affected subtree while keeping rowid deletes indexed. + let rowid: i64 = tx.query_row( + "SELECT rowid FROM nodes WHERE uid = ?1", + params![uid], + |row| row.get(0), + )?; + let mut affected = vec![rowid]; + if node.is_folder() { + let mut stmt = tx.prepare( + "WITH RECURSIVE descendants(rowid, uid) AS ( + SELECT rowid, uid FROM nodes WHERE parent_uid = ?1 + UNION ALL + SELECT n.rowid, n.uid FROM nodes n + JOIN descendants d ON n.parent_uid = d.uid + ) SELECT rowid FROM descendants", + )?; + for descendant in stmt.query_map(params![uid], |row| row.get(0))? { + affected.push(descendant?); + } + } + for affected_rowid in affected { + tx.execute( + "DELETE FROM nodes_fts WHERE rowid = ?1", + params![affected_rowid], + )?; + let indexed: Option<(String, String)> = tx + .query_row( + "SELECT name, uid FROM nodes WHERE rowid = ?1", + params![affected_rowid], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((name, indexed_uid)) = indexed + && node_is_indexable_tx(tx, &indexed_uid)? + { + let path = path_of(tx, &indexed_uid)?; + tx.execute( + "INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)", + params![affected_rowid, name, path], + )?; + } + } + Ok(()) +} + +fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result> { + let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?; + let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?; + let mut uids = Vec::new(); + for row in rows { + uids.push(row?); + } + Ok(uids) +} + +fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result { + let indexable: i64 = tx.query_row( + "WITH RECURSIVE ancestors(uid, parent_uid, trashed, path) AS ( + SELECT uid, parent_uid, trashed, char(31) || uid || char(31) + FROM nodes WHERE uid = ?1 + UNION ALL + SELECT n.uid, n.parent_uid, n.trashed, + a.path || n.uid || char(31) + FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid + WHERE instr(a.path, char(31) || n.uid || char(31)) = 0 + ) + SELECT CASE + WHEN COUNT(*) = 0 THEN 0 + WHEN MAX(trashed) != 0 THEN 0 + WHEN MAX(CASE WHEN parent_uid IS NULL THEN 1 ELSE 0 END) = 0 THEN 0 + ELSE 1 + END + FROM ancestors", + [uid], + |row| row.get(0), + )?; + Ok(indexable == 1) +} + +fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> { + let rows = { + let mut stmt = tx.prepare( + "WITH RECURSIVE subtree(rowid, uid, node_json) AS ( + SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1 + UNION + SELECT n.rowid, n.uid, n.node_json FROM nodes n + JOIN subtree s ON n.parent_uid = s.uid + ) + SELECT rowid, uid, node_json FROM subtree", + )?; + let mapped = stmt.query_map([root_uid], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + let mut rows = Vec::new(); + for row in mapped { + rows.push(row?); + } + rows + }; + for (rowid, uid, json) in rows { + let json = match json { + Some(json) => { + let mut node: Node = serde_json::from_str(&json)?; + node.trashed = true; + Some(serde_json::to_string(&node)?) + } + None => None, + }; + tx.execute( + "UPDATE nodes SET trashed = 1, listed = 0, node_json = ?2 WHERE uid = ?1", + params![uid, json], + )?; + tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", [rowid])?; + } + Ok(()) +} + +fn upsert_share_access_tx(tx: &Transaction<'_>, uid: &str, access: Access) -> Result<()> { + tx.execute( + "INSERT INTO share_access (root_uid, access) VALUES (?1, ?2) + ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access", + params![uid, access.as_db_str()], + )?; + Ok(()) +} + +fn parse_node_uid(value: &str) -> Option { + let (volume, link) = value.split_once('~')?; + Some(NodeUid::new(VolumeId::from(volume), LinkId::from(link))) +} + /// Unique quoted character trigrams suitable for an FTS5 OR expression. pub(super) fn candidate_trigrams(query: &str) -> Vec { let chars: Vec = query.to_lowercase().chars().collect(); diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index fedaa7f..679c122 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -87,6 +87,12 @@ fn uid(link: &str) -> NodeUid { NodeUid::new(VolumeId::from("vol"), LinkId::from(link)) } +fn now_test_id() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + NEXT.fetch_add(1, Ordering::Relaxed) +} + // `NodeVerification` is not re-exported, so build test nodes by // deserializing JSON (the field has a serde default and can be omitted). fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json::Value) -> Node { @@ -274,6 +280,13 @@ fn file(link: &str, parent: &str, name: &str, size: i64) -> Node { ) } +fn foreign_folder(volume: &str, link: &str, parent: NodeUid, name: &str) -> Node { + let mut node = folder(link, None, name); + node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link)); + node.parent_uid = Some(parent); + node +} + /// Recovering the root by uid is what lets the daemon mount offline /// (offline.md Phase 1): the uid is remembered in `sync_state`, the node /// itself comes back out of `nodes`. @@ -438,6 +451,375 @@ fn children_if_listed_excludes_trashed() { ); } +#[test] +fn shared_root_replacement_tombstones_without_losing_work_or_authority() { + let db = Db::open_in_memory().unwrap(); + let own_root = folder("root", None, "My Files"); + let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme")); + let virtual_root = foreign_folder( + "virtual", + "sharedwithme", + own_root.uid.clone(), + "Shared with me", + ); + let shared = foreign_folder("foreign", "share", virtual_uid.clone(), "Project"); + let child = foreign_folder("foreign", "child", shared.uid.clone(), "Secret"); + db.upsert_nodes(&[own_root, virtual_root, shared.clone(), child.clone()]) + .unwrap(); + db.set_share_access(&shared.uid, crate::Access::Editor) + .unwrap(); + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_REVISION.to_string(), + uid: child.uid.to_string(), + parent_uid: Some(shared.uid.to_string()), + name: Some(child.name.clone()), + blob_path: Some("/staging/shared-edit".to_string()), + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + assert_eq!(db.search("Secret", 10).unwrap().len(), 1); + + let removed = db.publish_shared_roots(&virtual_uid, &[], &[]).unwrap(); + assert_eq!(removed, vec![shared.uid.clone()]); + assert!(db.visible_children(&virtual_uid).unwrap().is_empty()); + assert!( + db.node_by_uid(&shared.uid.to_string()) + .unwrap() + .unwrap() + .trashed + ); + assert!( + db.node_by_uid(&child.uid.to_string()) + .unwrap() + .unwrap() + .trashed + ); + assert!(db.search("Secret", 10).unwrap().is_empty()); + assert_eq!(db.pending_ops().unwrap().len(), 1); + assert_eq!( + db.share_access(&shared.uid).unwrap(), + Some(crate::Access::Viewer) + ); + + let mut reappeared = shared.clone(); + reappeared.trashed = false; + assert!( + db.publish_shared_roots( + &virtual_uid, + std::slice::from_ref(&shared.uid), + &[PublishedSharedRoot { + node: reappeared, + access: crate::Access::Viewer, + }], + ) + .unwrap() + .is_empty() + ); + let restored = db.visible_children(&virtual_uid).unwrap(); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].parent_uid.as_ref(), Some(&virtual_uid)); + assert!(!restored[0].trashed); + assert!( + db.node_by_uid(&child.uid.to_string()) + .unwrap() + .unwrap() + .trashed, + "descendants remain hidden until their folder is re-enumerated" + ); + assert_eq!(db.pending_ops().unwrap().len(), 1); + assert_eq!( + db.share_access(&shared.uid).unwrap(), + Some(crate::Access::Viewer), + "reappearance stays fail-closed until an observed role refresh" + ); +} + +#[test] +fn shared_root_node_and_authority_publication_roll_back_together() { + let db = Db::open_in_memory().unwrap(); + let own_root = folder("root", None, "My Files"); + let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme")); + let virtual_root = foreign_folder( + "virtual", + "sharedwithme", + own_root.uid.clone(), + "Shared with me", + ); + let shared = foreign_folder("foreign", "share", virtual_uid.clone(), "Before"); + db.upsert_nodes(&[own_root, virtual_root, shared.clone()]) + .unwrap(); + db.set_share_access(&shared.uid, crate::Access::Viewer) + .unwrap(); + db.set_listed(&virtual_uid, false).unwrap(); + db.with_conn(|conn| { + conn.execute_batch( + "CREATE TRIGGER reject_shared_authority + BEFORE UPDATE ON share_access + WHEN NEW.root_uid = 'foreign~share' + BEGIN SELECT RAISE(ABORT, 'injected authority failure'); END;", + )?; + Ok(()) + }) + .unwrap(); + + let mut changed = shared.clone(); + changed.name = "After".into(); + let result = db.publish_shared_roots( + &virtual_uid, + std::slice::from_ref(&shared.uid), + &[PublishedSharedRoot { + node: changed, + access: crate::Access::Editor, + }], + ); + assert!(result.is_err()); + assert_eq!( + db.node_by_uid(&shared.uid.to_string()) + .unwrap() + .unwrap() + .name, + "Before" + ); + assert_eq!( + db.share_access(&shared.uid).unwrap(), + Some(crate::Access::Viewer) + ); + assert!(db.children_if_listed(&virtual_uid).unwrap().is_none()); +} + +#[test] +fn accepted_but_unmaterialized_shared_root_keeps_its_snapshot() { + let db = Db::open_in_memory().unwrap(); + let own_root = folder("root", None, "My Files"); + let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme")); + let virtual_root = foreign_folder( + "virtual", + "sharedwithme", + own_root.uid.clone(), + "Shared with me", + ); + let shared = foreign_folder("foreign", "share", virtual_uid.clone(), "Retained"); + db.upsert_nodes(&[own_root, virtual_root, shared.clone()]) + .unwrap(); + db.set_share_access(&shared.uid, crate::Access::Editor) + .unwrap(); + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_RENAME.to_string(), + uid: shared.uid.to_string(), + parent_uid: Some(virtual_uid.to_string()), + name: Some("Retained locally".to_string()), + blob_path: None, + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + + let removed = db + .publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[]) + .unwrap(); + assert!(removed.is_empty()); + assert_eq!(db.visible_children(&virtual_uid).unwrap().len(), 1); + assert_eq!(db.pending_ops().unwrap().len(), 1); + assert_eq!( + db.share_access(&shared.uid).unwrap(), + Some(crate::Access::Viewer), + "an accepted root without verified materialized membership fails closed" + ); +} + +#[test] +fn foreign_listing_reconciles_authoritative_uids_and_survives_restart_shape() { + let db = Db::open_in_memory().unwrap(); + let own_root = folder("root", None, "My Files"); + let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme")); + let virtual_root = foreign_folder( + "virtual", + "sharedwithme", + own_root.uid.clone(), + "Shared with me", + ); + let shared = foreign_folder("foreign", "share", virtual_uid, "Shared"); + let kept = foreign_folder("foreign", "kept", shared.uid.clone(), "Kept"); + let removed = foreign_folder("foreign", "removed", shared.uid.clone(), "Removed"); + let deep = foreign_folder("foreign", "deep", removed.uid.clone(), "Deep secret"); + db.upsert_nodes(&[ + own_root, + virtual_root, + shared.clone(), + kept.clone(), + removed.clone(), + deep.clone(), + ]) + .unwrap(); + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_REVISION.to_string(), + uid: deep.uid.to_string(), + parent_uid: Some(removed.uid.to_string()), + name: Some(deep.name.clone()), + blob_path: Some("/staging/foreign".into()), + meta_json: Some("{}".into()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + + let removed_uids = db + .publish_foreign_children(&shared.uid, std::slice::from_ref(&kept.uid), &[]) + .unwrap(); + assert_eq!(removed_uids, vec![removed.uid.clone()]); + let snapshot = db.visible_children(&shared.uid).unwrap(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].uid, kept.uid); + assert!( + db.node_by_uid(&deep.uid.to_string()) + .unwrap() + .unwrap() + .trashed + ); + assert!(db.search("secret", 10).unwrap().is_empty()); + assert_eq!(db.pending_ops().unwrap().len(), 1); + assert_eq!( + db.children_if_listed(&shared.uid).unwrap().unwrap().len(), + 1, + "restart hydration sees the completed authoritative snapshot" + ); +} + +#[test] +fn foreign_delete_tombstones_the_subtree_without_losing_pending_work() { + let db = Db::open_in_memory().unwrap(); + let own_root = folder("root", None, "My Files"); + let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme")); + let virtual_root = foreign_folder( + "virtual", + "sharedwithme", + own_root.uid.clone(), + "Shared with me", + ); + let shared = foreign_folder("foreign", "share", virtual_uid, "Shared"); + let deleted = foreign_folder("foreign", "deleted", shared.uid.clone(), "Deleted"); + let deep = foreign_folder("foreign", "deep", deleted.uid.clone(), "Searchable deep"); + db.upsert_nodes(&[ + own_root, + virtual_root, + shared, + deleted.clone(), + deep.clone(), + ]) + .unwrap(); + db.set_share_access(&deleted.uid, crate::Access::Editor) + .unwrap(); + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_REVISION.to_string(), + uid: deep.uid.to_string(), + parent_uid: Some(deleted.uid.to_string()), + name: Some(deep.name.clone()), + blob_path: Some("/staging/deleted-foreign".to_string()), + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + assert_eq!(db.search("Searchable", 10).unwrap().len(), 1); + + db.tombstone_foreign_subtree(&deleted.uid).unwrap(); + + for uid in [&deleted.uid, &deep.uid] { + assert!(db.node_by_uid(&uid.to_string()).unwrap().unwrap().trashed); + } + assert!(db.search("Searchable", 10).unwrap().is_empty()); + assert_eq!(db.pending_ops().unwrap().len(), 1); + assert_eq!( + db.share_access(&deleted.uid).unwrap(), + Some(crate::Access::Viewer) + ); +} + +#[test] +fn hidden_virtual_root_removes_descendants_from_fts_and_restores_them() { + let db = Db::open_in_memory().unwrap(); + let own_root = folder("root", None, "My Files"); + let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme")); + let mut virtual_root = foreign_folder( + "virtual", + "sharedwithme", + own_root.uid.clone(), + "Shared with me", + ); + let shared = foreign_folder("foreign", "share", virtual_uid, "Shared"); + let child = foreign_folder( + "foreign", + "child", + shared.uid.clone(), + "Findable descendant", + ); + db.upsert_node(&own_root).unwrap(); + assert!(db.ensure_virtual_root(&virtual_root).unwrap()); + db.upsert_nodes(&[shared, child.clone()]).unwrap(); + assert_eq!(db.search("Findable", 10).unwrap().len(), 1); + + virtual_root.trashed = true; + assert!(db.ensure_virtual_root(&virtual_root).unwrap()); + assert!(db.search("Findable", 10).unwrap().is_empty()); + assert!( + !db.node_by_uid(&child.uid.to_string()) + .unwrap() + .unwrap() + .trashed, + "valid descendants are hidden by ancestry, not revoked" + ); + + virtual_root.trashed = false; + assert!(db.ensure_virtual_root(&virtual_root).unwrap()); + assert_eq!(db.search("Findable", 10).unwrap().len(), 1); + let before = db.with_conn(|conn| Ok(conn.total_changes())).unwrap(); + assert!(!db.ensure_virtual_root(&virtual_root).unwrap()); + let after = db.with_conn(|conn| Ok(conn.total_changes())).unwrap(); + assert_eq!( + before, after, + "unchanged root lookup performs no DB/FTS writes" + ); +} + +#[test] +fn shared_root_pinned_name_survives_database_reopen() { + let path = std::env::temp_dir().join(format!( + "pdfs-shared-name-{}-{}.db", + std::process::id(), + now_test_id() + )); + { + let db = Db::open(&path).unwrap(); + db.set_state_str("shared_with_me_name", "Shared with me (Proton 4)") + .unwrap(); + } + let reopened = Db::open(&path).unwrap(); + assert_eq!( + reopened + .state_str("shared_with_me_name") + .unwrap() + .as_deref(), + Some("Shared with me (Proton 4)") + ); + drop(reopened); + let _ = std::fs::remove_file(path); +} + fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry { LocalEntry { path: path.into(), diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index 02ffd7b..b484060 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -36,6 +36,21 @@ fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) { } } +fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) { + let child = st.by_uid.get(uid).copied(); + let dentry = child.and_then(|ino| { + st.entries + .get(&ino) + .map(|entry| (entry.parent, entry.node.name.clone())) + }); + let changed = st.downgrade_shared_subtree(uid); + st.hide_foreign_subtree(uid); + invalidate_access_changes(notifier, &changed); + if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) { + let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name)); + } +} + /// Persist the fail-closed part of an access event and install the same denial /// in resident state even when SQLite rejects the write. The caller must not /// acknowledge the event when this returns an error. @@ -61,6 +76,34 @@ fn apply_access_downgrade( } } +fn apply_foreign_delete( + db: &Db, + uid: &NodeUid, + mut deny_and_hide: impl FnMut(&NodeUid), +) -> pdfs_core::Result<()> { + let result = db.tombstone_foreign_subtree(uid); + deny_and_hide(uid); + result +} + +fn is_foreign_node_delete(event: &DriveEvent, own_volume: &VolumeId) -> bool { + match event { + DriveEvent::NodeDeleted { node_uid, .. } => { + !is_own_or_virtual_uid(node_uid, own_volume) && !is_local_uid(node_uid) + } + _ => false, + } +} + +fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId) -> bool { + matches!( + event, + DriveEvent::ContinuityLost { .. } + | DriveEvent::ScopeAccessLost { .. } + | DriveEvent::SharedWithMeUpdated { .. } + ) || is_foreign_node_delete(event, own_volume) +} + /// Apply one remote event to the local cache and notify the kernel so it drops /// any stale cached metadata/data for the affected inodes. /// @@ -78,15 +121,32 @@ fn apply_access_downgrade( /// mount must be notified through its **own** channel; that pairing is what /// [`Core::for_each_mount`] exists to preserve. fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> { - let access_result = apply_access_downgrade(&core.db, event, |root| { - core.for_each_mount(|st, notifier| { - let changed = match root { - Some(uid) => st.downgrade_shared_subtree(uid), - None => st.downgrade_known_shared_access(), - }; - invalidate_access_changes(notifier, &changed); - }); - }); + let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id); + let serializes_shared_publication = + event_serializes_shared_publication(event, &core.primary_root_uid.volume_id); + let _publication = serializes_shared_publication.then(|| core.shared_publication.lock()); + if serializes_shared_publication { + core.invalidate_shared_refreshes(); + } + let mut apply_result = if let DriveEvent::NodeDeleted { node_uid, .. } = event + && foreign_delete + { + apply_foreign_delete(&core.db, node_uid, |uid| { + core.for_each_mount(|st, notifier| { + hide_foreign_deleted_and_notify(st, notifier, uid); + }); + }) + } else { + apply_access_downgrade(&core.db, event, |root| { + core.for_each_mount(|st, notifier| { + let changed = match root { + Some(uid) => st.downgrade_shared_subtree(uid), + None => st.downgrade_known_shared_access(), + }; + invalidate_access_changes(notifier, &changed); + }); + }) + }; match event { DriveEvent::NodeUpdated { node_uid, @@ -148,9 +208,11 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf } DriveEvent::NodeDeleted { node_uid, .. } => { core.cache.evict(node_uid); - core.for_each_mount(|st, notifier| { - forget_and_notify(st, notifier, node_uid); - }); + if !foreign_delete { + core.for_each_mount(|st, notifier| { + forget_and_notify(st, notifier, node_uid); + }); + } } // Losing event continuity or any shared-access signal makes persisted // shared roots untrustworthy. Keep owned/device trees unchanged, but @@ -160,6 +222,7 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf | DriveEvent::ScopeAccessLost { .. } | DriveEvent::SharedWithMeUpdated { .. } => { warn!("event access continuity lost; downgrading shared trees and resyncing lazily"); + apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS)); core.for_each_mount(|st, notifier| { let dirs: Vec = st.children.keys().copied().collect(); for &ino in &dirs { @@ -174,7 +237,7 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf // caller persisting the event id. DriveEvent::CursorAdvanced { .. } => {} } - access_result + apply_result } fn acknowledge_applied_event( @@ -469,6 +532,172 @@ mod tests { NodeUid::new(VolumeId::from("vol"), LinkId::from(link)) } + fn folder(volume: &str, link: &str, parent: Option, name: &str) -> Node { + Node { + uid: NodeUid::new(VolumeId::from(volume), LinkId::from(link)), + parent_uid: parent, + kind: NodeKind::Folder, + name: name.to_string(), + creation_time: 1, + modification_time: 1, + trashed: false, + is_shared: false, + is_shared_publicly: false, + signature_email: None, + membership: None, + verification: Default::default(), + } + } + + #[test] + fn foreign_delete_invalidates_an_earlier_refresh_generation() { + let own_volume = VolumeId::from("own"); + let event = DriveEvent::NodeDeleted { + id: DriveEventId::from("foreign-delete"), + node_uid: NodeUid::new(VolumeId::from("foreign"), LinkId::from("child")), + parent_node_uid: None, + }; + let generation = AtomicU64::new(7); + let captured = generation.load(Ordering::SeqCst); + + assert!(event_serializes_shared_publication(&event, &own_volume)); + generation.fetch_add(1, Ordering::SeqCst); + + assert!(!refresh_generation_is_current( + captured, + generation.load(Ordering::SeqCst) + )); + let own_delete = DriveEvent::NodeDeleted { + id: DriveEventId::from("own-delete"), + node_uid: NodeUid::new(own_volume.clone(), LinkId::from("child")), + parent_node_uid: None, + }; + assert!(!event_serializes_shared_publication( + &own_delete, + &own_volume + )); + } + + #[test] + fn foreign_delete_tombstones_subtree_and_advances_cursor_after_success() { + let db = Db::open(std::path::Path::new(":memory:")).unwrap(); + let root = folder("own", "root", None, "My Files"); + let deleted = folder( + "foreign", + "deleted", + Some(root.uid.clone()), + "Deleted foreign", + ); + let deep = folder( + "foreign", + "deep", + Some(deleted.uid.clone()), + "Searchable descendant", + ); + db.upsert_nodes(&[root, deleted.clone(), deep.clone()]) + .unwrap(); + db.set_share_access(&deleted.uid, Access::Editor).unwrap(); + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_RENAME.to_string(), + uid: deep.uid.to_string(), + parent_uid: Some(deleted.uid.to_string()), + name: Some("pending".to_string()), + blob_path: None, + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + let event = DriveEvent::NodeDeleted { + id: DriveEventId::from("foreign-delete-success"), + node_uid: deleted.uid.clone(), + parent_node_uid: deleted.parent_uid.clone(), + }; + let denied = Cell::new(false); + + let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true)); + let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap(); + + assert!(denied.get()); + assert_eq!(acknowledged.as_str(), "foreign-delete-success"); + assert_eq!( + db.get_event_cursor().unwrap().as_deref(), + Some("foreign-delete-success") + ); + for uid in [&deleted.uid, &deep.uid] { + assert!(db.node_by_uid(&uid.to_string()).unwrap().unwrap().trashed); + } + assert!(db.search("Searchable", 10).unwrap().is_empty()); + assert_eq!(db.pending_ops().unwrap().len(), 1); + assert_eq!(db.share_access(&deleted.uid).unwrap(), Some(Access::Viewer)); + } + + #[test] + fn foreign_delete_db_failure_denies_memory_and_retains_prior_cursor() { + let db = Db::open(std::path::Path::new(":memory:")).unwrap(); + let root = folder("own", "root", None, "My Files"); + let deleted = folder( + "foreign", + "deleted", + Some(root.uid.clone()), + "Deleted foreign", + ); + let deep = folder( + "foreign", + "deep", + Some(deleted.uid.clone()), + "Still searchable", + ); + db.upsert_nodes(&[root, deleted.clone(), deep.clone()]) + .unwrap(); + db.set_share_access(&deleted.uid, Access::Editor).unwrap(); + db.set_event_cursor("prior-event").unwrap(); + db.with_conn(|conn| { + conn.execute_batch( + "CREATE TRIGGER reject_foreign_tombstone + BEFORE UPDATE OF trashed ON nodes + WHEN OLD.uid = 'foreign~deleted' + BEGIN SELECT RAISE(ABORT, 'forced tombstone failure'); END;", + )?; + Ok(()) + }) + .unwrap(); + let event = DriveEvent::NodeDeleted { + id: DriveEventId::from("foreign-delete-failed"), + node_uid: deleted.uid.clone(), + parent_node_uid: deleted.parent_uid.clone(), + }; + let denied = Cell::new(false); + + let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true)); + + assert!(applied.is_err()); + assert!( + denied.get(), + "resident state must fail closed on DB failure" + ); + assert!(acknowledge_applied_event(&db, &event, applied).is_err()); + assert_eq!( + db.get_event_cursor().unwrap().as_deref(), + Some("prior-event") + ); + assert!( + !db.node_by_uid(&deleted.uid.to_string()) + .unwrap() + .unwrap() + .trashed + ); + assert_eq!(db.search("searchable", 10).unwrap().len(), 1); + assert_eq!( + db.share_access(&deleted.uid).unwrap(), + Some(Access::Editor), + "the failed atomic transaction must not partially publish" + ); + } + #[test] fn deleted_share_downgrade_failure_denies_memory_and_retains_cursor() { let db = Db::open(std::path::Path::new(":memory:")).unwrap(); diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 018c429..4193f5d 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -489,6 +489,7 @@ impl Core { /// (devices.md Phase 3). pub(crate) fn fork_state(&self) -> Core { let mut fork = self.clone(); + fork.primary = false; let share_access = self.state.lock().share_access.clone(); fork.state = Arc::new(Mutex::new(State { entries: HashMap::new(), diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 3895e49..7e8e820 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -62,18 +62,18 @@ use pdfs_core::control::{ }; use pdfs_core::db::{ Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp, - RenameMeta, StoredNode, StoredSyncFolder, StoredTrash, + PublishedSharedRoot, RenameMeta, StoredNode, StoredSyncFolder, StoredTrash, }; use pdfs_core::localindex; use pdfs_core::search::relevance_score; use pdfs_core::syncignore::is_transient_name; -use pdfs_core::{Access, CoreError, CoreResult, perm_bits}; +use pdfs_core::{Access, CoreError, CoreResult, access_for, perm_bits}; use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::error::ProtonError; -use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; +use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, VolumeId}; use proton_drive_rs::{ DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, ProtonDriveClient, - ProtonPhotosClient, RevisionReader, ThumbnailType, + ProtonPhotosClient, RevisionReader, SharedWithMeItem, ThumbnailType, }; mod background; @@ -93,6 +93,7 @@ mod sweep; mod sync; mod transfers; mod upload; +mod r#virtual; mod workers; use background::{run_event_sync, run_local_index}; pub(crate) use mount::is_stale_mount; @@ -102,6 +103,11 @@ use reads::{ReaderSlot, STREAM_BYPASS_MIN, StreamRing}; use state::{Entry, Intervals, PendingRevision, State, WriteHandle}; use tracing::{debug, error, info, warn}; use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry}; +use r#virtual::{ + SharedListingPlan, SharedRefreshDeadlines, disambiguate_shared_names, is_own_or_virtual_uid, + is_primary_root_listing, is_virtual_uid, listing_needs_refresh, refresh_generation_is_current, + shared_listing_plan, shared_with_me_uid, virtual_node, virtual_root_name, +}; use workers::{FUSE_WORKERS, Lane, Workers}; /// Attribute/entry cache lifetime handed back to the kernel. Long because the @@ -151,6 +157,9 @@ const TIMELINE_ENRICH_CHUNK: usize = 200; /// one listing a user changes and then immediately looks at — though our own /// mutations also invalidate it outright, so this only covers other clients. const TRASH_TTL: Duration = Duration::from_secs(60); +/// Shared roots and foreign-volume folders have no event manager. Revalidate +/// them on access while online; stale persisted listings remain usable offline. +const SHARED_LISTING_TTL: Duration = Duration::from_secs(60); /// How many trashed nodes are materialized per /// [`ProtonDriveClient::enumerate_nodes`] call, and persisted as a batch. The /// trash of an account that has been through a conflict storm runs to thousands @@ -174,6 +183,9 @@ const TRASH_FIRST_WAIT: Duration = Duration::from_secs(20); const PHOTOS_SYNCED_MS: &str = "photos_synced_ms"; const PHOTOS_AVAILABLE: &str = "photos_available"; const TRASH_SYNCED_MS: &str = "trash_synced_ms"; +const SHARED_WITH_ME_NAME: &str = "shared_with_me_name"; +const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms"; +const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:"; /// How stale the local-file index may get before the background scanner rebuilds /// it. A rescan is a full walk of `$HOME`, so this trades index freshness against @@ -213,6 +225,12 @@ const SIZE_UPGRADE_CHUNK: usize = 150; struct Core { client: ProtonDriveClient, rt: tokio::runtime::Handle, + /// The daemon's My Files root. Forked on-demand sessions retain this value + /// so ownership checks never mistake their own mount root for account scope. + primary_root_uid: NodeUid, + /// False only for a forked on-demand device mount. The synthetic shared + /// directory belongs exclusively to the primary My Files inode space. + primary: bool, state: Arc>, cache: Arc, /// Open [`RevisionReader`]s keyed by node, so the block fetches of a file @@ -232,6 +250,14 @@ struct Core { /// `State` maps. Every mutation writes through here, and the maps rehydrate /// from it on mount (plan.md P1). db: Arc, + /// Serializes only shared-list publication and access-loss invalidation. + /// Network calls happen before taking it. + shared_publication: Arc>, + /// Invalidates in-flight shared responses when access/list events arrive. + shared_generation: Arc, + /// Successful refreshes suppress retries even if persisting their timestamp + /// fails. Shared across primary/fork clones and cleared by access events. + shared_refresh_deadlines: Arc>, /// False while the API is unreachable and we are serving the cached tree /// (offline.md Phase 1). Set by the probe thread; read by front-ends through /// `Response::Status` so the UI can say so rather than leaving the user to @@ -658,6 +684,99 @@ fn release_can_discard_unlinked(db: &Db, uid: &NodeUid) -> bool { } } +struct AcceptedShares { + uids: Vec, + /// `None` means duplicate listing rows disagreed on provenance. + share_ids: HashMap>, +} + +fn accepted_share_provenance(items: Vec) -> AcceptedShares { + let mut uids = Vec::new(); + let mut share_ids = HashMap::new(); + for item in items { + match share_ids.entry(item.uid.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + uids.push(item.uid); + entry.insert(Some(item.share_id)); + } + std::collections::hash_map::Entry::Occupied(mut entry) => { + if entry.get().as_ref() != Some(&item.share_id) { + entry.insert(None); + } + } + } + } + AcceptedShares { uids, share_ids } +} + +fn shared_root_access(nodes: &[&Node], expected_share: Option<&ShareId>) -> Access { + let Some(expected_share) = expected_share else { + return Access::Viewer; + }; + let mut observed = None; + for node in nodes { + let Some(membership) = node.membership.as_ref() else { + return Access::Viewer; + }; + if &membership.share_id != expected_share { + return Access::Viewer; + } + let Some(role) = membership.role_exact() else { + return Access::Viewer; + }; + let access = access_for(Some(role), Access::Viewer, true); + if observed.is_some_and(|prior| prior != access) { + return Access::Viewer; + } + observed = Some(access); + } + observed.unwrap_or(Access::Viewer) +} + +fn prepare_shared_roots( + accepted: &AcceptedShares, + mut materialized: Vec, + parent: &NodeUid, +) -> Vec { + materialized.retain(|node| accepted.share_ids.contains_key(&node.uid)); + materialized.sort_by(|a, b| { + a.uid + .to_string() + .cmp(&b.uid.to_string()) + .then_with(|| a.name.cmp(&b.name)) + }); + let mut published = Vec::new(); + let mut start = 0usize; + while start < materialized.len() { + let uid = materialized[start].uid.clone(); + let mut end = start + 1; + while end < materialized.len() && materialized[end].uid == uid { + end += 1; + } + let access = shared_root_access( + &materialized[start..end].iter().collect::>(), + accepted.share_ids.get(&uid).and_then(Option::as_ref), + ); + let mut node = materialized[start].clone(); + node.parent_uid = Some(parent.clone()); + node.trashed = false; + published.push(PublishedSharedRoot { node, access }); + start = end; + } + let mut named: Vec = published.iter().map(|root| root.node.clone()).collect(); + disambiguate_shared_names(&mut named); + let names: HashMap = named + .into_iter() + .map(|node| (node.uid, node.name)) + .collect(); + for root in &mut published { + if let Some(name) = names.get(&root.node.uid) { + root.node.name.clone_from(name); + } + } + published +} + impl Core { /// Record that this daemon just changed `uid` on the remote, so the event /// feed's echo of that change is recognised instead of re-applied. Called @@ -1011,29 +1130,281 @@ impl Core { apply_pending_sizes(nodes, &sizes); } + fn prepare_virtual_root(&self, real_names: &HashSet) -> Result<(Node, bool), Errno> { + let pinned = self.db.state_str(SHARED_WITH_ME_NAME).map_err(|error| { + error!(%error, "reading shared-root display name failed"); + Errno::EIO + })?; + let (name, visible) = virtual_root_name(real_names, pinned.as_deref()); + if pinned.is_none() { + self.db + .set_state_str(SHARED_WITH_ME_NAME, &name) + .map_err(|error| { + error!(%error, "persisting shared-root display name failed"); + Errno::EIO + })?; + } + + let uid = shared_with_me_uid(); + let mut node = self + .db + .node_by_uid(&uid.to_string()) + .map_err(|error| { + error!(%error, "reading synthetic shared root failed"); + Errno::EIO + })? + .unwrap_or_else(|| { + virtual_node(self.primary_root_uid.clone(), name.clone(), now_secs()) + }); + node.parent_uid = Some(self.primary_root_uid.clone()); + node.name = name; + node.kind = NodeKind::Folder; + // A later real-name collision suppresses both the dentry and its search + // hit. The row and pinned name remain, and clearing the collision + // restores both by writing `trashed = false` again. + node.trashed = !visible; + node.membership = None; + self.db.ensure_virtual_root(&node).map_err(|error| { + error!(%error, "persisting synthetic shared root failed"); + Errno::EIO + })?; + Ok((node, visible)) + } + + /// Reconcile the synthetic dentry against an already-known primary-root + /// listing. The node remains persisted and pinned even while a real folder + /// of the same name temporarily suppresses its dentry. + fn reconcile_virtual_root_dentry( + &self, + parent_ino: u64, + real_names: HashSet, + ) -> Result<(), Errno> { + let (node, visible) = self.prepare_virtual_root(&real_names)?; + let uid = node.uid.clone(); + let mut st = self.state.lock(); + st.share_access.insert(uid, Access::Viewer); + let virtual_ino = st.intern_mem(parent_ino, node); + let children = st.children.entry(parent_ino).or_default(); + children.retain(|ino| *ino != virtual_ino); + if visible { + children.push(virtual_ino); + } + drop(st); + self.flush_access_changes(); + Ok(()) + } + + fn resident_real_root_names(&self, ino: u64) -> HashSet { + let st = self.state.lock(); + st.children + .get(&ino) + .into_iter() + .flatten() + .filter_map(|child| st.entries.get(child)) + .filter(|entry| !is_virtual_uid(&entry.uid)) + .map(|entry| entry.node.name.clone()) + .collect() + } + + fn shared_folder_freshness_key(uid: &NodeUid) -> String { + format!("{SHARED_FOLDER_SYNCED_PREFIX}{uid}") + } + + fn shared_listing_stale(&self, key: &str) -> bool { + if self + .shared_refresh_deadlines + .lock() + .is_fresh(key, Instant::now()) + { + return false; + } + self.listing_stale(key, SHARED_LISTING_TTL) + } + + fn mark_shared_refresh_success(&self, key: &str) { + self.shared_refresh_deadlines + .lock() + .mark(key, Instant::now(), SHARED_LISTING_TTL); + if let Err(error) = self.db.set_state_i64(key, now_millis()) { + warn!(key, %error, "persisting shared-list freshness failed"); + } + } + + fn invalidate_shared_refreshes(&self) { + self.shared_generation.fetch_add(1, Ordering::SeqCst); + self.shared_refresh_deadlines.lock().clear(); + } + + fn is_own_or_virtual(&self, uid: &NodeUid) -> bool { + is_own_or_virtual_uid(uid, &self.primary_root_uid.volume_id) + } + + fn ensure_shared_children(&self, ino: u64) -> Result<(), Errno> { + let online = self.online.load(Ordering::Relaxed); + let resident = self.state.lock().children.contains_key(&ino); + match shared_listing_plan( + resident, + online, + self.shared_listing_stale(SHARED_WITH_ME_SYNCED_MS), + ) { + SharedListingPlan::Resident => return Ok(()), + SharedListingPlan::Persisted => { + // A completed snapshot remains useful offline even after an + // event expired its freshness stamp. + let nodes = self + .db + .visible_children(&shared_with_me_uid()) + .map_err(|error| { + error!(%error, "loading persisted shared roots failed"); + Errno::EIO + })?; + let mut st = self.state.lock(); + if st.children.contains_key(&ino) { + return Ok(()); + } + let children = nodes + .into_iter() + .map(|node| { + let access = st + .share_access + .get(&node.uid) + .copied() + .unwrap_or(Access::Viewer); + st.intern_published_share_root(ino, node, access) + }) + .collect(); + st.children.insert(ino, children); + drop(st); + self.flush_access_changes(); + return Ok(()); + } + SharedListingPlan::Refresh => {} + } + + let generation = self.shared_generation.load(Ordering::SeqCst); + let accepted = accepted_share_provenance( + self.rt + .block_on(self.client.enumerate_shared_with_me()) + .map_err(|error| { + error!(%error, "enumerating shared roots failed"); + Errno::EIO + })?, + ); + let nodes = if accepted.uids.is_empty() { + Vec::new() + } else { + self.rt + .block_on(self.client.enumerate_nodes_light(&accepted.uids)) + .map_err(|error| { + error!(%error, "materializing shared roots failed"); + Errno::EIO + })? + }; + let virtual_uid = shared_with_me_uid(); + let published = prepare_shared_roots(&accepted, nodes, &virtual_uid); + + let _publication = self.shared_publication.lock(); + if !refresh_generation_is_current(generation, self.shared_generation.load(Ordering::SeqCst)) + { + return Err(Errno::EAGAIN); + } + let removed = match self + .db + .publish_shared_roots(&virtual_uid, &accepted.uids, &published) + { + Ok(removed) => removed, + Err(error) => { + error!(%error, "publishing shared-root listing failed"); + let mut st = self.state.lock(); + for uid in &accepted.uids { + st.downgrade_shared_subtree(uid); + } + drop(st); + self.flush_access_changes(); + return Err(Errno::EIO); + } + }; + let snapshot = self.db.visible_children(&virtual_uid).map_err(|error| { + error!(%error, "loading published shared-root listing failed"); + Errno::EIO + })?; + let published_access: HashMap = published + .iter() + .map(|root| (root.node.uid.clone(), root.access)) + .collect(); + let mut st = self.state.lock(); + for uid in &removed { + st.hide_shared_root(uid); + } + let mut children = Vec::with_capacity(snapshot.len()); + for node in snapshot { + let uid = node.uid.clone(); + let access = published_access + .get(&uid) + .copied() + .unwrap_or(Access::Viewer); + let child = st.intern_published_share_root(ino, node, access); + children.push(child); + } + st.children.insert(ino, children); + drop(st); + self.flush_access_changes(); + self.mark_shared_refresh_success(SHARED_WITH_ME_SYNCED_MS); + if let Some(notifier) = self.notifier.get() { + let _ = notifier.inval_inode(INodeNo(ino), 0, 0); + } + Ok(()) + } + /// Enumerate `ino`'s children from the remote and cache them. No-op if the /// directory has already been listed. Network I/O happens without the lock /// held so concurrent metadata reads aren't blocked behind a fetch. fn ensure_children(&self, ino: u64) -> Result<(), Errno> { - let folder_uid = { + let (folder_uid, cached) = { let st = self.state.lock(); - if st.children.contains_key(&ino) { - return Ok(()); - } match st.entries.get(&ino) { - Some(e) => e.uid.clone(), + Some(e) => (e.uid.clone(), st.children.contains_key(&ino)), None => return Err(Errno::ENOENT), } }; + if is_virtual_uid(&folder_uid) { + return self.ensure_shared_children(ino); + } + let primary_root = + is_primary_root_listing(self.primary, &folder_uid, &self.primary_root_uid); + let foreign = !self.is_own_or_virtual(&folder_uid) && !is_local_uid(&folder_uid); + let foreign_key = foreign.then(|| Self::shared_folder_freshness_key(&folder_uid)); + let refresh_foreign = foreign_key.as_deref().is_some_and(|key| { + listing_needs_refresh( + self.online.load(Ordering::Relaxed), + self.shared_listing_stale(key), + ) + }); + if cached && !refresh_foreign { + if primary_root { + self.reconcile_virtual_root_dentry(ino, self.resident_real_root_names(ino))?; + } + return Ok(()); + } // Offline fast path: a folder the DB still records as fully enumerated // can be rebuilt from disk without hitting the API, even if its listing // was trimmed from the hot cache mid-run. - match self.db.children_if_listed(&folder_uid) { + let cached_nodes = if refresh_foreign { + Ok(None) + } else { + self.db.children_if_listed(&folder_uid) + }; + match cached_nodes { Ok(Some(mut nodes)) => { + if primary_root { + nodes.retain(|node| !is_virtual_uid(&node.uid)); + } // Before the lock: a DB row carries the size the server last // sealed, which a queued write is ahead of (B11). self.stamp_pending_sizes(&mut nodes); + let real_names: HashSet = + nodes.iter().map(|node| node.name.clone()).collect(); let hidden = self.hidden.lock().clone(); let mut st = self.state.lock(); if st.children.contains_key(&ino) { @@ -1059,6 +1430,9 @@ impl Core { st.children.insert(ino, child_inos); drop(st); self.flush_access_changes(); + if primary_root { + self.reconcile_virtual_root_dentry(ino, real_names)?; + } // Rows persisted from a cheap enumeration whose upgrade never // ran (a restart in between, say) still owe their real sizes. self.spawn_size_upgrade(ino, needs_size); @@ -1068,6 +1442,8 @@ impl Core { Err(e) => warn!(%folder_uid, error = %e, "db children_if_listed failed"), } + let refresh_generation = + refresh_foreign.then(|| self.shared_generation.load(Ordering::SeqCst)); let uids = self .rt .block_on(self.client.enumerate_folder_children_node_uids(&folder_uid)) @@ -1094,18 +1470,75 @@ impl Core { // Same as the DB path above: the remote's size for a file with a write // still queued is the pre-write one (B11). self.stamp_pending_sizes(&mut nodes); + let real_names: HashSet = nodes.iter().map(|node| node.name.clone()).collect(); let hidden = self.hidden.lock().clone(); + let mut filtered_nodes: Vec = nodes + .into_iter() + .filter(|node| node_visible(node, &folder_uid, &hidden)) + .collect(); + if foreign { + for node in &mut filtered_nodes { + node.parent_uid = Some(folder_uid.clone()); + } + let _publication = self.shared_publication.lock(); + if !refresh_generation_is_current( + refresh_generation.expect("foreign refresh has a generation"), + self.shared_generation.load(Ordering::SeqCst), + ) { + return Err(Errno::EAGAIN); + } + let removed = self + .db + .publish_foreign_children(&folder_uid, &uids, &filtered_nodes) + .map_err(|error| { + error!(%folder_uid, %error, "publishing foreign-folder listing failed"); + Errno::EIO + })?; + let mut snapshot = self.db.visible_children(&folder_uid).map_err(|error| { + error!(%folder_uid, %error, "loading published foreign-folder listing failed"); + Errno::EIO + })?; + self.stamp_pending_sizes(&mut snapshot); + snapshot.retain(|node| node_visible(node, &folder_uid, &hidden)); + let needs_size: Vec = snapshot + .iter() + .filter(|node| { + matches!( + &node.kind, + NodeKind::File { + claimed_size: None, + .. + } + ) + }) + .map(|node| node.uid.clone()) + .collect(); + let mut st = self.state.lock(); + for uid in &removed { + st.hide_foreign_subtree(uid); + } + let children = snapshot + .into_iter() + .map(|node| st.intern_from_db(ino, node)) + .collect(); + st.children.insert(ino, children); + drop(st); + self.flush_access_changes(); + self.mark_shared_refresh_success( + foreign_key + .as_deref() + .expect("foreign refresh has a freshness key"), + ); + self.spawn_size_upgrade(ino, needs_size); + return Ok(()); + } let mut st = self.state.lock(); // Lost the race? Another thread already populated it. if st.children.contains_key(&ino) { return Ok(()); } - let mut child_inos = Vec::with_capacity(nodes.len()); - let filtered_nodes: Vec = nodes - .into_iter() - .filter(|node| node_visible(node, &folder_uid, &hidden)) - .collect(); + let mut child_inos = Vec::with_capacity(filtered_nodes.len()); // Files whose real size the cheap enumeration could not read. Collected // before interning so the upgrade below has the uids without re-walking. let needs_size: Vec = filtered_nodes @@ -1131,6 +1564,9 @@ impl Core { } drop(st); self.flush_access_changes(); + if primary_root { + self.reconcile_virtual_root_dentry(ino, real_names)?; + } self.spawn_size_upgrade(ino, needs_size); Ok(()) } @@ -4035,11 +4471,12 @@ mod pending_size_tests { mod tests { use super::{ Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, SELF_CHANGE_TTL_MS, - StateRegistry, conflict_name, copy_pending_for_truncate, fuse_name, is_stale_mount, - node_visible, note_self_change, parse_node_uid, preserve_on_access_denied, + ShareId, SharedWithMeItem, StateRegistry, accepted_share_provenance, conflict_name, + copy_pending_for_truncate, fuse_name, is_stale_mount, node_visible, note_self_change, + parse_node_uid, prepare_shared_roots, preserve_on_access_denied, release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue, require_node_parent_access, require_rename_access, require_uid_access, - take_self_change, + shared_with_me_uid, take_self_change, }; use crate::filesystem::access_allowed; use pdfs_core::cache::{Baseline, StagedWrite}; @@ -4448,6 +4885,84 @@ mod tests { assert!(require_uid_access(&db, &uid, &[Access::Editor]).is_ok()); } + #[test] + fn shared_listing_provenance_must_agree_before_editor_is_granted() { + use proton_drive_rs::ShareMembership; + use proton_drive_rs::proton_sdk::ids::ShareMembershipId; + + let uid = NodeUid::new(VolumeId::from("foreign"), LinkId::from("root")); + let share_a = ShareId::from("share-a"); + let share_b = ShareId::from("share-b"); + let parent = shared_with_me_uid(); + let item = |share_id: ShareId| SharedWithMeItem { + uid: uid.clone(), + share_id, + }; + let node = |share_id: Option, permissions: i32| { + let mut node = node_helper("root", "none", "Shared", true); + node.uid = uid.clone(); + node.membership = share_id.map(|share_id| ShareMembership { + share_id, + membership_id: ShareMembershipId::from("membership"), + permissions, + }); + node + }; + + let accepted = + accepted_share_provenance(vec![item(share_a.clone()), item(share_a.clone())]); + assert_eq!( + prepare_shared_roots(&accepted, vec![node(Some(share_a.clone()), 6)], &parent)[0] + .access, + Access::Editor + ); + for materialized in [ + node(None, 6), + node(Some(share_b.clone()), 6), + node(Some(share_a.clone()), 12345), + ] { + assert_eq!( + prepare_shared_roots(&accepted, vec![materialized], &parent)[0].access, + Access::Viewer + ); + } + + let conflicting = accepted_share_provenance(vec![item(share_a), item(share_b)]); + assert_eq!( + prepare_shared_roots( + &conflicting, + vec![node(Some(ShareId::from("share-a")), 6)], + &parent + )[0] + .access, + Access::Viewer, + "duplicate UID provenance disagreement must fail closed" + ); + } + + #[test] + fn virtual_bulk_upload_destination_is_denied_by_runtime_authority() { + let (mut state, _dir) = state_test_helper(); + let root = state.intern(0, node_helper("root", "none", "My Files", true)); + let mut virtual_node = node_helper("sharedwithme", "root", "Shared with me", true); + virtual_node.uid = shared_with_me_uid(); + state + .db + .set_share_access(&virtual_node.uid, Access::Viewer) + .unwrap(); + state + .share_access + .insert(virtual_node.uid.clone(), Access::Viewer); + let virtual_ino = state.intern(root, virtual_node); + let uid = state.entries[&virtual_ino].uid.clone(); + assert_eq!( + require_uid_access(&state.db, &uid, &[Access::Viewer]) + .unwrap_err() + .code(), + libc::EACCES + ); + } + fn function_source<'a>(source: &'a str, signature: &str) -> &'a str { let start = source.find(signature).expect("function signature exists"); let body = &source[start..]; @@ -4550,6 +5065,14 @@ mod tests { assert_before(function, "source_parent_uid", gate); assert_before(function, gate, remote_call); } + + let upload = include_str!("upload.rs"); + let run_uploads = function_source(upload, "async fn run_uploads("); + assert_before(run_uploads, "require_uid_writable", ".upload_file_from"); + let bulk = function_source(upload, "pub(super) fn upload_paths("); + assert_before(bulk, "require_uid_writable", "collect_uploads"); + let folder = function_source(upload, "fn ensure_remote_folder("); + assert_before(folder, "require_uid_writable(parent_uid)", ".create_folder"); } #[test] diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index b35a370..9067f73 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -191,6 +191,8 @@ pub fn mount( let core = Core { client: client.clone(), rt: rt.clone(), + primary_root_uid: root.uid.clone(), + primary: true, state: Arc::new(Mutex::new(State { entries: HashMap::new(), by_uid: HashMap::new(), @@ -208,6 +210,9 @@ pub fn mount( stream_ring: Arc::new(Mutex::new(StreamRing::default())), workers: Arc::new(Workers::new(FUSE_WORKERS)?), db, + shared_publication: Arc::new(Mutex::new(())), + shared_generation: Arc::new(AtomicU64::new(0)), + shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())), online: Arc::new(AtomicBool::new(online)), pending: Arc::new(Mutex::new(HashMap::new())), hidden: Arc::new(Mutex::new(HashSet::new())), diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index 4696bb8..f644f34 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -10,8 +10,10 @@ use std::sync::Arc; use pdfs_core::cache::StagedWrite; use pdfs_core::db::Db; use pdfs_core::{Access, access_for}; +#[cfg(test)] +use proton_drive_rs::MemberRole; use proton_drive_rs::proton_sdk::ids::NodeUid; -use proton_drive_rs::{MemberRole, Node, NodeKind}; +use proton_drive_rs::{Node, NodeKind}; use tracing::warn; /// A node known to the filesystem, addressed by its kernel inode. @@ -227,7 +229,7 @@ impl State { /// `share_access`. P3 can call this method only when its share-root endpoint /// explicitly observed a role; explicit absence or an unknown wire role is /// represented by `None` and fails closed to Viewer. - #[allow(dead_code)] // P3 consumes this explicit-provenance hook. + #[cfg(test)] pub(crate) fn record_observed_share_root_role( &mut self, uid: &NodeUid, @@ -266,6 +268,50 @@ impl State { changed } + /// Install access already committed atomically with the root node. Unlike + /// the observation hook this performs no database write and cannot swallow + /// a publication failure. + pub(crate) fn intern_published_share_root( + &mut self, + parent: u64, + node: Node, + access: Access, + ) -> u64 { + self.share_access.insert(node.uid.clone(), access); + self.intern_mem_with_access(parent, node, access) + } + + /// Withdraw a revoked shared root from the namespace without deleting its + /// resident inode or persisted authority. Open handles remain usable for + /// reads, while removing the root's cached listing guarantees a later + /// reappearance re-enumerates descendants instead of exposing stale rows. + pub(crate) fn hide_shared_root(&mut self, uid: &NodeUid) -> Vec { + let changed = self.downgrade_shared_subtree(uid); + self.hide_subtree_mem(uid); + changed + } + + /// Withdraw a foreign child removed by its authoritative folder listing. + /// Authority is retained because this is content deletion, not access loss. + pub(crate) fn hide_foreign_subtree(&mut self, uid: &NodeUid) { + self.hide_subtree_mem(uid); + } + + fn hide_subtree_mem(&mut self, uid: &NodeUid) { + let Some(&ino) = self.by_uid.get(uid) else { + return; + }; + let Some(entry) = self.entries.get_mut(&ino) else { + return; + }; + entry.node.trashed = true; + let parent = entry.parent; + if let Some(children) = self.children.get_mut(&parent) { + children.retain(|child| *child != ino); + } + self.children.remove(&ino); + } + fn resident_children(&self) -> HashMap> { let mut children: HashMap> = HashMap::new(); for (&ino, entry) in &self.entries { @@ -482,8 +528,12 @@ impl State { /// only. Every caller owes the DB a write-through — see the callers below; /// the split exists so a batch can pay for one transaction instead of `n`. pub(crate) fn intern_mem(&mut self, parent: u64, node: Node) -> u64 { + let access = self.access_for_node(parent, &node); + self.intern_mem_with_access(parent, node, access) + } + + fn intern_mem_with_access(&mut self, parent: u64, node: Node, access: Access) -> u64 { if let Some(&ino) = self.by_uid.get(&node.uid) { - let access = self.access_for_node(parent, &node); let changed = self .entries .get(&ino) @@ -500,7 +550,6 @@ impl State { } return ino; } - let access = self.access_for_node(parent, &node); let ino = self.next_ino; self.next_ino += 1; self.by_uid.insert(node.uid.clone(), ino); @@ -1023,6 +1072,64 @@ mod tests { ); } + #[test] + fn omitted_accepted_root_is_visible_but_resident_access_fails_closed() { + let (mut st, _dir) = state(); + let owned_root = st.intern(0, node("root", "none", "My Files", true)); + let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme")); + let mut virtual_root = node("placeholder", "root", "Shared with me", true); + virtual_root.uid = virtual_uid.clone(); + st.db.upsert_node(&virtual_root).unwrap(); + st.db + .set_share_access(&virtual_uid, Access::Viewer) + .unwrap(); + let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer); + + let mut shared = node("shared", "placeholder", "Retained", true); + shared.parent_uid = Some(virtual_uid.clone()); + shared.membership = Some(membership(6)); + st.db.upsert_node(&shared).unwrap(); + st.db.set_share_access(&shared.uid, Access::Editor).unwrap(); + let shared_ino = + st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor); + let child = st.intern( + shared_ino, + node("shared-child", "shared", "pending.txt", false), + ); + st.db + .enqueue_op(&pdfs_core::db::PendingOp { + id: 0, + kind: pdfs_core::db::OP_RENAME.to_string(), + uid: uid("shared-child").to_string(), + parent_uid: Some(shared.uid.to_string()), + name: Some("pending-renamed.txt".to_string()), + blob_path: None, + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + assert_eq!(st.entries[&shared_ino].access, Access::Editor); + assert_eq!(st.entries[&child].access, Access::Editor); + + st.db + .publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[]) + .unwrap(); + let snapshot = st.db.visible_children(&virtual_uid).unwrap(); + assert_eq!(snapshot.len(), 1); + st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer); + + assert_eq!( + st.db.share_access(&shared.uid).unwrap(), + Some(Access::Viewer) + ); + assert_eq!(st.entries[&shared_ino].access, Access::Viewer); + assert_eq!(st.entries[&child].access, Access::Viewer); + assert_eq!(st.db.pending_ops().unwrap().len(), 1); + } + #[test] fn persisted_share_access_controls_offline_hydration() { let (mut st, _dir) = state(); @@ -1160,6 +1267,50 @@ mod tests { assert!(pending.contains(&child)); } + #[test] + fn synthetic_container_and_observed_roles_drive_shared_access() { + let (mut st, _dir) = state(); + let owned_root = st.intern(0, node("root", "none", "My Files", true)); + let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme")); + let mut virtual_node = node("sharedwithme", "root", "Shared with me", true); + virtual_node.uid = virtual_uid.clone(); + st.db + .set_share_access(&virtual_uid, Access::Viewer) + .unwrap(); + st.share_access.insert(virtual_uid.clone(), Access::Viewer); + let virtual_ino = st.intern(owned_root, virtual_node); + st.children.insert(virtual_ino, Vec::new()); + assert_eq!(st.entries[&virtual_ino].access, Access::Viewer); + + let viewer = st.intern(virtual_ino, node("viewer", "sharedwithme", "Viewer", true)); + let editor = st.intern(virtual_ino, node("editor", "sharedwithme", "Editor", true)); + let unknown = st.intern( + virtual_ino, + node("unknown", "sharedwithme", "Unknown", true), + ); + st.record_observed_share_root_role(&uid("viewer"), Some(MemberRole::Viewer)); + st.record_observed_share_root_role(&uid("editor"), Some(MemberRole::Editor)); + st.record_observed_share_root_role(&uid("unknown"), None); + let child = st.intern(editor, node("child", "editor", "child.txt", false)); + + assert_eq!(st.entries[&viewer].access, Access::Viewer); + assert_eq!(st.entries[&editor].access, Access::Editor); + assert_eq!(st.entries[&unknown].access, Access::Viewer); + assert_eq!(st.entries[&child].access, Access::Editor); + + st.children + .get_mut(&virtual_ino) + .unwrap() + .extend([viewer, editor, unknown]); + let changed = st.hide_shared_root(&uid("editor")); + assert!(changed.contains(&editor)); + assert!(changed.contains(&child)); + assert!(!st.children[&virtual_ino].contains(&editor)); + assert!(!st.children.contains_key(&editor)); + assert_eq!(st.entries[&editor].access, Access::Viewer); + assert_eq!(st.entries[&child].access, Access::Viewer); + } + #[test] fn restored_editor_access_marks_root_and_descendants_for_invalidation() { let (mut st, _dir) = state(); diff --git a/crates/pdfs-fuse/src/sweep.rs b/crates/pdfs-fuse/src/sweep.rs index 83e9e92..538ee00 100644 --- a/crates/pdfs-fuse/src/sweep.rs +++ b/crates/pdfs-fuse/src/sweep.rs @@ -82,7 +82,11 @@ impl Core { return; } }; - let nodes: Vec<_> = stored.into_iter().map(|s| s.node).collect(); + let nodes: Vec<_> = stored + .into_iter() + .map(|stored| stored.node) + .filter(|node| self.is_own_or_virtual(&node.uid)) + .collect(); // Index live (non-trashed) files by (parent, name) for sibling lookup. let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new(); diff --git a/crates/pdfs-fuse/src/upload.rs b/crates/pdfs-fuse/src/upload.rs index 3dfd98e..44614a9 100644 --- a/crates/pdfs-fuse/src/upload.rs +++ b/crates/pdfs-fuse/src/upload.rs @@ -63,6 +63,12 @@ async fn run_uploads( .transfers .begin(&t.name, "", TransferDirection::Upload, t.size); let reader = OwnedCountingReader::new(file, guard); + if let Err(error) = core.require_uid_writable(&t.parent_uid) { + return Err(( + t.name, + format!("destination access denied: errno {}", error.code()), + )); + } match core .client .upload_file_from( @@ -110,6 +116,8 @@ impl Core { let (pino, parent_uid) = self.resolve(parent_rel)?; self.ensure_children(pino) .map_err(|e| self.errno_error(e, "enumerate"))?; + self.require_uid_writable(&parent_uid) + .map_err(|error| self.errno_error(error, "upload destination"))?; // Phase 1 (sequential): build the remote folder skeleton and collect the // flat list of files to upload. Folders must exist before their children, @@ -207,19 +215,24 @@ impl Core { } self.ensure_children(pino) .map_err(|e| self.errno_error(e, "enumerate"))?; - { + let existing = { let st = self.state.lock(); - if let Some(kids) = st.children.get(&pino) { - for &ino in kids { - if let Some(e) = st.entries.get(&ino) - && e.node.is_folder() - && e.node.name == name - { - return Ok((ino, e.uid.clone())); - } - } - } + st.children.get(&pino).and_then(|kids| { + kids.iter().find_map(|ino| { + st.entries + .get(ino) + .filter(|entry| entry.node.is_folder() && entry.node.name == name) + .map(|entry| (*ino, entry.uid.clone())) + }) + }) + }; + if let Some((ino, uid)) = existing { + self.require_uid_writable(&uid) + .map_err(|error| self.errno_error(error, "upload destination"))?; + return Ok((ino, uid)); } + self.require_uid_writable(parent_uid) + .map_err(|error| self.errno_error(error, "upload destination"))?; let new_uid = self .rt .block_on( @@ -239,6 +252,8 @@ impl Core { } drop(st); self.flush_access_changes(); + self.require_uid_writable(&new_uid) + .map_err(|error| self.errno_error(error, "created upload destination"))?; Ok((ino, new_uid)) } diff --git a/crates/pdfs-fuse/src/virtual.rs b/crates/pdfs-fuse/src/virtual.rs new file mode 100644 index 0000000..ce6b657 --- /dev/null +++ b/crates/pdfs-fuse/src/virtual.rs @@ -0,0 +1,318 @@ +//! Synthetic nodes and naming rules for the mounted shared tree. + +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; +use proton_drive_rs::{Node, NodeKind}; + +pub(crate) const VIRTUAL_VOLUME: &str = "virtual"; +pub(crate) const SHARED_WITH_ME_LINK: &str = "sharedwithme"; +pub(crate) const SHARED_WITH_ME_BASE: &str = "Shared with me"; +const MAX_COMPONENT_BYTES: usize = 255; + +pub(crate) fn shared_with_me_uid() -> NodeUid { + NodeUid::new( + VolumeId::from(VIRTUAL_VOLUME), + LinkId::from(SHARED_WITH_ME_LINK), + ) +} + +pub(crate) fn is_virtual_uid(uid: &NodeUid) -> bool { + uid.volume_id.as_str() == VIRTUAL_VOLUME +} + +pub(crate) fn is_own_or_virtual_uid(uid: &NodeUid, own_volume: &VolumeId) -> bool { + uid.volume_id == *own_volume || is_virtual_uid(uid) +} + +pub(crate) fn is_primary_root_listing( + primary: bool, + folder_uid: &NodeUid, + primary_root_uid: &NodeUid, +) -> bool { + primary && folder_uid == primary_root_uid +} + +pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) -> Node { + Node { + uid: shared_with_me_uid(), + parent_uid: Some(parent_uid), + name, + kind: NodeKind::Folder, + creation_time: timestamp, + modification_time: timestamp, + trashed: false, + is_shared: false, + is_shared_publicly: false, + signature_email: None, + membership: None, + verification: Default::default(), + } +} + +/// Choose the initial synthetic-root name. Once persisted, `pinned` always wins; +/// a later real collision only controls whether the synthetic dentry is exposed. +pub(crate) fn virtual_root_name( + real_names: &HashSet, + pinned: Option<&str>, +) -> (String, bool) { + if let Some(pinned) = pinned { + return (pinned.to_string(), !real_names.contains(pinned)); + } + for n in 0usize.. { + let candidate = match n { + 0 => SHARED_WITH_ME_BASE.to_string(), + 1 => format!("{SHARED_WITH_ME_BASE} (Proton)"), + _ => format!("{SHARED_WITH_ME_BASE} (Proton {n})"), + }; + if !real_names.contains(&candidate) { + return (candidate, true); + } + } + unreachable!("the numbered virtual-root namespace is unbounded") +} + +/// Give every shared root a unique Linux component name, independent of API +/// ordering. Literal suffix-shaped names reserve their basename before duplicate +/// names are numbered, so `Photos (2)` is never stolen by a second `Photos`. +pub(crate) fn disambiguate_shared_names(nodes: &mut [Node]) { + nodes.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then_with(|| a.uid.to_string().cmp(&b.uid.to_string())) + }); + let reserved: HashSet = nodes + .iter() + .map(|node| component_with_suffix(&node.name, "")) + .collect(); + let mut used = HashSet::with_capacity(nodes.len()); + + for node in nodes { + let original = node.name.clone(); + let basename = component_with_suffix(&original, ""); + let chosen = if used.insert(basename.clone()) { + basename + } else { + let mut n = 2usize; + loop { + let suffix = format!(" ({n})"); + let candidate = component_with_suffix(&original, &suffix); + if !reserved.contains(&candidate) && used.insert(candidate.clone()) { + break candidate; + } + n += 1; + } + }; + node.name = chosen; + } +} + +pub(crate) fn listing_needs_refresh(online: bool, stale: bool) -> bool { + online && stale +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SharedListingPlan { + Resident, + Persisted, + Refresh, +} + +pub(crate) fn shared_listing_plan(resident: bool, online: bool, stale: bool) -> SharedListingPlan { + if listing_needs_refresh(online, stale) { + SharedListingPlan::Refresh + } else if resident { + SharedListingPlan::Resident + } else { + SharedListingPlan::Persisted + } +} + +pub(crate) fn refresh_generation_is_current(started: u64, current: u64) -> bool { + started == current +} + +#[derive(Default)] +pub(crate) struct SharedRefreshDeadlines { + deadlines: HashMap, +} + +impl SharedRefreshDeadlines { + pub(crate) fn is_fresh(&mut self, key: &str, now: Instant) -> bool { + self.deadlines.retain(|_, deadline| *deadline > now); + self.deadlines + .get(key) + .is_some_and(|deadline| *deadline > now) + } + + pub(crate) fn mark(&mut self, key: &str, now: Instant, ttl: Duration) { + self.deadlines.insert(key.to_string(), now + ttl); + } + + pub(crate) fn clear(&mut self) { + self.deadlines.clear(); + } +} + +fn component_with_suffix(name: &str, suffix: &str) -> String { + let keep = MAX_COMPONENT_BYTES.saturating_sub(suffix.len()); + let mut boundary = name.len().min(keep); + while !name.is_char_boundary(boundary) { + boundary -= 1; + } + let mut out = String::with_capacity(boundary + suffix.len()); + out.push_str(&name[..boundary]); + out.push_str(suffix); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn folder(volume: &str, link: &str, name: &str) -> Node { + let mut node = virtual_node( + NodeUid::new(VolumeId::from("virtual"), LinkId::from("parent")), + name.to_string(), + 1, + ); + node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link)); + node + } + + #[test] + fn virtual_uid_and_node_round_trip() { + let parent = NodeUid::new(VolumeId::from("own"), LinkId::from("root")); + let node = virtual_node(parent.clone(), SHARED_WITH_ME_BASE.into(), 42); + assert_eq!(node.uid.to_string(), "virtual~sharedwithme"); + assert!(is_virtual_uid(&node.uid)); + assert_eq!(node.parent_uid.as_ref(), Some(&parent)); + assert!(node.is_folder()); + let json = serde_json::to_string(&node).unwrap(); + let restored: Node = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.uid, node.uid); + assert_eq!(restored.name, SHARED_WITH_ME_BASE); + } + + #[test] + fn root_collision_ladder_and_pinned_collision_policy() { + let names = HashSet::from([ + SHARED_WITH_ME_BASE.to_string(), + format!("{SHARED_WITH_ME_BASE} (Proton)"), + format!("{SHARED_WITH_ME_BASE} (Proton 2)"), + ]); + assert_eq!( + virtual_root_name(&names, None), + (format!("{SHARED_WITH_ME_BASE} (Proton 3)"), true) + ); + + let (name, visible) = virtual_root_name(&names, Some(SHARED_WITH_ME_BASE)); + assert_eq!(name, SHARED_WITH_ME_BASE); + assert!(!visible, "a real later collision suppresses the dentry"); + + let cleared = HashSet::new(); + assert_eq!( + virtual_root_name(&cleared, Some(SHARED_WITH_ME_BASE)), + (SHARED_WITH_ME_BASE.to_string(), true), + "the pinned name returns when the collision clears" + ); + } + + #[test] + fn duplicate_names_are_stable_under_reordering_and_reserve_suffixes() { + let input = vec![ + folder("b", "2", "Photos"), + folder("c", "3", "Photos (2)"), + folder("a", "1", "Photos"), + ]; + let mut forward = input.clone(); + let mut reverse = input; + reverse.reverse(); + disambiguate_shared_names(&mut forward); + disambiguate_shared_names(&mut reverse); + let summarize = |nodes: &[Node]| { + nodes + .iter() + .map(|node| (node.uid.to_string(), node.name.clone())) + .collect::>() + }; + assert_eq!(summarize(&forward), summarize(&reverse)); + assert_eq!( + forward + .iter() + .map(|node| node.name.as_str()) + .collect::>(), + ["Photos", "Photos (3)", "Photos (2)"] + ); + } + + #[test] + fn disambiguation_respects_utf8_component_limit() { + let long = "é".repeat(200); + let mut nodes = vec![folder("a", "1", &long), folder("b", "2", &long)]; + disambiguate_shared_names(&mut nodes); + assert!(nodes.iter().all(|node| node.name.len() <= 255)); + assert!( + nodes + .iter() + .all(|node| node.name.is_char_boundary(node.name.len())) + ); + assert_ne!(nodes[0].name, nodes[1].name); + assert!(nodes[1].name.ends_with(" (2)")); + } + + #[test] + fn ttl_refresh_only_happens_online() { + assert!(!listing_needs_refresh(false, true)); + assert!(!listing_needs_refresh(true, false)); + assert!(listing_needs_refresh(true, true)); + } + + #[test] + fn offline_snapshot_suppresses_the_api_and_memory_deadline_suppresses_retries() { + assert_eq!( + shared_listing_plan(false, false, true), + SharedListingPlan::Persisted + ); + assert_eq!( + shared_listing_plan(true, true, false), + SharedListingPlan::Resident + ); + + let now = Instant::now(); + let mut deadlines = SharedRefreshDeadlines::default(); + deadlines.mark("shared", now, Duration::from_secs(60)); + assert!(deadlines.is_fresh("shared", now + Duration::from_secs(30))); + deadlines.clear(); + assert!(!deadlines.is_fresh("shared", now + Duration::from_secs(30))); + } + + #[test] + fn generation_change_rejects_an_inflight_response() { + assert!(refresh_generation_is_current(7, 7)); + assert!(!refresh_generation_is_current(7, 8)); + } + + #[test] + fn only_the_primary_inode_space_installs_the_synthetic_root() { + let root = NodeUid::new(VolumeId::from("own"), LinkId::from("root")); + assert!(is_primary_root_listing(true, &root, &root)); + assert!(!is_primary_root_listing(false, &root, &root)); + assert!(!is_primary_root_listing( + true, + &NodeUid::new(VolumeId::from("own"), LinkId::from("device")), + &root + )); + } + + #[test] + fn ownership_predicate_excludes_foreign_share_volumes() { + let own = VolumeId::from("own"); + let own_uid = NodeUid::new(own.clone(), LinkId::from("file")); + let foreign = NodeUid::new(VolumeId::from("foreign"), LinkId::from("share")); + assert!(is_own_or_virtual_uid(&own_uid, &own)); + assert!(is_own_or_virtual_uid(&shared_with_me_uid(), &own)); + assert!(!is_own_or_virtual_uid(&foreign, &own)); + } +} diff --git a/docs/BUGS.md b/docs/BUGS.md index b24675a..b00fa71 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -1432,8 +1432,8 @@ added later, but it must retain the row and blob. ## B34 — Writable POSIX Exposure of Read-Only Shared Folders (MED-07) -**Status:** Fixed in code by P2 (2026-07-29); live credentialed shared-tree and second-account -acceptance pending P3/P7 +**Status:** Fixed in code by P2/P3 (2026-07-29); P3 now exposes the shared tree, while credentialed +second-account and live FUSE acceptance remain pending P7 **Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07) **Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs` @@ -1444,9 +1444,9 @@ entries, exposes read-only POSIX modes, and gates mutations at handler, queue, a Downgrades fail closed and are not acknowledged past failed durable state updates. **Verification:** The local workspace passes formatting, locked checks, clippy with warnings denied, -and all 324 workspace tests, including access inheritance, mutation gating, queued-operation -authorization, and downgrade failure paths. Live credentialed shared-tree and second-account -acceptance cannot run until P3 exposes the tree and remains required in P7. +and all 347 workspace tests, including P3 virtual-tree materialization and access inheritance plus +mutation gating, queued-operation authorization, and downgrade failure paths. Credentialed +second-account and live FUSE acceptance have not been run and remain required in P7. ---