mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
implemented primary mount shared with me
This commit is contained in:
1 parent
295bbeadac
commit
7de667b386
12 files changed
+2018
-132
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user