implemented primary mount shared with me

This commit is contained in:
nvrl committed 2026-07-29 17:04:42 +02:00
1 parent 295bbeadac
commit 7de667b386
12 files changed
+2018 -132

No files matched your search

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