diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 31ee891..254aa97 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -240,9 +240,20 @@ pub enum Request { role: String, message: Option, }, + /// The by-uid twin of [`Request::ShareNode`], for nodes proven to belong to + /// an on-demand or mirror device location. This is a distinct variant so an + /// older daemon rejects it instead of ignoring `uid` and acting on `path`. + ShareNodeByUid { + uid: String, + emails: Vec, + role: String, + message: Option, + }, /// List the members, pending invitations and public link of the node at /// mountpoint-relative `path`. Replies with [`Response::Share`]. ListShare { path: String }, + /// The by-uid twin of [`Request::ListShare`]. + ListShareByUid { uid: String }, /// Change the role of a share entry (member or pending invitation) on the node /// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]). /// Replies with [`Response::Ok`]. @@ -252,6 +263,13 @@ pub enum Request { kind: ShareEntryKind, role: String, }, + /// The by-uid twin of [`Request::UpdateShareRole`]. + UpdateShareRoleByUid { + uid: String, + id: String, + kind: ShareEntryKind, + role: String, + }, /// Remove a share entry (member, pending Proton invite, or external invite) /// from the node at `path`. Replies with [`Response::Ok`]. RemoveShareEntry { @@ -259,6 +277,12 @@ pub enum Request { id: String, kind: ShareEntryKind, }, + /// The by-uid twin of [`Request::RemoveShareEntry`]. + RemoveShareEntryByUid { + uid: String, + id: String, + kind: ShareEntryKind, + }, /// Create a public link on the node at `path`. `role` is "viewer" or "editor"; /// `password` optionally adds a custom password; `expires` is an optional Unix /// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL). @@ -268,9 +292,18 @@ pub enum Request { password: Option, expires: Option, }, + /// The by-uid twin of [`Request::CreatePublicLink`]. + CreatePublicLinkByUid { + uid: String, + role: String, + password: Option, + expires: Option, + }, /// Remove the public link `id` from the node at `path`. Replies with /// [`Response::Ok`]. RemovePublicLink { path: String, id: String }, + /// The by-uid twin of [`Request::RemovePublicLink`]. + RemovePublicLinkByUid { uid: String, id: String }, // ---- shared by me ----------------------------------------------------- /// List the nodes I have shared with others — collaborative shares that still @@ -1580,28 +1613,58 @@ mod tests { role: "editor".into(), message: Some("hi".into()), }, + Request::ShareNodeByUid { + uid: "vol~link".into(), + emails: vec!["x@proton.me".into(), "y@example.com".into()], + role: "editor".into(), + message: Some("hi".into()), + }, Request::ListShare { path: "a/b".into() }, + Request::ListShareByUid { + uid: "vol~link".into(), + }, Request::UpdateShareRole { path: "a/b".into(), id: "mid-1".into(), kind: ShareEntryKind::Member, role: "admin".into(), }, + Request::UpdateShareRoleByUid { + uid: "vol~link".into(), + id: "mid-1".into(), + kind: ShareEntryKind::Member, + role: "admin".into(), + }, Request::RemoveShareEntry { path: "a/b".into(), id: "iid-1".into(), kind: ShareEntryKind::ExternalInvite, }, + Request::RemoveShareEntryByUid { + uid: "vol~link".into(), + id: "iid-1".into(), + kind: ShareEntryKind::ExternalInvite, + }, Request::CreatePublicLink { path: "a/b".into(), role: "viewer".into(), password: Some("pw".into()), expires: Some(1_700_000_000), }, + Request::CreatePublicLinkByUid { + uid: "vol~link".into(), + role: "viewer".into(), + password: Some("pw".into()), + expires: Some(1_700_000_000), + }, Request::RemovePublicLink { path: "a/b".into(), id: "url-1".into(), }, + Request::RemovePublicLinkByUid { + uid: "vol~link".into(), + id: "url-1".into(), + }, Request::ListSharedByMe, Request::ListActivity { limit: 100 }, Request::ListSharedWithMe, @@ -1635,6 +1698,222 @@ mod tests { } } + #[test] + fn path_share_requests_keep_their_existing_wire_shape() { + let cases = [ + ( + Request::ShareNode { + path: "a/b".into(), + emails: vec!["x@example.com".into()], + role: "viewer".into(), + message: None, + }, + r#"{"ShareNode":{"path":"a/b","emails":["x@example.com"],"role":"viewer","message":null}}"#, + ), + ( + Request::ListShare { path: "a/b".into() }, + r#"{"ListShare":{"path":"a/b"}}"#, + ), + ( + Request::UpdateShareRole { + path: "a/b".into(), + id: "member".into(), + kind: ShareEntryKind::Member, + role: "editor".into(), + }, + r#"{"UpdateShareRole":{"path":"a/b","id":"member","kind":"Member","role":"editor"}}"#, + ), + ( + Request::RemoveShareEntry { + path: "a/b".into(), + id: "invite".into(), + kind: ShareEntryKind::ProtonInvite, + }, + r#"{"RemoveShareEntry":{"path":"a/b","id":"invite","kind":"ProtonInvite"}}"#, + ), + ( + Request::CreatePublicLink { + path: "a/b".into(), + role: "viewer".into(), + password: None, + expires: None, + }, + r#"{"CreatePublicLink":{"path":"a/b","role":"viewer","password":null,"expires":null}}"#, + ), + ( + Request::RemovePublicLink { + path: "a/b".into(), + id: "link".into(), + }, + r#"{"RemovePublicLink":{"path":"a/b","id":"link"}}"#, + ), + ]; + + for (request, expected) in cases { + assert_eq!(serde_json::to_string(&request).unwrap(), expected); + assert!( + !expected.contains("\"uid\""), + "path variants must never acquire an optional uid" + ); + } + } + + #[test] + fn by_uid_share_requests_are_distinct_wire_variants() { + let cases = [ + ( + Request::ShareNodeByUid { + uid: "vol~link".into(), + emails: vec!["x@example.com".into()], + role: "viewer".into(), + message: None, + }, + "ShareNodeByUid", + ), + ( + Request::ListShareByUid { + uid: "vol~link".into(), + }, + "ListShareByUid", + ), + ( + Request::UpdateShareRoleByUid { + uid: "vol~link".into(), + id: "member".into(), + kind: ShareEntryKind::Member, + role: "editor".into(), + }, + "UpdateShareRoleByUid", + ), + ( + Request::RemoveShareEntryByUid { + uid: "vol~link".into(), + id: "invite".into(), + kind: ShareEntryKind::ProtonInvite, + }, + "RemoveShareEntryByUid", + ), + ( + Request::CreatePublicLinkByUid { + uid: "vol~link".into(), + role: "viewer".into(), + password: None, + expires: None, + }, + "CreatePublicLinkByUid", + ), + ( + Request::RemovePublicLinkByUid { + uid: "vol~link".into(), + id: "link".into(), + }, + "RemovePublicLinkByUid", + ), + ]; + + for (request, variant) in cases { + let wire = serde_json::to_string(&request).unwrap(); + let object = serde_json::from_str::(&wire) + .unwrap() + .as_object() + .cloned() + .unwrap(); + assert_eq!(object.len(), 1); + assert!(object.contains_key(variant)); + let decoded = serde_json::from_str::(&wire).unwrap(); + let decoded_uid = match &decoded { + Request::ShareNodeByUid { uid, .. } + | Request::ListShareByUid { uid } + | Request::UpdateShareRoleByUid { uid, .. } + | Request::RemoveShareEntryByUid { uid, .. } + | Request::CreatePublicLinkByUid { uid, .. } + | Request::RemovePublicLinkByUid { uid, .. } => uid, + other => panic!("decoded into the wrong dispatch variant: {other:?}"), + }; + assert_eq!(decoded_uid, "vol~link"); + assert_eq!(serde_json::to_string(&decoded).unwrap(), wire); + } + } + + #[test] + fn legacy_daemons_reject_new_by_uid_variants() { + #[allow(dead_code)] + #[derive(serde::Deserialize)] + enum LegacyShareRequest { + ShareNode { + path: String, + emails: Vec, + role: String, + message: Option, + }, + ListShare { + path: String, + }, + UpdateShareRole { + path: String, + id: String, + kind: ShareEntryKind, + role: String, + }, + RemoveShareEntry { + path: String, + id: String, + kind: ShareEntryKind, + }, + CreatePublicLink { + path: String, + role: String, + password: Option, + expires: Option, + }, + RemovePublicLink { + path: String, + id: String, + }, + } + + let requests = [ + Request::ShareNodeByUid { + uid: "vol~link".into(), + emails: Vec::new(), + role: "viewer".into(), + message: None, + }, + Request::ListShareByUid { + uid: "vol~link".into(), + }, + Request::UpdateShareRoleByUid { + uid: "vol~link".into(), + id: "member".into(), + kind: ShareEntryKind::Member, + role: "viewer".into(), + }, + Request::RemoveShareEntryByUid { + uid: "vol~link".into(), + id: "member".into(), + kind: ShareEntryKind::Member, + }, + Request::CreatePublicLinkByUid { + uid: "vol~link".into(), + role: "viewer".into(), + password: None, + expires: None, + }, + Request::RemovePublicLinkByUid { + uid: "vol~link".into(), + id: "link".into(), + }, + ]; + + for request in requests { + let wire = serde_json::to_string(&request).unwrap(); + assert!( + serde_json::from_str::(&wire).is_err(), + "an old daemon must reject {wire}, never reinterpret it as a path request" + ); + } + } + /// A daemon built before `kind` existed sends `Error` without the field. /// It must still parse, and land on the class that promises the least. #[test] diff --git a/crates/pdfs-core/src/db/nodes.rs b/crates/pdfs-core/src/db/nodes.rs index 0ae75bb..b0c7377 100644 --- a/crates/pdfs-core/src/db/nodes.rs +++ b/crates/pdfs-core/src/db/nodes.rs @@ -177,14 +177,24 @@ impl Db { .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 { + /// Atomically publish the synthetic root's pinned name, node/FTS state, and + /// Viewer authority. + /// + /// The pin is inserted last so any failure there rolls back the node and + /// its descendant FTS updates as well as the access row. Returning `false` + /// guarantees no write occurred, which keeps cached root lookups O(1). + pub fn publish_virtual_root(&self, pinned_name_key: &str, 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 pinned_name: Option = tx + .query_row( + "SELECT value FROM sync_state WHERE key = ?1", + params![pinned_name_key], + |row| row.get(0), + ) + .optional()?; let stored: Option<(Option, String, i64, i64)> = tx .query_row( "SELECT parent_uid, name, is_dir, trashed FROM nodes WHERE uid = ?1", @@ -204,17 +214,19 @@ impl Db { && name == node.name && is_dir == 1 && trashed == node.trashed as i64 - }) && access.as_deref() == Some(Access::Viewer.as_db_str()); + }) && access.as_deref() == Some(Access::Viewer.as_db_str()) + && pinned_name.is_some(); 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_share_access_tx(&tx, &uid, Access::Viewer)?; upsert_node_tx(&tx, node)?; + tx.execute( + "INSERT INTO sync_state (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO NOTHING", + params![pinned_name_key, node.name], + )?; tx.commit()?; Ok(true) } diff --git a/crates/pdfs-core/src/db/sync.rs b/crates/pdfs-core/src/db/sync.rs index d562765..b813fe5 100644 --- a/crates/pdfs-core/src/db/sync.rs +++ b/crates/pdfs-core/src/db/sync.rs @@ -174,6 +174,28 @@ impl Db { Ok(map) } + /// Whether `uid` is represented by a mirror location. + /// + /// An on-demand location proves residency through its live inode state. + /// Mirror locations have no FUSE state, so their folder root comes from + /// `sync_folder` and descendants come from the last successful + /// `sync_entry` baseline. + pub fn mirror_contains_uid(&self, uid: &str) -> Result { + let conn = self.conn.lock(); + conn.query_row( + "SELECT EXISTS( + SELECT 1 + FROM sync_folder AS folder + LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id + WHERE folder.mode = 'mirror' + AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1) + )", + params![uid], + |row| row.get(0), + ) + .map_err(Into::into) + } + /// Insert or replace one baseline row. pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> { let conn = self.conn.lock(); diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 679c122..bafea25 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -769,12 +769,18 @@ fn hidden_virtual_root_removes_descendants_from_fts_and_restores_them() { "Findable descendant", ); db.upsert_node(&own_root).unwrap(); - assert!(db.ensure_virtual_root(&virtual_root).unwrap()); + assert!( + db.publish_virtual_root("shared_with_me_name", &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.publish_virtual_root("shared_with_me_name", &virtual_root) + .unwrap() + ); assert!(db.search("Findable", 10).unwrap().is_empty()); assert!( !db.node_by_uid(&child.uid.to_string()) @@ -785,10 +791,16 @@ fn hidden_virtual_root_removes_descendants_from_fts_and_restores_them() { ); virtual_root.trashed = false; - assert!(db.ensure_virtual_root(&virtual_root).unwrap()); + assert!( + db.publish_virtual_root("shared_with_me_name", &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()); + assert!( + !db.publish_virtual_root("shared_with_me_name", &virtual_root) + .unwrap() + ); let after = db.with_conn(|conn| Ok(conn.total_changes())).unwrap(); assert_eq!( before, after, @@ -1941,6 +1953,48 @@ fn pending_mode_is_queued_until_the_mode_is_reached() { assert_eq!(folder.pending_mode, None); } +#[test] +fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() { + let db = Db::open_in_memory().unwrap(); + let mirror = db + .sync_folder_add("/home/me/Mirror", "vol~mirror-root", "share") + .unwrap(); + db.sync_entry_upsert( + mirror, + &StoredSyncEntry { + rel_path: "nested/file.txt".into(), + remote_uid: Some("vol~mirror-file".into()), + local_mtime: 1, + local_size: 2, + remote_rev: Some("3".into()), + remote_hash: Some("2".into()), + }, + ) + .unwrap(); + let ondemand = db + .sync_folder_add("/home/me/OnDemand", "vol~ondemand-root", "share") + .unwrap(); + db.sync_folder_set_mode(ondemand, "ondemand").unwrap(); + db.sync_entry_upsert( + ondemand, + &StoredSyncEntry { + rel_path: "stale.txt".into(), + remote_uid: Some("vol~ondemand-stale".into()), + local_mtime: 1, + local_size: 2, + remote_rev: Some("3".into()), + remote_hash: Some("2".into()), + }, + ) + .unwrap(); + + assert!(db.mirror_contains_uid("vol~mirror-root").unwrap()); + assert!(db.mirror_contains_uid("vol~mirror-file").unwrap()); + assert!(!db.mirror_contains_uid("vol~ondemand-root").unwrap()); + assert!(!db.mirror_contains_uid("vol~ondemand-stale").unwrap()); + assert!(!db.mirror_contains_uid("vol~missing").unwrap()); +} + #[test] fn node_path_resolves_relative_to_a_sync_root_uid() { let db = Db::open_in_memory().unwrap(); diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index d16ba59..212dd31 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -692,6 +692,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::ShareNodeByUid { + uid, + emails, + role, + message, + }) => match core.share_node_by_uid(&uid, &emails, &role, message.as_deref()) { + Ok((proton, external)) => { + core.log_activity( + ActivityKind::Share, + &uid, + format!("{} recipient(s) as {role}", proton + external), + true, + ); + CtlResponse::Ok { + message: format!("invited {proton} Proton and {external} external user(s)"), + } + } + Err(e) => { + core.log_activity(ActivityKind::Share, &uid, &e, false); + CtlResponse::error(e) + } + }, Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.list_share(&rel) { Ok((entries, link)) => CtlResponse::Share { entries, link }, @@ -699,6 +721,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::ListShareByUid { uid }) => match core.list_share_by_uid(&uid) { + Ok((entries, link)) => CtlResponse::Share { entries, link }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::UpdateShareRole { path, id, @@ -713,6 +739,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::UpdateShareRoleByUid { + uid, + id, + kind, + role, + }) => match core.update_share_role_by_uid(&uid, &id, kind, &role) { + Ok(()) => CtlResponse::Ok { + message: format!("role updated to {role}"), + }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => { match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.remove_share_entry(&rel, &id, kind) { @@ -727,6 +764,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Err(e) => CtlResponse::error(e), } } + Ok(CtlRequest::RemoveShareEntryByUid { uid, id, kind }) => { + match core.remove_share_entry_by_uid(&uid, &id, kind) { + Ok(()) => { + core.log_activity(ActivityKind::Unshare, &uid, "access removed", true); + CtlResponse::Ok { + message: "removed".to_string(), + } + } + Err(e) => CtlResponse::error(e), + } + } Ok(CtlRequest::CreatePublicLink { path, role, @@ -745,6 +793,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::CreatePublicLinkByUid { + uid, + role, + password, + expires, + }) => match core.create_public_link_by_uid(&uid, &role, password.as_deref(), expires) { + Ok(link) => { + core.log_activity(ActivityKind::PublicLink, &uid, "link created", true); + CtlResponse::PublicLink { link } + } + Err(e) => { + core.log_activity(ActivityKind::PublicLink, &uid, &e, false); + CtlResponse::error(e) + } + }, Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.remove_public_link(&rel, &id) { Ok(()) => { @@ -757,6 +820,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::RemovePublicLinkByUid { uid, id }) => { + match core.remove_public_link_by_uid(&uid, &id) { + Ok(()) => { + core.log_activity(ActivityKind::Unshare, &uid, "link removed", true); + CtlResponse::Ok { + message: "public link removed".to_string(), + } + } + Err(e) => CtlResponse::error(e), + } + } Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() { Ok(entries) => CtlResponse::Entries { entries }, Err(e) => CtlResponse::error(e), diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 7e8e820..2a2f18f 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -221,6 +221,66 @@ const MAX_THUMBNAIL_MISSES: usize = 8192; /// delay the waiters this chunking exists to release (bugs.md B14). const SIZE_UPGRADE_CHUNK: usize = 150; +#[derive(Clone, Debug, Eq, PartialEq)] +struct RootListingSnapshot { + children: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RootListingChild { + ino: u64, + entry: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RootListingEntry { + uid: NodeUid, + name: String, + parent: u64, + trashed: bool, + unlinked: bool, +} + +impl RootListingSnapshot { + fn capture(state: &State, parent_ino: u64) -> Option { + let children = state.children.get(&parent_ino)?; + Some(Self { + children: children + .iter() + .map(|ino| RootListingChild { + ino: *ino, + entry: state.entries.get(ino).and_then(|entry| { + (!is_virtual_uid(&entry.uid)).then(|| RootListingEntry { + uid: entry.uid.clone(), + name: entry.node.name.clone(), + parent: entry.parent, + trashed: entry.node.trashed, + unlinked: entry.unlinked, + }) + }), + }) + .collect(), + }) + } + + fn real_names(&self) -> HashSet { + self.children + .iter() + .filter_map(|child| child.entry.as_ref()) + .map(|entry| entry.name.clone()) + .collect() + } + + fn is_current(&self, state: &State, parent_ino: u64) -> bool { + Self::capture(state, parent_ino).as_ref() == Some(self) + } +} + +struct VirtualRootPlan { + node: Node, + visible: bool, +} + #[derive(Clone)] struct Core { client: ProtonDriveClient, @@ -508,6 +568,18 @@ impl StateRegistry { .filter_map(|m| Some((m.state.upgrade()?, m.notifier.clone()))) .collect() } + + /// Whether any live inode space owns and currently exposes `uid`. + /// + /// Retained open inodes and revoked incoming shares remain interned after + /// their dentries disappear, so residency alone is not authority. The + /// state's mounted root also scopes the lookup to its own volume, excluding + /// foreign shared-with-me residents from uid-addressed sharing. + fn owns_visible_uid(&self, uid: &NodeUid) -> bool { + self.live() + .into_iter() + .any(|(state, _)| state.lock().owns_visible_uid(uid)) + } } /// Why [`Core::apply_sync_folder_mode`] did not switch a folder. The two cases are @@ -1130,32 +1202,26 @@ impl Core { apply_pending_sizes(nodes, &sizes); } - fn prepare_virtual_root(&self, real_names: &HashSet) -> Result<(Node, bool), Errno> { + /// Read the inputs for virtual-root publication without changing SQLite. + /// + /// The caller validates the listing snapshot after these reads. Keeping all + /// writes out of this phase prevents an invalidated/repopulated root listing + /// from changing the synthetic node's persisted visibility or FTS ancestry. + fn prepare_virtual_root(&self, real_names: &HashSet) -> Result { 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()) - }); + let previous_node = self.db.node_by_uid(&uid.to_string()).map_err(|error| { + error!(%error, "reading synthetic shared root failed"); + Errno::EIO + })?; + let mut node = previous_node.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; @@ -1164,11 +1230,7 @@ impl Core { // 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)) + Ok(VirtualRootPlan { node, visible }) } /// Reconcile the synthetic dentry against an already-known primary-root @@ -1177,33 +1239,23 @@ impl Core { fn reconcile_virtual_root_dentry( &self, parent_ino: u64, - real_names: HashSet, + snapshot: RootListingSnapshot, ) -> Result<(), Errno> { - let (node, visible) = self.prepare_virtual_root(&real_names)?; - let uid = node.uid.clone(); + let _publication = self.shared_publication.lock(); + let plan = self.prepare_virtual_root(&snapshot.real_names())?; 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); - } + // State methods already establish the state -> DB lock order. No path + // holds the DB connection while acquiring state, including shared event + // publication, so keeping state locked across this one transaction + // closes the crash window without introducing an inverse order. + publish_virtual_root_in_listing(&self.db, &mut st, parent_ino, &snapshot, plan)?; 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 resident_root_listing_snapshot(&self, ino: u64) -> Result { + RootListingSnapshot::capture(&self.state.lock(), ino).ok_or(Errno::EAGAIN) } fn shared_folder_freshness_key(uid: &NodeUid) -> String { @@ -1383,7 +1435,7 @@ impl Core { }); if cached && !refresh_foreign { if primary_root { - self.reconcile_virtual_root_dentry(ino, self.resident_real_root_names(ino))?; + self.reconcile_virtual_root_dentry(ino, self.resident_root_listing_snapshot(ino)?)?; } return Ok(()); } @@ -1403,8 +1455,6 @@ impl Core { // 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) { @@ -1428,10 +1478,12 @@ impl Core { child_inos.push(st.intern_from_db(ino, node)); } st.children.insert(ino, child_inos); + let root_snapshot = + primary_root.then(|| RootListingSnapshot::capture(&st, ino).unwrap()); drop(st); self.flush_access_changes(); - if primary_root { - self.reconcile_virtual_root_dentry(ino, real_names)?; + if let Some(snapshot) = root_snapshot { + self.reconcile_virtual_root_dentry(ino, snapshot)?; } // Rows persisted from a cheap enumeration whose upgrade never // ran (a restart in between, say) still owe their real sizes. @@ -1470,7 +1522,6 @@ 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() @@ -1557,6 +1608,7 @@ impl Core { let inos = st.intern_batch(ino, filtered_nodes); child_inos.extend(inos); st.children.insert(ino, child_inos); + let root_snapshot = primary_root.then(|| RootListingSnapshot::capture(&st, ino).unwrap()); // Record the listing as complete so a later restart (or a trimmed hot // cache) can rebuild it from the DB without the API. if let Err(e) = self.db.set_listed(&folder_uid, true) { @@ -1564,8 +1616,8 @@ impl Core { } drop(st); self.flush_access_changes(); - if primary_root { - self.reconcile_virtual_root_dentry(ino, real_names)?; + if let Some(snapshot) = root_snapshot { + self.reconcile_virtual_root_dentry(ino, snapshot)?; } self.spawn_size_upgrade(ino, needs_size); Ok(()) @@ -1838,6 +1890,21 @@ impl Core { .map_err(|e| self.errno_error(e, &format!("could not resolve {}", rel.display()))) } + /// Resolve a wire-format uid only when one of this daemon's locations + /// proves that it owns the node. + /// + /// The primary and on-demand mounts prove residency through + /// [`StateRegistry`]. Mirror locations have no inode space, so their root + /// and last-synced descendants are resolved through `sync_folder` and + /// `sync_entry` instead. + pub(crate) fn resolve_anywhere(&self, uid: &str) -> CoreResult { + resolve_anywhere_with( + uid, + |uid| self.states.owns_visible_uid(uid), + |uid| self.db.mirror_contains_uid(&uid.to_string()), + ) + } + fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult { let state = self.state.lock(); let entry = state @@ -3653,6 +3720,80 @@ impl Core { } } +fn publish_virtual_root_in_listing( + db: &Db, + state: &mut State, + parent_ino: u64, + snapshot: &RootListingSnapshot, + plan: VirtualRootPlan, +) -> Result<(), Errno> { + if !snapshot.is_current(state, parent_ino) { + return Err(Errno::EAGAIN); + } + + db.publish_virtual_root(SHARED_WITH_ME_NAME, &plan.node) + .map_err(|error| { + error!(%error, "atomically publishing synthetic shared root failed"); + Errno::EIO + })?; + + let uid = plan.node.uid.clone(); + state.share_access.insert(uid, Access::Viewer); + let virtual_ino = state.intern_mem(parent_ino, plan.node); + let published = + reconcile_virtual_root_in_listing(state, parent_ino, snapshot, virtual_ino, plan.visible); + debug_assert!( + published, + "snapshot cannot change while the state lock is held" + ); + Ok(()) +} + +/// Update the synthetic dentry only while the parent listing is still known. +/// +/// Event invalidation can remove the listing after its real names were captured. +/// Recreating it here would publish a synthetic-only partial snapshot as complete. +fn reconcile_virtual_root_in_listing( + state: &mut State, + parent_ino: u64, + snapshot: &RootListingSnapshot, + virtual_ino: u64, + visible: bool, +) -> bool { + if !snapshot.is_current(state, parent_ino) { + return false; + } + let Some(children) = state.children.get_mut(&parent_ino) else { + return false; + }; + children.retain(|ino| *ino != virtual_ino); + if visible { + children.push(virtual_ino); + } + true +} + +fn resolve_anywhere_with( + raw_uid: &str, + resident: impl FnOnce(&NodeUid) -> bool, + mirrored: impl FnOnce(&NodeUid) -> pdfs_core::Result, +) -> CoreResult { + let uid = + parse_uid(raw_uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {raw_uid}")))?; + if is_local_uid(&uid) || is_virtual_uid(&uid) { + return Err(CoreError::invalid(format!("reserved uid: {raw_uid}"))); + } + if resident(&uid) { + return Ok(uid); + } + if mirrored(&uid)? { + return Ok(uid); + } + Err(CoreError::not_found(format!( + "node is not present in any location: {raw_uid}" + ))) +} + /// Map a [`MemberRole`] to its wire string. fn role_to_str(role: MemberRole) -> &'static str { match role { @@ -3698,6 +3839,9 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo { /// receive uids as strings over the control socket and pass them back verbatim. fn parse_uid(s: &str) -> Option { let (vol, link) = s.split_once('~')?; + if vol.is_empty() || link.is_empty() || link.contains('~') { + return None; + } Some(NodeUid::new(VolumeId::from(vol), LinkId::from(link))) } /// Current wall-clock time as epoch seconds (0 if the clock is before the epoch). @@ -3721,8 +3865,7 @@ fn now_millis() -> i64 { /// how one is persisted in `pending_op.uid` and a [`StagedWrite`] sidecar. The /// SDK has no `FromStr`, and neither id contains a `~`. fn parse_node_uid(s: &str) -> Option { - let (vol, link) = s.split_once('~')?; - Some(NodeUid::new(VolumeId::from(vol), LinkId::from(link))) + parse_uid(s) } /// Distinguishes placeholder uids minted by [`mint_local_uid`] within one run. @@ -4470,13 +4613,15 @@ mod pending_size_tests { #[cfg(test)] mod tests { use super::{ - Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, SELF_CHANGE_TTL_MS, - 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, - shared_with_me_uid, take_self_change, + Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot, + SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan, + 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, publish_virtual_root_in_listing, + reconcile_virtual_root_in_listing, 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, + resolve_anywhere_with, shared_with_me_uid, take_self_change, virtual_node, }; use crate::filesystem::access_allowed; use pdfs_core::cache::{Baseline, StagedWrite}; @@ -4529,6 +4674,469 @@ mod tests { ); } + #[test] + fn state_registry_resolves_uids_from_any_resident_mount() { + let registry = StateRegistry::default(); + let (primary, _primary_dir) = rooted_state("primary-volume", "primary"); + let (mut fork, _fork_dir) = rooted_state("device-volume", "device"); + let primary_uid = primary.entries[&super::ROOT_INO].uid.clone(); + let fork_node = + node_helper_in_volume("device-volume", "fork", Some("device"), "fork", true); + let fork_uid = fork_node.uid.clone(); + let fork_ino = fork.intern(super::ROOT_INO, fork_node); + fork.children.insert(super::ROOT_INO, vec![fork_ino]); + let primary = std::sync::Arc::new(parking_lot::Mutex::new(primary)); + let fork = std::sync::Arc::new(parking_lot::Mutex::new(fork)); + registry.register(&primary, std::sync::Arc::new(std::sync::OnceLock::new())); + registry.register(&fork, std::sync::Arc::new(std::sync::OnceLock::new())); + + assert!(registry.owns_visible_uid(&primary_uid)); + assert!(registry.owns_visible_uid(&fork_uid)); + assert!(!registry.owns_visible_uid(&super::parse_uid("device-volume~missing").unwrap())); + + drop(fork); + assert!( + !registry.owns_visible_uid(&fork_uid), + "an unmounted on-demand state must stop authorizing its uids" + ); + } + + #[test] + fn state_registry_treats_missing_listing_as_unknown_not_absent() { + let registry = StateRegistry::default(); + let (mut state, _dir) = rooted_state("device-volume", "device"); + let child_node = + node_helper_in_volume("device-volume", "child", Some("device"), "child.txt", false); + let child_uid = child_node.uid.clone(); + let child_ino = state.intern(super::ROOT_INO, child_node); + state.children.insert(super::ROOT_INO, vec![child_ino]); + let state = std::sync::Arc::new(parking_lot::Mutex::new(state)); + registry.register(&state, std::sync::Arc::new(std::sync::OnceLock::new())); + + assert!(registry.owns_visible_uid(&child_uid)); + + state.lock().invalidate_listing(super::ROOT_INO); + assert!( + registry.owns_visible_uid(&child_uid), + "an invalidated listing is unknown and must not hide a valid resident child" + ); + + state.lock().children.insert(super::ROOT_INO, Vec::new()); + assert!( + !registry.owns_visible_uid(&child_uid), + "a present listing that omits the child proves it is no longer visible" + ); + } + + #[test] + fn virtual_root_reconcile_does_not_recreate_an_invalidated_listing() { + let registry = StateRegistry::default(); + let (mut state, _dir) = rooted_state("primary-volume", "root"); + let root_uid = state.entries[&super::ROOT_INO].uid.clone(); + let child_node = + node_helper_in_volume("primary-volume", "child", Some("root"), "child.txt", false); + let child_uid = child_node.uid.clone(); + let child_ino = state.intern(super::ROOT_INO, child_node); + state.children.insert(super::ROOT_INO, vec![child_ino]); + + // Simulate ensure_children observing a complete primary-root listing. + let snapshot = RootListingSnapshot::capture(&state, super::ROOT_INO).unwrap(); + let detected_names = snapshot.real_names(); + assert_eq!( + detected_names, + std::collections::HashSet::from(["child.txt".into()]) + ); + + // An event wins the race before virtual-root dentry publication. + state.invalidate_listing(super::ROOT_INO); + let virtual_ino = state.intern_mem( + super::ROOT_INO, + virtual_node(root_uid, "Shared with me".into(), 0), + ); + assert!( + !reconcile_virtual_root_in_listing( + &mut state, + super::ROOT_INO, + &snapshot, + virtual_ino, + true, + ), + "the invalidated snapshot must not publish" + ); + assert!( + !state.children.contains_key(&super::ROOT_INO), + "reconciliation must leave an invalidated listing absent" + ); + + let state = std::sync::Arc::new(parking_lot::Mutex::new(state)); + registry.register(&state, std::sync::Arc::new(std::sync::OnceLock::new())); + assert!( + registry.owns_visible_uid(&child_uid), + "the absent listing remains unknown until refresh, so a valid resident uid is allowed" + ); + } + + #[test] + fn virtual_root_reconcile_rejects_repopulated_changed_snapshot() { + let (mut state, _dir) = rooted_state("primary-volume", "root"); + let root_uid = state.entries[&super::ROOT_INO].uid.clone(); + let child = + node_helper_in_volume("primary-volume", "child", Some("root"), "child.txt", false); + let child_ino = state.intern(super::ROOT_INO, child); + state.children.insert(super::ROOT_INO, vec![child_ino]); + let stale_snapshot = RootListingSnapshot::capture(&state, super::ROOT_INO).unwrap(); + + let mut persisted_virtual = virtual_node(root_uid.clone(), "Shared with me".into(), 0); + persisted_virtual.trashed = true; + state + .db + .publish_virtual_root(super::SHARED_WITH_ME_NAME, &persisted_virtual) + .unwrap(); + let mut indexed_descendant = node_helper_in_volume( + "foreign-volume", + "inside", + None, + "SnapshotRaceFindable", + false, + ); + indexed_descendant.parent_uid = Some(shared_with_me_uid()); + state.db.upsert_node(&indexed_descendant).unwrap(); + assert!( + state + .db + .search("SnapshotRaceFindable", 10) + .unwrap() + .is_empty(), + "the hidden synthetic ancestor must hide descendant search hits" + ); + + state.invalidate_listing(super::ROOT_INO); + state.entries.get_mut(&child_ino).unwrap().node.name = "Shared with me".into(); + state.children.insert(super::ROOT_INO, vec![child_ino]); + let replacement_snapshot = RootListingSnapshot::capture(&state, super::ROOT_INO).unwrap(); + assert_ne!(stale_snapshot, replacement_snapshot); + assert_eq!( + replacement_snapshot.real_names(), + std::collections::HashSet::from(["Shared with me".into()]) + ); + + let mut stale_node = persisted_virtual.clone(); + stale_node.trashed = false; + let stale_plan = VirtualRootPlan { + node: stale_node.clone(), + visible: true, + }; + let db = state.db.clone(); + let error = publish_virtual_root_in_listing( + &db, + &mut state, + super::ROOT_INO, + &stale_snapshot, + stale_plan, + ) + .unwrap_err(); + assert_eq!( + error.code(), + Errno::EAGAIN.code(), + "stale work must fail before persistence or dentry publication" + ); + assert_eq!( + state.children[&super::ROOT_INO], + vec![child_ino], + "the replacement listing must remain untouched" + ); + assert!( + state + .db + .node_by_uid(&shared_with_me_uid().to_string()) + .unwrap() + .unwrap() + .trashed, + "stale work must not change persisted visibility" + ); + assert!( + state + .db + .search("SnapshotRaceFindable", 10) + .unwrap() + .is_empty(), + "stale work must not change FTS ancestry" + ); + + let virtual_ino = state.intern_mem(super::ROOT_INO, stale_node); + state + .children + .get_mut(&super::ROOT_INO) + .unwrap() + .push(virtual_ino); + let current_snapshot = RootListingSnapshot::capture(&state, super::ROOT_INO).unwrap(); + state.entries.get_mut(&virtual_ino).unwrap().node.trashed = false; + assert!( + reconcile_virtual_root_in_listing( + &mut state, + super::ROOT_INO, + ¤t_snapshot, + virtual_ino, + false, + ), + "the synthetic node's own visibility update must not stale the real-child snapshot" + ); + assert_eq!(state.children[&super::ROOT_INO], vec![child_ino]); + } + + #[test] + fn virtual_root_publication_failure_is_atomic_and_leaves_state_unchanged() { + let (mut state, _dir) = rooted_state("primary-volume", "root"); + let root_uid = state.entries[&super::ROOT_INO].uid.clone(); + let child = + node_helper_in_volume("primary-volume", "child", Some("root"), "child.txt", false); + let child_ino = state.intern(super::ROOT_INO, child); + state.children.insert(super::ROOT_INO, vec![child_ino]); + let snapshot = RootListingSnapshot::capture(&state, super::ROOT_INO).unwrap(); + + let mut descendant = node_helper_in_volume( + "foreign-volume", + "inside", + None, + "AtomicRollbackFindable", + false, + ); + descendant.parent_uid = Some(shared_with_me_uid()); + state.db.upsert_node(&descendant).unwrap(); + assert!( + state + .db + .search("AtomicRollbackFindable", 10) + .unwrap() + .is_empty(), + "a descendant with no synthetic ancestor starts outside FTS" + ); + + // The pin is the transaction's final statement. Failing it proves the + // earlier access, node, and descendant FTS work rolls back with it. + state + .db + .with_conn(|conn| { + conn.execute_batch( + "CREATE TRIGGER reject_virtual_root_pin + BEFORE INSERT ON sync_state + WHEN NEW.key = 'shared_with_me_name' + BEGIN + SELECT RAISE(ABORT, 'injected virtual-root pin failure'); + END;", + )?; + Ok(()) + }) + .unwrap(); + + let entries_before = state.entries.len(); + let by_uid_before = state.by_uid.len(); + let next_ino_before = state.next_ino; + let access_before = state.share_access.clone(); + let db = state.db.clone(); + let result = publish_virtual_root_in_listing( + &db, + &mut state, + super::ROOT_INO, + &snapshot, + VirtualRootPlan { + node: virtual_node(root_uid, "Shared with me".into(), 0), + visible: true, + }, + ); + + assert_eq!(result.unwrap_err().code(), Errno::EIO.code()); + assert_eq!( + RootListingSnapshot::capture(&state, super::ROOT_INO), + Some(snapshot), + "the resident listing must not change on DB failure" + ); + assert_eq!(state.entries.len(), entries_before); + assert_eq!(state.by_uid.len(), by_uid_before); + assert_eq!(state.next_ino, next_ino_before); + assert_eq!(state.share_access, access_before); + assert!( + !state.by_uid.contains_key(&shared_with_me_uid()), + "the synthetic inode must not be interned before commit" + ); + + assert_eq!( + state.db.state_str(super::SHARED_WITH_ME_NAME).unwrap(), + None + ); + assert!( + state + .db + .node_by_uid(&shared_with_me_uid().to_string()) + .unwrap() + .is_none() + ); + assert_eq!(state.db.share_access(&shared_with_me_uid()).unwrap(), None); + assert!( + state + .db + .search("AtomicRollbackFindable", 10) + .unwrap() + .is_empty(), + "descendant FTS visibility must roll back with the failed pin" + ); + } + + #[test] + fn state_registry_rejects_open_unlinked_and_revoked_residents() { + let registry = StateRegistry::default(); + let (mut state, _dir) = rooted_state("device-volume", "device"); + + let open_node = + node_helper_in_volume("device-volume", "open", Some("device"), "open.txt", false); + let open_uid = open_node.uid.clone(); + let open_ino = state.intern(super::ROOT_INO, open_node); + state.children.insert(super::ROOT_INO, vec![open_ino]); + state.entries.get_mut(&open_ino).unwrap().open_count = 1; + + let revoked_node = + node_helper_in_volume("device-volume", "revoked", Some("device"), "revoked", true); + let revoked_uid = revoked_node.uid.clone(); + let revoked_ino = state.intern(super::ROOT_INO, revoked_node); + let child_node = node_helper_in_volume( + "device-volume", + "revoked-child", + Some("revoked"), + "child.txt", + false, + ); + let child_uid = child_node.uid.clone(); + let child_ino = state.intern(revoked_ino, child_node); + state + .children + .get_mut(&super::ROOT_INO) + .unwrap() + .push(revoked_ino); + state.children.insert(revoked_ino, vec![child_ino]); + + let state = std::sync::Arc::new(parking_lot::Mutex::new(state)); + registry.register(&state, std::sync::Arc::new(std::sync::OnceLock::new())); + assert!(registry.owns_visible_uid(&open_uid)); + assert!(registry.owns_visible_uid(&child_uid)); + + state.lock().unlink_mem(&open_uid); + assert!( + !registry.owns_visible_uid(&open_uid), + "an open inode retained after unlink is not addressable" + ); + + state.lock().hide_shared_root(&revoked_uid); + assert!(!registry.owns_visible_uid(&revoked_uid)); + assert!( + !registry.owns_visible_uid(&child_uid), + "a retained descendant beneath a revoked root is not reachable" + ); + } + + #[test] + fn state_registry_rejects_foreign_broken_and_cyclic_residents() { + let registry = StateRegistry::default(); + let (mut state, _dir) = rooted_state("own-volume", "root"); + + let foreign_node = + node_helper_in_volume("foreign-volume", "shared", Some("root"), "shared", true); + let foreign_uid = foreign_node.uid.clone(); + let foreign_ino = state.intern(super::ROOT_INO, foreign_node); + + let broken_node = + node_helper_in_volume("own-volume", "broken", Some("root"), "broken", true); + let broken_uid = broken_node.uid.clone(); + let _broken_ino = state.intern(super::ROOT_INO, broken_node); + + let cycle_a_node = node_helper_in_volume("own-volume", "cycle-a", Some("root"), "a", true); + let cycle_a_uid = cycle_a_node.uid.clone(); + let cycle_a = state.intern(super::ROOT_INO, cycle_a_node); + let cycle_b_node = + node_helper_in_volume("own-volume", "cycle-b", Some("cycle-a"), "b", true); + let cycle_b = state.intern(cycle_a, cycle_b_node); + let cycle_b_uid = state.entries[&cycle_b].uid.clone(); + let cycle_a_entry = state.entries.get_mut(&cycle_a).unwrap(); + cycle_a_entry.parent = cycle_b; + cycle_a_entry.node.parent_uid = Some(cycle_b_uid); + + state.children.insert(super::ROOT_INO, vec![foreign_ino]); + state.children.insert(cycle_a, vec![cycle_b]); + state.children.insert(cycle_b, vec![cycle_a]); + + let state = std::sync::Arc::new(parking_lot::Mutex::new(state)); + registry.register(&state, std::sync::Arc::new(std::sync::OnceLock::new())); + + assert!( + !registry.owns_visible_uid(&foreign_uid), + "incoming-share residents are outside the mounted root's volume" + ); + assert!( + !registry.owns_visible_uid(&broken_uid), + "an interned node without a visible parent dentry is not reachable" + ); + assert!( + !registry.owns_visible_uid(&cycle_a_uid), + "a cyclic resident parent chain is not reachable" + ); + } + + #[test] + fn resolve_anywhere_prefers_resident_state_then_falls_back_to_mirror() { + let resident = resolve_anywhere_with( + "vol~resident", + |_| true, + |_| panic!("resident uid must not query mirror bookkeeping"), + ) + .unwrap(); + assert_eq!(resident.to_string(), "vol~resident"); + + let mirror = resolve_anywhere_with("vol~mirror", |_| false, |_| Ok(true)).unwrap(); + assert_eq!(mirror.to_string(), "vol~mirror"); + } + + #[test] + fn resolve_anywhere_classifies_invalid_missing_and_db_errors() { + for raw_uid in ["not-a-uid", "~link", "vol~", "vol~link~extra"] { + let invalid = resolve_anywhere_with( + raw_uid, + |_| panic!("invalid uid must not query live state"), + |_| panic!("invalid uid must not query mirror bookkeeping"), + ) + .unwrap_err(); + assert_eq!( + invalid.kind, + pdfs_core::control::ErrorKind::Invalid, + "{raw_uid}" + ); + } + + for raw_uid in ["local~pending", "virtual~sharedwithme"] { + let reserved = resolve_anywhere_with( + raw_uid, + |_| panic!("reserved uid must not query live state"), + |_| panic!("reserved uid must not query mirror bookkeeping"), + ) + .unwrap_err(); + assert_eq!( + reserved.kind, + pdfs_core::control::ErrorKind::Invalid, + "{raw_uid}" + ); + } + + let missing = resolve_anywhere_with("vol~missing", |_| false, |_| Ok(false)).unwrap_err(); + assert_eq!(missing.kind, pdfs_core::control::ErrorKind::NotFound); + + let db_error = resolve_anywhere_with( + "vol~db", + |_| false, + |_| Err(pdfs_core::Error::Other("lookup failed".into())), + ) + .unwrap_err(); + assert_eq!(db_error.kind, pdfs_core::control::ErrorKind::Internal); + assert!(db_error.message.contains("lookup failed")); + } + /// An event feed carries no revision id, so the only thing that tells a /// remote change apart from the echo of one this daemon just made is this /// bookkeeping. Getting it wrong is expensive in both directions: too eager @@ -5206,6 +5814,29 @@ mod tests { (st, TestDir(dir_path)) } + fn rooted_state(volume: &str, root_link: &str) -> (crate::state::State, TestDir) { + let (mut state, dir) = state_test_helper(); + let root = node_helper_in_volume(volume, root_link, None, "root", true); + let root_ino = state.intern(0, root); + assert_eq!(root_ino, super::ROOT_INO); + state.entries.get_mut(&root_ino).unwrap().parent = root_ino; + (state, dir) + } + + fn node_helper_in_volume( + volume: &str, + id: &str, + parent: Option<&str>, + name: &str, + is_dir: bool, + ) -> Node { + let mut node = node_helper(id, "none", name, is_dir); + node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(id)); + node.parent_uid = + parent.map(|parent| NodeUid::new(VolumeId::from(volume), LinkId::from(parent))); + node + } + #[test] fn queued_trash_of_open_file_survives_last_release_until_atomic_completion() { let (mut state, _dir) = state_test_helper(); diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index 8a20db2..bd5d37c 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -57,11 +57,33 @@ impl Core { message: Option<&str>, ) -> CoreResult<(usize, usize)> { let (_ino, uid) = self.resolve(rel)?; + self.share_node_for_uid(&uid, emails, role, message) + } + + /// [`Core::share_node`] for a node addressed through any daemon location. + pub(crate) fn share_node_by_uid( + &self, + uid: &str, + emails: &[String], + role: &str, + message: Option<&str>, + ) -> CoreResult<(usize, usize)> { + let uid = self.resolve_anywhere(uid)?; + self.share_node_for_uid(&uid, emails, role, message) + } + + fn share_node_for_uid( + &self, + uid: &NodeUid, + emails: &[String], + role: &str, + message: Option<&str>, + ) -> CoreResult<(usize, usize)> { let role = role_from_str(role)?; let invitees: Vec<(String, MemberRole)> = emails.iter().map(|e| (e.clone(), role)).collect(); self.rt - .block_on(self.client.invite_users(&uid, &invitees, message)) + .block_on(self.client.invite_users(uid, &invitees, message)) .map_err(|e| CoreError::from_api(&e, "share")) } @@ -71,11 +93,26 @@ impl Core { rel: &Path, ) -> CoreResult<(Vec, Option)> { let (_ino, uid) = self.resolve(rel)?; + self.list_share_for_uid(&uid) + } + /// [`Core::list_share`] for a node addressed through any daemon location. + pub(crate) fn list_share_by_uid( + &self, + uid: &str, + ) -> CoreResult<(Vec, Option)> { + let uid = self.resolve_anywhere(uid)?; + self.list_share_for_uid(&uid) + } + + fn list_share_for_uid( + &self, + uid: &NodeUid, + ) -> CoreResult<(Vec, Option)> { let mut entries = Vec::new(); for m in self .rt - .block_on(self.client.list_share_members(&uid)) + .block_on(self.client.list_share_members(uid)) .map_err(|e| CoreError::from_api(&e, "list members"))? { entries.push(ShareEntry { @@ -87,7 +124,7 @@ impl Core { } for inv in self .rt - .block_on(self.client.list_share_invitations(&uid)) + .block_on(self.client.list_share_invitations(uid)) .map_err(|e| CoreError::from_api(&e, "list invitations"))? { entries.push(ShareEntry { @@ -99,7 +136,7 @@ impl Core { } for ext in self .rt - .block_on(self.client.list_external_invitations(&uid)) + .block_on(self.client.list_external_invitations(uid)) .map_err(|e| CoreError::from_api(&e, "list external invitations"))? { entries.push(ShareEntry { @@ -112,7 +149,7 @@ impl Core { let link = self .rt - .block_on(self.client.get_public_link(&uid)) + .block_on(self.client.get_public_link(uid)) .map_err(|e| CoreError::from_api(&e, "get public link"))? .map(public_link_info); @@ -129,12 +166,35 @@ impl Core { role: &str, ) -> CoreResult<()> { let (_ino, uid) = self.resolve(rel)?; + self.update_share_role_for_uid(&uid, id, kind, role) + } + + /// [`Core::update_share_role`] for a node addressed through any daemon + /// location. + pub(crate) fn update_share_role_by_uid( + &self, + uid: &str, + id: &str, + kind: ShareEntryKind, + role: &str, + ) -> CoreResult<()> { + let uid = self.resolve_anywhere(uid)?; + self.update_share_role_for_uid(&uid, id, kind, role) + } + + fn update_share_role_for_uid( + &self, + uid: &NodeUid, + id: &str, + kind: ShareEntryKind, + role: &str, + ) -> CoreResult<()> { let role = role_from_str(role)?; match kind { ShareEntryKind::Member => { let member = self .rt - .block_on(self.client.list_share_members(&uid)) + .block_on(self.client.list_share_members(uid)) .map_err(|e| CoreError::from_api(&e, "list members"))? .into_iter() .find(|m| m.membership_id.to_string() == id) @@ -146,7 +206,7 @@ impl Core { ShareEntryKind::ProtonInvite => { let inv = self .rt - .block_on(self.client.list_share_invitations(&uid)) + .block_on(self.client.list_share_invitations(uid)) .map_err(|e| CoreError::from_api(&e, "list invitations"))? .into_iter() .find(|i| i.invitation_id == id) @@ -170,11 +230,32 @@ impl Core { kind: ShareEntryKind, ) -> CoreResult<()> { let (_ino, uid) = self.resolve(rel)?; + self.remove_share_entry_for_uid(&uid, id, kind) + } + + /// [`Core::remove_share_entry`] for a node addressed through any daemon + /// location. + pub(crate) fn remove_share_entry_by_uid( + &self, + uid: &str, + id: &str, + kind: ShareEntryKind, + ) -> CoreResult<()> { + let uid = self.resolve_anywhere(uid)?; + self.remove_share_entry_for_uid(&uid, id, kind) + } + + fn remove_share_entry_for_uid( + &self, + uid: &NodeUid, + id: &str, + kind: ShareEntryKind, + ) -> CoreResult<()> { match kind { ShareEntryKind::Member => { let member = self .rt - .block_on(self.client.list_share_members(&uid)) + .block_on(self.client.list_share_members(uid)) .map_err(|e| CoreError::from_api(&e, "list members"))? .into_iter() .find(|m| m.membership_id.to_string() == id) @@ -186,7 +267,7 @@ impl Core { ShareEntryKind::ProtonInvite => { let inv = self .rt - .block_on(self.client.list_share_invitations(&uid)) + .block_on(self.client.list_share_invitations(uid)) .map_err(|e| CoreError::from_api(&e, "list invitations"))? .into_iter() .find(|i| i.invitation_id == id) @@ -198,7 +279,7 @@ impl Core { ShareEntryKind::ExternalInvite => { let ext = self .rt - .block_on(self.client.list_external_invitations(&uid)) + .block_on(self.client.list_external_invitations(uid)) .map_err(|e| CoreError::from_api(&e, "list external invitations"))? .into_iter() .find(|i| i.invitation_id == id) @@ -219,13 +300,33 @@ impl Core { expires: Option, ) -> CoreResult { let (_ino, uid) = self.resolve(rel)?; + self.create_public_link_for_uid(&uid, role, password, expires) + } + + /// [`Core::create_public_link`] for a node addressed through any daemon + /// location. + pub(crate) fn create_public_link_by_uid( + &self, + uid: &str, + role: &str, + password: Option<&str>, + expires: Option, + ) -> CoreResult { + let uid = self.resolve_anywhere(uid)?; + self.create_public_link_for_uid(&uid, role, password, expires) + } + + fn create_public_link_for_uid( + &self, + uid: &NodeUid, + role: &str, + password: Option<&str>, + expires: Option, + ) -> CoreResult { let role = role_from_str(role)?; let link = self .rt - .block_on( - self.client - .create_public_link(&uid, role, password, expires), - ) + .block_on(self.client.create_public_link(uid, role, password, expires)) .map_err(|e| CoreError::from_api(&e, "create public link"))?; Ok(public_link_info(link)) } @@ -233,9 +334,20 @@ impl Core { /// Remove the public link `id` from the node at `rel`. pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> { let (_ino, uid) = self.resolve(rel)?; + self.remove_public_link_for_uid(&uid, id) + } + + /// [`Core::remove_public_link`] for a node addressed through any daemon + /// location. + pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> { + let uid = self.resolve_anywhere(uid)?; + self.remove_public_link_for_uid(&uid, id) + } + + fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> { let link = self .rt - .block_on(self.client.get_public_link(&uid)) + .block_on(self.client.get_public_link(uid)) .map_err(|e| CoreError::from_api(&e, "get public link"))? .filter(|l| l.public_link_id == id) .ok_or_else(|| CoreError::not_found("public link not found"))?; diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index f644f34..eef6861 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -173,6 +173,61 @@ pub(crate) struct State { } impl State { + /// Whether `uid` names an owned node currently reachable from this mount's + /// root. Resident entries can outlive their dentries for open-handle and + /// access-revocation semantics, so `by_uid` membership alone is not proof + /// that a control-socket operation may address the node. + pub(crate) fn owns_visible_uid(&self, uid: &NodeUid) -> bool { + let Some(root) = self.entries.get(&crate::ROOT_INO) else { + return false; + }; + if uid.volume_id != root.uid.volume_id { + return false; + } + + let Some(&target) = self.by_uid.get(uid) else { + return false; + }; + let mut current = target; + let mut seen = HashSet::new(); + loop { + if !seen.insert(current) { + return false; + } + let Some(entry) = self.entries.get(¤t) else { + return false; + }; + if entry.uid.volume_id != root.uid.volume_id + || entry.unlinked + || entry.node.trashed + || entry.uid != entry.node.uid + || self.by_uid.get(&entry.uid) != Some(¤t) + { + return false; + } + if current == crate::ROOT_INO { + return true; + } + + let parent = entry.parent; + if parent == 0 || parent == current { + return false; + } + let Some(parent_entry) = self.entries.get(&parent) else { + return false; + }; + if entry.node.parent_uid.as_ref() != Some(&parent_entry.uid) + || self + .children + .get(&parent) + .is_some_and(|children| !children.contains(¤t)) + { + return false; + } + current = parent; + } + } + pub(crate) fn require_writable(&self, ino: u64) -> Result<(), fuser::Errno> { match self.entries.get(&ino) { Some(entry) if entry.writable() => Ok(()),