mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
implemented shared id permissions
This commit is contained in:
1 parent
7de667b386
commit
551e14b644
8 files changed
+1329
-90
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user