mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
+692
-61
@@ -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<RootListingChild>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct RootListingChild {
|
||||
ino: u64,
|
||||
entry: Option<RootListingEntry>,
|
||||
}
|
||||
|
||||
#[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<Self> {
|
||||
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<String> {
|
||||
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<String>) -> 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<String>) -> Result<VirtualRootPlan, 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())
|
||||
});
|
||||
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<String>,
|
||||
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<String> {
|
||||
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, Errno> {
|
||||
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<String> =
|
||||
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<String> = nodes.iter().map(|node| node.name.clone()).collect();
|
||||
let hidden = self.hidden.lock().clone();
|
||||
let mut filtered_nodes: Vec<Node> = 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<NodeUid> {
|
||||
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<NodeUid> {
|
||||
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<bool>,
|
||||
) -> CoreResult<NodeUid> {
|
||||
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<NodeUid> {
|
||||
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<NodeUid> {
|
||||
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();
|
||||
|
||||
+127
-15
@@ -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<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
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<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn list_share_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
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<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
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<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
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<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
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"))?;
|
||||
|
||||
@@ -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(()),
|
||||
|
||||
Reference in new issue
Block a user