implemented shared id permissions

This commit is contained in:
nvrl committed 2026-07-29 19:26:57 +02:00
1 parent 7de667b386
commit 551e14b644
8 files changed
+1329 -90

No files matched your search

+279
View File
@@ -240,9 +240,20 @@ pub enum Request {
role: String,
message: Option<String>,
},
/// 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<String>,
role: String,
message: Option<String>,
},
/// 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<String>,
expires: Option<i64>,
},
/// The by-uid twin of [`Request::CreatePublicLink`].
CreatePublicLinkByUid {
uid: String,
role: String,
password: Option<String>,
expires: Option<i64>,
},
/// 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::<serde_json::Value>(&wire)
.unwrap()
.as_object()
.cloned()
.unwrap();
assert_eq!(object.len(), 1);
assert!(object.contains_key(variant));
let decoded = serde_json::from_str::<Request>(&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<String>,
role: String,
message: Option<String>,
},
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<String>,
expires: Option<i64>,
},
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::<LegacyShareRequest>(&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]
+22 -10
View File
@@ -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<bool> {
/// 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<bool> {
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<String> = 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>, 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)
}
+22
View File
@@ -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<bool> {
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();
+58 -4
View File
@@ -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();