implemented share_access, POSIX read-only mode

This commit is contained in:
nvrl committed 2026-07-29 14:57:56 +02:00
1 parent 32817870a5
commit 295bbeadac
22 files changed
+3055 -146

No files matched your search

+181 -13
View File
@@ -27,6 +27,40 @@ fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid)
true
}
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
let Some(notifier) = notifier else {
return;
};
for &ino in changed {
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
}
/// 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.
fn apply_access_downgrade(
db: &Db,
event: &DriveEvent,
mut deny: impl FnMut(Option<&NodeUid>),
) -> pdfs_core::Result<()> {
match event {
DriveEvent::NodeDeleted { node_uid, .. } => {
let result = db.set_share_access(node_uid, Access::Viewer);
deny(Some(node_uid));
result
}
DriveEvent::ContinuityLost { .. }
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => {
let result = db.downgrade_all_share_access().map(|_| ());
deny(None);
result
}
_ => Ok(()),
}
}
/// Apply one remote event to the local cache and notify the kernel so it drops
/// any stale cached metadata/data for the affected inodes.
///
@@ -43,7 +77,16 @@ fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid)
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
/// 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) {
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);
});
});
match event {
DriveEvent::NodeUpdated {
node_uid,
@@ -109,11 +152,14 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
forget_and_notify(st, notifier, node_uid);
});
}
// Continuity or scope was lost: our cached listings may be arbitrarily
// stale, so drop every listing and tell the kernel to forget all
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
// Losing event continuity or any shared-access signal makes persisted
// shared roots untrustworthy. Keep owned/device trees unchanged, but
// fail every known shared subtree closed until a root refresh carries a
// current membership role.
DriveEvent::ContinuityLost { .. }
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => {
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
core.for_each_mount(|st, notifier| {
let dirs: Vec<u64> = st.children.keys().copied().collect();
for &ino in &dirs {
@@ -126,8 +172,20 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) {
}
// No substantive local change; the cursor advance is handled by the
// caller persisting the event id.
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
DriveEvent::CursorAdvanced { .. } => {}
}
access_result
}
fn acknowledge_applied_event(
db: &Db,
event: &DriveEvent,
applied: pdfs_core::Result<()>,
) -> pdfs_core::Result<DriveEventId> {
applied?;
let id = event.id().clone();
db.set_event_cursor(id.as_str())?;
Ok(id)
}
/// The folders one batch of events changed, collected so the listings are
@@ -296,12 +354,17 @@ pub(super) async fn run_event_sync(
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
break;
}
apply_event(&core, event, &mut dirty);
let applied = event.id().clone();
if let Err(e) = db.set_event_cursor(applied.as_str()) {
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
break;
}
let applied = match acknowledge_applied_event(
&db,
event,
apply_event(&core, event, &mut dirty),
) {
Ok(applied) => applied,
Err(e) => {
warn!(error = %e, "event application or acknowledgment failed; retaining prior cursor for retry");
break;
}
};
// Advance only after this exact event is durably acknowledged. If a
// later event fails, polling resumes from here and replays nothing
// that was not already made visible locally.
@@ -396,3 +459,108 @@ fn scan_local_once(
}
indexing.store(false, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
#[test]
fn deleted_share_downgrade_failure_denies_memory_and_retains_cursor() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("shared");
db.set_share_access(&root, Access::Editor).unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_share_update
BEFORE UPDATE ON share_access
BEGIN SELECT RAISE(FAIL, 'forced downgrade failure'); END;",
)?;
Ok(())
})
.unwrap();
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("event-1"),
node_uid: root,
parent_node_uid: None,
};
let denied = Cell::new(false);
let applied = apply_access_downgrade(&db, &event, |root| {
denied.set(root.is_some());
});
assert!(applied.is_err());
assert!(denied.get(), "resident state must fail closed immediately");
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert!(
db.get_event_cursor().unwrap().is_none(),
"failed downgrade must leave the event unacknowledged"
);
}
#[test]
fn deleted_resident_root_without_share_row_is_denied_and_tombstoned() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("resident-without-row");
assert!(db.share_access(&root).unwrap().is_none());
let resident = std::cell::RefCell::new(std::collections::HashMap::from([(
root.clone(),
Access::Editor,
)]));
let event = DriveEvent::NodeDeleted {
id: DriveEventId::from("event-missing-row"),
node_uid: root.clone(),
parent_node_uid: None,
};
let applied = apply_access_downgrade(&db, &event, |target| {
assert_eq!(target, Some(&root));
resident.borrow_mut().insert(root.clone(), Access::Viewer);
});
assert!(applied.is_ok());
assert_eq!(resident.borrow().get(&root), Some(&Access::Viewer));
assert_eq!(db.share_access(&root).unwrap(), Some(Access::Viewer));
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
assert_eq!(acknowledged.as_str(), "event-missing-row");
assert_eq!(
db.get_event_cursor().unwrap().as_deref(),
Some("event-missing-row")
);
}
#[test]
fn continuity_downgrade_failure_denies_memory_and_retains_cursor() {
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
let root = uid("shared");
db.set_share_access(&root, Access::Editor).unwrap();
db.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_all_share_updates
BEFORE UPDATE ON share_access
BEGIN SELECT RAISE(FAIL, 'forced global downgrade failure'); END;",
)?;
Ok(())
})
.unwrap();
let event = DriveEvent::ContinuityLost {
id: DriveEventId::from("event-2"),
};
let denied_all = Cell::new(false);
let applied = apply_access_downgrade(&db, &event, |root| {
denied_all.set(root.is_none());
});
assert!(applied.is_err());
assert!(
denied_all.get(),
"all resident shared roots must fail closed immediately"
);
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert!(db.get_event_cursor().unwrap().is_none());
}
}
+3
View File
@@ -489,6 +489,7 @@ impl Core {
/// (devices.md Phase 3).
pub(crate) fn fork_state(&self) -> Core {
let mut fork = self.clone();
let share_access = self.state.lock().share_access.clone();
fork.state = Arc::new(Mutex::new(State {
entries: HashMap::new(),
by_uid: HashMap::new(),
@@ -497,6 +498,8 @@ impl Core {
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
access_changes: std::collections::HashSet::new(),
share_access,
db: self.db.clone(),
}));
// A fresh inode space needs a fresh notification channel: this fork's
+275 -3
View File
@@ -27,7 +27,7 @@ use std::time::Duration;
use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
use proton_drive_rs::Node;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, error, info, warn};
@@ -40,6 +40,78 @@ use super::{
now_millis, now_secs, parse_node_uid,
};
const DRAIN_ACCESS_RECHECK: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DrainDisposition {
Applied,
AccessDeferred,
}
fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::error::Error>> {
let uid = || -> Result<NodeUid, Box<dyn std::error::Error>> {
parse_node_uid(&op.uid)
.ok_or_else(|| Box::<dyn std::error::Error>::from("pending op has an unparseable uid"))
};
let parent = || -> Result<NodeUid, Box<dyn std::error::Error>> {
let parent = op
.parent_uid
.as_deref()
.ok_or("pending op has no parent authority")?;
parse_node_uid(parent).ok_or_else(|| {
Box::<dyn std::error::Error>::from("pending op has an unparseable parent")
})
};
match op.kind.as_str() {
OP_REVISION | OP_TRASH => Ok(vec![uid()?]),
OP_CREATE | OP_MKDIR => Ok(vec![parent()?]),
OP_RENAME => {
let mut authorities = vec![uid()?];
if let Some(json) = op.meta_json.as_deref() {
let meta: RenameMeta = serde_json::from_str(json)?;
authorities.push(
parse_node_uid(&meta.original_parent_uid)
.ok_or("rename op has an unparseable original parent")?,
);
} else {
// Rows written by 1.1.1 predate foreign shared mounts. Their
// source tree was necessarily owned, so the node plus desired
// destination are the complete available authority set.
}
authorities.push(parent()?);
authorities.dedup();
Ok(authorities)
}
other => Err(format!("unknown pending op kind {other:?}").into()),
}
}
fn run_authorized_drain(
op: &PendingOp,
mut require: impl FnMut(&NodeUid) -> Result<(), fuser::Errno>,
apply: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
) -> Result<DrainDisposition, Box<dyn std::error::Error>> {
// This check is the admission/linearization point. A downgrade that lands
// first defers the op; one observed afterwards does not revoke this admitted
// attempt. Every later queued operation rechecks. No permission lock is
// held across remote latency.
for authority in pending_op_authorities(op)? {
match require(&authority) {
Ok(()) => {}
Err(error) if error.code() == libc::EACCES => {
return Ok(DrainDisposition::AccessDeferred);
}
Err(error) => {
return Err(
format!("drain access check failed with errno {}", error.code()).into(),
);
}
}
}
apply()?;
Ok(DrainDisposition::Applied)
}
impl Core {
/// Drain the pending-op queue: the background half of every write
/// (offline.md Phase 3).
@@ -69,7 +141,30 @@ impl Core {
self.wait_for_drain_work_or_due();
continue;
};
if let Err(e) = self.drain_op(&op) {
let outcome = run_authorized_drain(
&op,
|uid| self.require_uid_writable(uid),
|| self.drain_op(&op),
);
if matches!(outcome, Ok(DrainDisposition::AccessDeferred)) {
let next_attempt_at = now_millis() + DRAIN_ACCESS_RECHECK.as_millis() as i64;
if let Err(error) = self.db.defer_op_without_attempt(op.id, next_attempt_at) {
error!(
uid = %op.uid,
%error,
"deferring access-blocked pending operation failed"
);
self.wait_for_drain_work();
} else {
debug!(
uid = %op.uid,
next_attempt_at,
"pending operation deferred until access is writable"
);
}
continue;
}
if let Err(e) = outcome {
let attempts = op.attempts + 1;
let err_str = e.to_string();
// Never infer that user data is disposable from an error string
@@ -252,7 +347,7 @@ impl Core {
}
Err(e) => return Err(e.into()),
}
self.db.delete_op(op.id)?;
self.db.complete_trash_op(op.id, &uid)?;
self.own_sealed_revs.lock().remove(&uid);
self.invalidate_trash();
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
@@ -996,8 +1091,184 @@ fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Opti
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::Access;
use pdfs_core::db::Db;
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
use std::cell::Cell;
fn pending(kind: &str) -> PendingOp {
PendingOp {
id: 0,
kind: kind.to_string(),
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("node")).to_string(),
parent_uid: Some(NodeUid::new(VolumeId::from("v"), LinkId::from("parent")).to_string()),
name: Some("name".to_string()),
blob_path: (kind == OP_REVISION).then(|| "/staging/blob".to_string()),
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
}
}
#[test]
fn drain_authorities_match_each_operation_kind() {
for kind in [OP_REVISION, OP_TRASH] {
assert_eq!(pending_op_authorities(&pending(kind)).unwrap().len(), 1);
}
for kind in [OP_CREATE, OP_MKDIR] {
let authorities = pending_op_authorities(&pending(kind)).unwrap();
assert_eq!(authorities.len(), 1);
assert_eq!(authorities[0].to_string(), "v~parent");
}
let authorities = pending_op_authorities(&pending(OP_RENAME)).unwrap();
assert_eq!(authorities.len(), 2);
assert_eq!(authorities[0].to_string(), "v~node");
assert_eq!(authorities[1].to_string(), "v~parent");
}
fn folder_node(link: &str, parent: Option<&str>) -> Node {
Node {
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(link)),
parent_uid: parent
.map(|parent| NodeUid::new(VolumeId::from("v"), LinkId::from(parent))),
kind: NodeKind::Folder,
name: link.to_string(),
creation_time: 0,
modification_time: 0,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
#[test]
fn queued_cross_parent_rename_retains_viewer_source_authority_after_local_move() {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"pdfs-drain-rename-access-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let db = Db::open(&dir.join("cache.db")).unwrap();
let source = folder_node("source", None);
let destination = folder_node("destination", None);
let mut moved = file_node(1, 0, None);
moved.parent_uid = Some(destination.uid.clone());
for node in [&source, &destination, &moved] {
db.upsert_node(node).unwrap();
}
db.set_share_access(&source.uid, Access::Viewer).unwrap();
db.set_share_access(&destination.uid, Access::Editor)
.unwrap();
let mut op = pending(OP_RENAME);
op.uid = moved.uid.to_string();
op.parent_uid = Some(destination.uid.to_string());
op.meta_json = Some(
serde_json::to_string(&RenameMeta {
original_parent_uid: source.uid.to_string(),
})
.unwrap(),
);
let authorities = pending_op_authorities(&op).unwrap();
assert_eq!(authorities.len(), 3);
assert_eq!(authorities[1], source.uid);
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&op,
|uid| {
db.effective_node_access(uid)
.unwrap()
.filter(|access| access.writable())
.map(|_| ())
.ok_or(fuser::Errno::EACCES)
},
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::AccessDeferred);
assert!(!applied.get());
assert_eq!(
db.effective_node_access(&moved.uid).unwrap(),
Some(Access::Editor),
"the node's optimistic destination ancestry is writable"
);
drop(db);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn viewer_deferred_drain_never_applies_or_consumes_an_attempt() {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"pdfs-drain-access-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let db = Db::open(&dir.join("cache.db")).unwrap();
let mut queued = pending(OP_REVISION);
queued.attempts = 2;
db.enqueue_op(&queued).unwrap();
let op = db.pending_ops().unwrap().remove(0);
let before_count = db.pending_op_counts().unwrap();
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&op,
|_| Err(fuser::Errno::EACCES),
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::AccessDeferred);
assert!(!applied.get(), "no API surrogate may run while read-only");
db.defer_op_without_attempt(op.id, 5_000).unwrap();
let retained = db.pending_ops().unwrap();
let after_count = db.pending_op_counts().unwrap();
assert_eq!(retained.len(), 1);
assert_eq!(retained[0].attempts, op.attempts);
assert_eq!(after_count.uploads, before_count.uploads);
assert_eq!(after_count.changes, before_count.changes);
drop(db);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn owner_and_editor_drain_authorities_apply_normally() {
for access in [Access::Owner, Access::Editor] {
let applied = Cell::new(false);
let disposition = run_authorized_drain(
&pending(OP_REVISION),
|_| access.writable().then_some(()).ok_or(fuser::Errno::EACCES),
|| {
applied.set(true);
Ok(())
},
)
.unwrap();
assert_eq!(disposition, DrainDisposition::Applied);
assert!(applied.get());
}
}
fn file_node(mtime: i64, size: i64, rev: Option<&str>) -> Node {
Node {
@@ -1019,6 +1290,7 @@ mod tests {
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
+188 -44
View File
@@ -46,7 +46,7 @@ impl Filesystem for ProtonFs {
}
)
.then(|| (e.parent, e.uid.clone()));
(self.attr(ino.0, &e.node), provisional)
(self.attr(ino.0, &e.node, e.access), provisional)
}
None => {
reply.error(Errno::ENOENT);
@@ -79,7 +79,7 @@ impl Filesystem for ProtonFs {
let attr = {
let st = fs.core.state.lock();
match st.entries.get(&ino.0) {
Some(e) => fs.attr(ino.0, &e.node),
Some(e) => fs.attr(ino.0, &e.node, e.access),
// Forgotten while we waited.
None => attr,
}
@@ -110,10 +110,15 @@ impl Filesystem for ProtonFs {
}
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
let (uid, base_mtime, base_size, base_revision_id) = {
let mut st = self.core.state.lock();
match st.entries.get_mut(&ino.0) {
Some(e) if e.node.is_file() => {
if write_requested && !e.writable() {
reply.error(Errno::EACCES);
return;
}
e.open_count = e.open_count.saturating_add(1);
(
e.uid.clone(),
@@ -315,6 +320,10 @@ impl Filesystem for ProtonFs {
reply: ReplyCreate,
) {
let parent = parent.0;
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let name = match fuse_name(name) {
Ok(name) => name,
Err(error) => {
@@ -346,6 +355,10 @@ impl Filesystem for ProtonFs {
_lock_owner: Option<LockOwner>,
reply: ReplyWrite,
) {
if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error);
return;
}
let fh = fh.0;
// Stage the bytes straight into the scratch file (no base download): only
// the untouched remainder is pulled from the remote, and only at commit.
@@ -413,6 +426,10 @@ impl Filesystem for ProtonFs {
_flags: Option<BsdFileFlags>,
reply: ReplyAttr,
) {
if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error);
return;
}
// Only resizes change remote state; everything else (mode/owner/times) is
// accepted and ignored so tools that chmod/utimes after writing succeed.
if let Some(size) = size {
@@ -476,6 +493,10 @@ impl Filesystem for ProtonFs {
mode: i32,
reply: ReplyEmpty,
) {
if let Err(error) = self.core.require_writable(ino.0) {
reply.error(error);
return;
}
let new_end = offset.saturating_add(length);
let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE
@@ -637,18 +658,7 @@ impl Filesystem for ProtonFs {
) {
let (handle, unlinked_uid) = {
let mut st = self.core.state.lock();
let unlinked_uid = if let Some(entry) = st.entries.get_mut(&_ino.0) {
entry.open_count = entry.open_count.saturating_sub(1);
if entry.open_count == 0 && entry.unlinked {
let uid = entry.uid.clone();
st.forget(&uid);
Some(uid)
} else {
None
}
} else {
None
};
let unlinked_uid = release_unlinked_entry(&mut st, _ino.0);
let ino = st.handles.remove(&fh.0);
let h = ino.and_then(|i| {
let aw = st.active_writes.get_mut(&i)?;
@@ -679,6 +689,10 @@ impl Filesystem for ProtonFs {
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
let parent = parent.0;
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let name_str = match fuse_name(name) {
Ok(name) => name,
Err(error) => {
@@ -695,6 +709,10 @@ impl Filesystem for ProtonFs {
fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
let parent = parent.0;
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let name_str = match fuse_name(name) {
Ok(name) => name,
Err(error) => {
@@ -720,6 +738,10 @@ impl Filesystem for ProtonFs {
reply: ReplyEntry,
) {
let parent = parent.0;
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let name = match fuse_name(name) {
Ok(name) => name,
Err(error) => {
@@ -748,6 +770,14 @@ impl Filesystem for ProtonFs {
) {
let parent = parent.0;
let newparent = newparent.0;
if let Err(error) = self
.core
.require_writable(parent)
.and_then(|()| self.core.require_writable(newparent))
{
reply.error(error);
return;
}
let name = match fuse_name(name) {
Ok(name) => name,
Err(error) => {
@@ -771,6 +801,19 @@ impl Filesystem for ProtonFs {
});
}
fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) {
let state = self.core.state.lock();
let Some(entry) = state.entries.get(&ino.0) else {
reply.error(Errno::ENOENT);
return;
};
if !access_allowed(entry.node.is_folder(), entry.access, mask) {
reply.error(Errno::EACCES);
} else {
reply.ok();
}
}
fn getxattr(&self, _req: &Request, ino: INodeNo, name: &OsStr, size: u32, reply: ReplyXattr) {
// Always off the dispatch loop: a miss goes to the wire, and a lister
// that stats a directory issues one of these per file per advertised
@@ -935,6 +978,11 @@ pub struct ProtonFs {
gid: u32,
}
pub(crate) fn access_allowed(is_dir: bool, access: Access, mask: AccessFlags) -> bool {
(!mask.contains(AccessFlags::W_OK) || access.writable())
&& (!mask.contains(AccessFlags::X_OK) || is_dir)
}
impl ProtonFs {
/// Build the filesystem rooted at `root` (the user's My Files folder).
pub(super) fn new(core: Core, root: Node) -> Self {
@@ -944,12 +992,14 @@ impl ProtonFs {
warn!(uid = %root.uid, error = %e, "db upsert root failed");
}
st.by_uid.insert(root.uid.clone(), ROOT_INO);
let access = st.access_for_node(ROOT_INO, &root);
st.entries.insert(
ROOT_INO,
Entry {
uid: root.uid.clone(),
parent: ROOT_INO,
node: root,
access,
lookup_count: 1,
open_count: 0,
unlinked: false,
@@ -991,7 +1041,7 @@ impl ProtonFs {
}
)
.then(|| (e.parent, e.uid.clone()));
(ino, self.attr(ino, &e.node), provisional)
(ino, self.attr(ino, &e.node, e.access), provisional)
})
})
})
@@ -1023,7 +1073,7 @@ impl ProtonFs {
let st = self.core.state.lock();
st.entries
.get(&ino)
.map_or(attr, |e| self.attr(ino, &e.node))
.map_or(attr, |e| self.attr(ino, &e.node, e.access))
};
reply.entry(&TTL, &attr, Generation(0));
}
@@ -1065,11 +1115,12 @@ impl ProtonFs {
reply.ok();
}
fn attr(&self, ino: u64, node: &Node) -> FileAttr {
let (kind, perm) = match node.kind {
NodeKind::Folder => (FileType::Directory, 0o755),
NodeKind::File { .. } => (FileType::RegularFile, 0o644),
fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr {
let kind = match node.kind {
NodeKind::Folder => FileType::Directory,
NodeKind::File { .. } => FileType::RegularFile,
};
let perm = perm_bits(node.is_folder(), access);
let size = node_size(node);
let mtime = unix_secs(node.modification_time);
let crtime = unix_secs(node.creation_time);
@@ -1097,6 +1148,10 @@ impl ProtonFs {
/// subtrees, so an `rmdir` of a non-empty dir behaves the same).
/// The body of [`Filesystem::create`], run off the dispatch loop.
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
if let Err(e) = self.core.ensure_children(parent) {
reply.error(e);
return;
@@ -1128,6 +1183,10 @@ impl ProtonFs {
let node =
if !transient && self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid)
{
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
reply.error(error);
return;
}
// Create an empty file on the remote so it has a real uid immediately;
// written bytes are buffered and sealed as a new revision on close.
let new_uid = match self.core.rt.block_on(self.core.client.upload_file(
@@ -1211,7 +1270,10 @@ impl ProtonFs {
});
aw.open_count += 1;
st.handles.insert(fh, ino);
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node);
let entry = st.entries.get(&ino).unwrap();
let attr = self.attr(ino, &entry.node, entry.access);
drop(st);
self.core.flush_access_changes();
reply.created(
&TTL,
&attr,
@@ -1223,6 +1285,10 @@ impl ProtonFs {
/// The body of [`Filesystem::mkdir`], run off the dispatch loop.
fn serve_mkdir(&self, parent: u64, name: &str, reply: ReplyEntry) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
if let Err(e) = self.core.ensure_children(parent) {
reply.error(e);
return;
@@ -1245,6 +1311,10 @@ impl ProtonFs {
// queued — the folder becomes a placeholder that the drain turns into a
// real one (offline.md Phase 3b).
let node = if self.core.online.load(Ordering::Relaxed) && !is_local_uid(&parent_uid) {
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
reply.error(error);
return;
}
let new_uid =
match self
.core
@@ -1292,12 +1362,19 @@ impl ProtonFs {
warn!(%uid, error = %e, "db set_listed(true) failed for local folder");
}
}
let attr = self.attr(ino, &st.entries.get(&ino).unwrap().node);
let entry = st.entries.get(&ino).unwrap();
let attr = self.attr(ino, &entry.node, entry.access);
drop(st);
self.core.flush_access_changes();
reply.entry(&TTL, &attr, Generation(0));
}
/// The body of [`Filesystem::unlink`], run off the dispatch loop.
fn serve_unlink(&self, parent: u64, name_str: &str, reply: ReplyEmpty) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
let st = self.core.state.lock();
if let Some(entry) = st.entries.get(&ino)
@@ -1312,6 +1389,10 @@ impl ProtonFs {
/// The body of [`Filesystem::rmdir`], run off the dispatch loop.
fn serve_rmdir(&self, parent: u64, name_str: &str, reply: ReplyEmpty) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
{
let st = self.core.state.lock();
@@ -1348,10 +1429,18 @@ impl ProtonFs {
reply: ReplyEmpty,
) {
let was_unlinked = unlinked_uid.is_some();
if let Some(uid) = unlinked_uid {
self.core.discard_queued_ops(&uid);
self.core.cache.evict(&uid);
self.core.evict_reader(&uid);
if let Some(uid) = unlinked_uid
&& release_can_discard_unlinked(&self.core.db, &uid)
{
if let Err(error) = self.core.discard_queued_ops(&uid) {
warn!(%uid, ?error, "queued-op cleanup failed; retaining unlinked state");
} else {
if let Err(error) = self.core.db.delete_node(&uid) {
warn!(%uid, %error, "deleting released unlinked node state failed");
}
self.core.cache.evict(&uid);
self.core.evict_reader(&uid);
}
}
// Hand the bytes to the queue rather than uploading them here: the
// scratch file is the only copy of what was just written, and blocking
@@ -1366,6 +1455,7 @@ impl ProtonFs {
{
warn!(path = %h.path.display(), error = %e, "discarding unlinked scratch file failed");
}
self.core.cache.clear_scratch_durable(&h.path);
reply.ok();
}
(Some(h), false) => match self.core.queue_revision(&h) {
@@ -1386,6 +1476,14 @@ impl ProtonFs {
flags: RenameFlags,
reply: ReplyEmpty,
) {
if let Err(error) = self
.core
.require_writable(parent)
.and_then(|()| self.core.require_writable(newparent))
{
reply.error(error);
return;
}
let (ino, uid) = match self.core.lookup_child(parent, name) {
Ok(x) => x,
Err(e) => {
@@ -1413,11 +1511,11 @@ impl ProtonFs {
reply.error(e);
return;
}
let new_parent_uid = {
let (old_parent_uid, new_parent_uid) = {
let st = self.core.state.lock();
match st.entries.get(&newparent) {
Some(e) => e.uid.clone(),
None => {
match (st.entries.get(&parent), st.entries.get(&newparent)) {
(Some(old), Some(new)) => (old.uid.clone(), new.uid.clone()),
_ => {
reply.error(Errno::ENOENT);
return;
}
@@ -1446,7 +1544,7 @@ impl ProtonFs {
return;
}
};
if let Some((victim_ino, victim_uid)) = &victim {
let replacement_authorized = if let Some((victim_ino, victim_uid)) = &victim {
if flags.contains(RenameFlags::RENAME_NOREPLACE) {
reply.error(Errno::EEXIST);
return;
@@ -1490,11 +1588,23 @@ impl ProtonFs {
reply.error(e);
return;
}
if let Err(error) = require_rename_access(
|authority| self.core.require_uid_writable(authority),
&uid,
&old_parent_uid,
&new_parent_uid,
) {
reply.error(error);
return;
}
if let Err(e) = self.core.remove_replaced(victim_uid, newname) {
reply.error(e);
return;
}
}
true
} else {
false
};
// A node whose own creation is still queued has no server-side identity
// to rename: the queued op *is* the node, so rewriting its target is the
// whole rename. Nothing reaches the API, which is why this works offline
@@ -1557,10 +1667,26 @@ impl ProtonFs {
newparent != parent,
newname != name,
) {
match self
.core
.queue_rename(ino, &uid, newparent, &new_parent_uid, newname)
{
let queued = if replacement_authorized {
self.core.queue_rename_authorized(
ino,
&uid,
&old_parent_uid,
newparent,
&new_parent_uid,
newname,
)
} else {
self.core.queue_rename(
ino,
&uid,
&old_parent_uid,
newparent,
&new_parent_uid,
newname,
)
};
match queued {
Ok(()) => {
// Send the rename response first. Kernel-side rename cache
// updates happen while processing that response; notifying
@@ -1588,6 +1714,16 @@ impl ProtonFs {
// A failure past this point has already trashed any node the rename was
// replacing, so put it back rather than leaving the caller with neither
// the source moved nor the destination intact.
if let Err(error) = require_rename_access(
|authority| self.core.require_uid_writable(authority),
&uid,
&old_parent_uid,
&new_parent_uid,
) {
self.core.restore_replaced(victim.as_ref(), newname);
reply.error(error);
return;
}
if newname != name
&& let Err(e) = self
.core
@@ -1655,7 +1791,7 @@ impl ProtonFs {
let st = self.core.state.lock();
match st.entries.get(&ino) {
Some(e) => {
let attr = self.attr(ino, &e.node);
let attr = self.attr(ino, &e.node, e.access);
reply.attr(&TTL, &attr);
}
None => reply.error(Errno::ENOENT),
@@ -1663,6 +1799,10 @@ impl ProtonFs {
}
fn trash_child(&self, parent: u64, name: &str, reply: ReplyEmpty) {
if let Err(error) = self.core.require_writable(parent) {
reply.error(error);
return;
}
let (ino, uid) = match self.core.lookup_child(parent, name) {
Ok(x) => x,
Err(e) => {
@@ -1681,8 +1821,9 @@ impl ProtonFs {
// it just means its queued creation is no longer wanted. This works
// offline, which the remote path below cannot (offline.md Phase 3b).
if is_local_uid(&uid) {
if !open_now {
self.core.discard_queued_ops(&uid);
if !open_now && let Err(error) = self.core.discard_queued_ops(&uid) {
reply.error(error);
return;
}
self.core.state.lock().forget_or_unlink(&uid);
debug!(%uid, name, "deleted a node that had not been created remotely yet");
@@ -1694,14 +1835,15 @@ impl ProtonFs {
// command returns (offline.md Phase 3b).
if !self.core.online.load(Ordering::Relaxed) {
match self.core.queue_trash(&uid, name) {
Ok(()) => {
self.core.state.lock().forget_or_unlink(&uid);
reply.ok();
}
Ok(()) => reply.ok(),
Err(e) => reply.error(e),
}
return;
}
if let Err(e) = self.core.require_uid_writable(&uid) {
reply.error(e);
return;
}
if let Err(e) = self
.core
.rt
@@ -1718,7 +1860,9 @@ impl ProtonFs {
self.core.hidden.lock().insert(uid.clone());
self.core.state.lock().forget_or_unlink(&uid);
if !open_now {
self.core.discard_queued_ops(&uid);
if let Err(error) = self.core.discard_queued_ops(&uid) {
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
}
self.core.cache.evict(&uid);
self.core.evict_reader(&uid);
}
File diff suppressed because it is too large. Load diff
+5
View File
@@ -179,6 +179,9 @@ pub fn mount(
} = options;
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
let scope = root.tree_event_scope_id();
let share_access = db
.all_share_access()
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
// nudged over this channel; the sender lives in Core so control-socket
@@ -196,6 +199,8 @@ pub fn mount(
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
access_changes: HashSet::new(),
share_access,
db: db.clone(),
})),
cache: Arc::new(cache),
+688 -4
View File
@@ -2,15 +2,16 @@
//! the open write handles, and the interval set that tracks which bytes of a
//! staged write are authored locally.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs::File;
use std::path::PathBuf;
use std::sync::Arc;
use pdfs_core::cache::StagedWrite;
use pdfs_core::db::Db;
use pdfs_core::{Access, access_for};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{Node, NodeKind};
use proton_drive_rs::{MemberRole, Node, NodeKind};
use tracing::warn;
/// A node known to the filesystem, addressed by its kernel inode.
@@ -18,11 +19,18 @@ pub(crate) struct Entry {
pub(crate) uid: NodeUid,
pub(crate) parent: u64,
pub(crate) node: Node,
pub(crate) access: Access,
pub(crate) lookup_count: u64,
pub(crate) open_count: u32,
pub(crate) unlinked: bool,
}
impl Entry {
pub(crate) fn writable(&self) -> bool {
self.access.writable()
}
}
/// A set of non-overlapping `[start, end)` byte ranges, kept sorted and merged.
/// Tracks which bytes of a [`WriteHandle`]'s scratch file were authored locally
/// (vs. still living only in the remote base), so reads and the commit gap-fill
@@ -148,6 +156,14 @@ pub(crate) struct State {
/// have no entry here.
pub(crate) handles: HashMap<u64, u64>,
pub(crate) next_fh: u64,
/// Resident inode attributes whose effective access changed during an
/// intern/root refresh. Core drains this set and notifies the matching
/// kernel only after releasing the State lock.
pub(crate) access_changes: HashSet<u64>,
/// Persisted shared-root authority, loaded once per inode space and updated
/// on every explicit role/tombstone change. Descendant inheritance reads
/// this map instead of issuing one SQLite query per interned inode.
pub(crate) share_access: HashMap<NodeUid, Access>,
/// Unified SQLite metadata cache. Every map mutation below writes through to
/// it inside the `State` lock so the DB stays the authoritative copy across
/// restarts (see plan.md P1).
@@ -155,6 +171,306 @@ pub(crate) struct State {
}
impl State {
pub(crate) fn require_writable(&self, ino: u64) -> Result<(), fuser::Errno> {
match self.entries.get(&ino) {
Some(entry) if entry.writable() => Ok(()),
Some(_) => Err(fuser::Errno::EACCES),
None => Err(fuser::Errno::ENOENT),
}
}
pub(crate) fn access_by_uid(&self, uid: &NodeUid) -> Option<Access> {
self.by_uid
.get(uid)
.and_then(|ino| self.entries.get(ino))
.map(|entry| entry.access)
}
pub(crate) fn take_access_changes(&mut self) -> Vec<u64> {
self.access_changes.drain().collect()
}
fn stored_share_access(&self, uid: &NodeUid) -> Option<Access> {
self.share_access.get(uid).copied()
}
pub(crate) fn access_for_node(&mut self, parent: u64, node: &Node) -> Access {
let parent_access = self
.entries
.get(&parent)
.map_or(Access::Owner, |entry| entry.access);
let stored = self.stored_share_access(&node.uid);
// A persisted row is the offline authority. A live membership on a
// node directly below an owned root identifies a newly discovered
// share root and refreshes that row. Descendants inherit directly.
let is_new_share_root = node.membership.is_some() && parent_access == Access::Owner;
if stored.is_some() || is_new_share_root {
let access = node.membership.as_ref().map_or_else(
|| stored.unwrap_or(Access::Viewer),
|membership| access_for(membership.role_exact(), parent_access, true),
);
if let Err(error) = self.db.set_share_access(&node.uid, access) {
warn!(uid = %node.uid, ?access, %error, "db set_share_access failed");
} else {
self.share_access.insert(node.uid.clone(), access);
}
return access;
}
parent_access
}
/// Record a role observed by an authority-bearing shared-root surface.
///
/// Ordinary node enumeration, especially `enumerate_nodes_light` and old
/// cached JSON, may omit membership and must continue to trust persisted
/// `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.
pub(crate) fn record_observed_share_root_role(
&mut self,
uid: &NodeUid,
role: Option<MemberRole>,
) -> Vec<u64> {
let (root, parent_access) = match self.by_uid.get(uid).copied() {
Some(root) => {
let parent_access = self
.entries
.get(&root)
.and_then(|entry| self.entries.get(&entry.parent))
.map_or(Access::Owner, |entry| entry.access);
(Some(root), parent_access)
}
None => (None, Access::Unknown),
};
let access = access_for(role, parent_access, true);
if let Err(error) = self.db.set_share_access(uid, access) {
warn!(%uid, ?access, %error, "db set observed share access failed");
} else {
self.share_access.insert(uid.clone(), access);
}
let Some(root) = root else {
return Vec::new();
};
let mut changed = Vec::new();
if let Some(entry) = self.entries.get_mut(&root)
&& entry.access != access
{
entry.access = access;
changed.push(root);
}
changed.extend(self.recompute_descendant_access(root));
self.access_changes.extend(changed.iter().copied());
changed
}
fn resident_children(&self) -> HashMap<u64, Vec<u64>> {
let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
for (&ino, entry) in &self.entries {
if entry.parent != ino {
children.entry(entry.parent).or_default().push(ino);
}
}
children
}
/// Recompute all resident descendants after a root's effective access
/// changes. A nested persisted share root remains its own authority.
fn recompute_descendant_access(&mut self, root: u64) -> Vec<u64> {
let mut changed = Vec::new();
let mut queue = VecDeque::from([root]);
let mut seen = HashSet::new();
let children = self.resident_children();
while let Some(parent) = queue.pop_front() {
if !seen.insert(parent) {
continue;
}
let parent_access = match self.entries.get(&parent) {
Some(entry) => entry.access,
None => continue,
};
for &child in children.get(&parent).into_iter().flatten() {
let access = self
.entries
.get(&child)
.and_then(|entry| self.share_access.get(&entry.uid).copied())
.unwrap_or(parent_access);
if let Some(entry) = self.entries.get_mut(&child)
&& entry.access != access
{
entry.access = access;
changed.push(child);
}
queue.push_back(child);
}
}
changed
}
/// Force a known shared root and every resident descendant read-only.
///
/// This intentionally ignores nested authorities: losing access to an
/// outer tree must not leave a cached inner entry writable.
pub(crate) fn downgrade_shared_subtree(&mut self, uid: &NodeUid) -> Vec<u64> {
self.share_access.insert(uid.clone(), Access::Viewer);
let Some(&root) = self.by_uid.get(uid) else {
return Vec::new();
};
let mut changed = Vec::new();
let mut queue = VecDeque::from([root]);
let mut seen = HashSet::new();
let children = self.resident_children();
while let Some(ino) = queue.pop_front() {
if !seen.insert(ino) {
continue;
}
if let Some(entry) = self.entries.get_mut(&ino)
&& entry.access != Access::Viewer
{
entry.access = Access::Viewer;
changed.push(ino);
}
queue.extend(children.get(&ino).into_iter().flatten().copied());
}
changed
}
/// Downgrade resident shared subtrees identified by persisted authority,
/// live membership, or already-inherited non-owner access. Owned and device
/// entries remain `Owner`.
pub(crate) fn downgrade_known_shared_access(&mut self) -> Vec<u64> {
for access in self.share_access.values_mut() {
*access = Access::Viewer;
}
let roots: Vec<u64> = self
.entries
.iter()
.filter(|entry| {
let (_, entry) = entry;
entry.access != Access::Owner
|| entry.node.membership.is_some()
|| self.share_access.contains_key(&entry.uid)
})
.map(|(&ino, _)| ino)
.collect();
let children = self.resident_children();
let mut queue = VecDeque::from(roots);
let mut seen = HashSet::new();
let mut changed = Vec::new();
while let Some(ino) = queue.pop_front() {
if !seen.insert(ino) {
continue;
}
if let Some(entry) = self.entries.get_mut(&ino)
&& entry.access != Access::Viewer
{
entry.access = Access::Viewer;
changed.push(ino);
}
queue.extend(children.get(&ino).into_iter().flatten().copied());
}
changed
}
/// Rebuild effective access after cold hydration. The memo makes each
/// parent edge resolve once, then every child is a direct inheritance.
pub(crate) fn hydrate_access(&mut self) {
fn resolve(
state: &State,
ino: u64,
memo: &mut HashMap<u64, Access>,
visiting: &mut HashSet<u64>,
) -> Access {
if let Some(access) = memo.get(&ino) {
return *access;
}
let Some(entry) = state.entries.get(&ino) else {
return Access::Unknown;
};
if !visiting.insert(ino) {
return Access::Unknown;
}
let stored = state.share_access.get(&entry.uid).copied();
let access = if let Some(stored) = stored {
stored
} else if entry.parent == ino {
Access::Owner
} else if !state.entries.contains_key(&entry.parent) {
entry
.node
.parent_uid
.as_ref()
.and_then(|parent_uid| state.share_access.get(parent_uid).copied())
.unwrap_or(Access::Unknown)
} else {
let parent_access = resolve(state, entry.parent, memo, visiting);
if entry.node.membership.is_none() || parent_access != Access::Owner {
parent_access
} else {
access_for(
entry
.node
.membership
.as_ref()
.and_then(|membership| membership.role_exact()),
parent_access,
true,
)
}
};
visiting.remove(&ino);
memo.insert(ino, access);
access
}
let mut memo = HashMap::new();
let mut visiting = HashSet::new();
for ino in self.entries.keys().copied().collect::<Vec<_>>() {
resolve(self, ino, &mut memo, &mut visiting);
}
for (ino, access) in memo {
if let Some(entry) = self.entries.get_mut(&ino) {
entry.access = access;
}
}
let discovered: Vec<(NodeUid, Access)> = self
.entries
.values()
.filter_map(|entry| {
let parent_access = self
.entries
.get(&entry.parent)
.map_or(Access::Unknown, |parent| parent.access);
(entry.node.membership.is_some()
&& parent_access == Access::Owner
&& !self.share_access.contains_key(&entry.uid))
.then(|| {
(
entry.uid.clone(),
access_for(
entry
.node
.membership
.as_ref()
.and_then(|membership| membership.role_exact()),
parent_access,
true,
),
)
})
})
.collect();
for (uid, access) in discovered {
if let Err(error) = self.db.set_share_access(&uid, access) {
warn!(%uid, ?access, %error, "db set hydrated share access failed");
} else {
self.share_access.insert(uid, access);
}
}
}
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
if let Err(e) = self.db.upsert_node(&node) {
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
@@ -167,12 +483,24 @@ impl State {
/// 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 {
if let Some(&ino) = self.by_uid.get(&node.uid) {
let access = self.access_for_node(parent, &node);
let changed = self
.entries
.get(&ino)
.is_some_and(|entry| entry.access != access);
if let Some(e) = self.entries.get_mut(&ino) {
e.node = node;
e.parent = parent;
e.access = access;
}
if changed {
self.access_changes.insert(ino);
let descendants = self.recompute_descendant_access(ino);
self.access_changes.extend(descendants);
}
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);
@@ -182,6 +510,7 @@ impl State {
uid: node.uid.clone(),
parent,
node,
access,
lookup_count: 1,
open_count: 0,
unlinked: false,
@@ -276,15 +605,40 @@ impl State {
self.forget(uid)
}
/// Remove a dentry while retaining both open inode state and the persisted
/// node row. Queued trash needs the row as drain-time authority; if the node
/// is open, POSIX also requires its inode to survive until the final close.
pub(crate) fn unlink_mem(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
if let Some(&ino) = self.by_uid.get(uid)
&& let Some(entry) = self.entries.get_mut(&ino)
&& entry.open_count > 0
{
entry.unlinked = true;
if let Some(kids) = self.children.get_mut(&entry.parent) {
kids.retain(|&kid| kid != ino);
}
return Some((entry.parent, entry.node.name.clone()));
}
self.forget_mem(uid)
}
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
/// listing, its slot in its parent's listing, and its DB row. Returns
/// `(parent_ino, name)` when the node was known, so the caller can notify
/// the kernel.
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
let ino = self.by_uid.remove(uid)?;
if let Err(e) = self.db.delete_node(uid) {
warn!(%uid, error = %e, "db delete_node failed");
}
self.forget_mem(uid)
}
/// Forget a resident node while retaining its persisted row.
///
/// A queued trash uses the row as its drain-time access authority and only
/// deletes it after the remote mutation lands.
pub(crate) fn forget_mem(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
let ino = self.by_uid.remove(uid)?;
let entry = self.entries.remove(&ino)?;
self.children.remove(&ino);
if let Some(kids) = self.children.get_mut(&entry.parent) {
@@ -308,11 +662,21 @@ impl State {
new_parent_uid: &NodeUid,
name: &str,
) {
let parent_access = self
.entries
.get(&new_parent)
.map_or(Access::Owner, |entry| entry.access);
let access = self
.entries
.get(&ino)
.and_then(|entry| self.share_access.get(&entry.uid).copied())
.unwrap_or(parent_access);
let Some(entry) = self.entries.get_mut(&ino) else {
return;
};
let old_parent = entry.parent;
entry.parent = new_parent;
entry.access = access;
entry.node.name = name.to_string();
entry.node.parent_uid = Some(new_parent_uid.clone());
let node = entry.node.clone();
@@ -331,6 +695,7 @@ impl State {
if let Err(e) = self.db.upsert_node(&node) {
warn!(uid = %node.uid, error = %e, "db upsert_node failed for a queued rename");
}
self.recompute_descendant_access(ino);
}
/// Drop a directory's cached child listing and mark it unlisted in the DB,
@@ -427,7 +792,8 @@ impl State {
#[cfg(test)]
mod tests {
use super::*;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
use proton_drive_rs::ShareMembership;
use proton_drive_rs::proton_sdk::ids::{LinkId, ShareId, ShareMembershipId, VolumeId};
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
@@ -457,10 +823,19 @@ mod tests {
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
fn membership(permissions: i32) -> ShareMembership {
ShareMembership {
share_id: ShareId::from("share"),
membership_id: ShareMembershipId::from("membership"),
permissions,
}
}
/// A unique temp directory removed on drop; avoids a dev-dependency.
struct TempDir(PathBuf);
impl TempDir {
@@ -488,6 +863,7 @@ mod tests {
fn state() -> (State, TempDir) {
let dir = TempDir::new();
let db = Db::open(&dir.0.join("cache.db")).unwrap();
let share_access = db.all_share_access().unwrap();
let st = State {
entries: HashMap::new(),
by_uid: HashMap::new(),
@@ -496,6 +872,8 @@ mod tests {
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
access_changes: HashSet::new(),
share_access,
db: Arc::new(db),
};
(st, dir)
@@ -624,4 +1002,310 @@ mod tests {
st.children.remove(&folder);
assert!(st.has_children(folder), "has child in db");
}
#[test]
fn share_root_access_is_persisted_and_children_inherit_in_constant_time() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared = node("shared", "root", "Shared", true);
shared.membership = Some(membership(6));
let shared_ino = st.intern(owned_root, shared);
let child = st.intern(
shared_ino,
node("shared-child", "shared", "child.txt", false),
);
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
assert_eq!(st.entries[&child].access, Access::Editor);
assert_eq!(
st.db.share_access(&uid("shared")).unwrap(),
Some(Access::Editor)
);
}
#[test]
fn persisted_share_access_controls_offline_hydration() {
let (mut st, _dir) = state();
let root_node = node("root", "none", "My Files", true);
let shared_node = node("offline-share", "root", "Offline share", true);
let child_node = node("offline-child", "offline-share", "nested folder", true);
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
st.db.upsert_node(node).unwrap();
}
st.db
.set_share_access(&uid("offline-share"), Access::Viewer)
.unwrap();
st.share_access = st.db.all_share_access().unwrap();
// Reconstruct in deliberately non-topological order, matching the
// database hydration path that first allocates every inode and only
// then materializes entries.
let owned_root = 1;
let shared = 2;
let child = 3;
let leaf = 4;
for (node, ino) in [
(&root_node, owned_root),
(&shared_node, shared),
(&child_node, child),
(&leaf_node, leaf),
] {
st.by_uid.insert(node.uid.clone(), ino);
}
for (ino, parent, node) in [
(leaf, child, leaf_node),
(child, shared, child_node),
(shared, owned_root, shared_node),
(owned_root, owned_root, root_node),
] {
st.entries.insert(
ino,
Entry {
uid: node.uid.clone(),
parent,
node,
access: Access::Unknown,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
}
st.next_ino = 5;
st.hydrate_access();
assert_eq!(st.entries[&owned_root].access, Access::Owner);
assert_eq!(st.entries[&shared].access, Access::Viewer);
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(st.entries[&leaf].access, Access::Viewer);
assert_eq!(
st.require_writable(shared).unwrap_err().code(),
libc::EACCES
);
assert_eq!(st.require_writable(child).unwrap_err().code(), libc::EACCES);
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
assert!(st.require_writable(owned_root).is_ok());
}
#[test]
fn reinterned_share_root_propagates_a_viewer_downgrade() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(6));
let shared = st.intern(owned_root, shared_node.clone());
let child = st.intern(shared, node("child", "shared", "folder", true));
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
assert_eq!(st.entries[&leaf].access, Access::Editor);
shared_node.membership = Some(membership(4));
assert_eq!(st.intern(owned_root, shared_node), shared);
for ino in [shared, child, leaf] {
assert_eq!(st.entries[&ino].access, Access::Viewer);
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
}
assert_eq!(
st.db.share_access(&uid("shared")).unwrap(),
Some(Access::Viewer)
);
}
#[test]
fn missing_light_membership_keeps_persisted_share_authority() {
for persisted in [Access::Viewer, Access::Editor] {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
st.db.set_share_access(&uid("shared"), persisted).unwrap();
st.share_access = st.db.all_share_access().unwrap();
let shared = st.intern(
owned_root,
node("shared", "root", "Shared without membership", true),
);
let child = st.intern(
shared,
node("child", "shared", "Light child without membership", false),
);
assert_eq!(st.entries[&shared].access, persisted);
assert_eq!(st.entries[&child].access, persisted);
assert_eq!(st.db.share_access(&uid("shared")).unwrap(), Some(persisted));
}
}
#[test]
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
st.db
.set_share_access(&uid("shared"), Access::Editor)
.unwrap();
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
let child = st.intern(shared, node("child", "shared", "child.txt", false));
st.take_access_changes();
let changed = st.record_observed_share_root_role(&uid("shared"), None);
assert_eq!(st.entries[&shared].access, Access::Viewer);
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(
st.db.share_access(&uid("shared")).unwrap(),
Some(Access::Viewer)
);
assert!(changed.contains(&shared));
assert!(changed.contains(&child));
let pending = st.take_access_changes();
assert!(pending.contains(&shared));
assert!(pending.contains(&child));
}
#[test]
fn restored_editor_access_marks_root_and_descendants_for_invalidation() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(4));
let shared = st.intern(owned_root, shared_node.clone());
let child = st.intern(shared, node("child", "shared", "child.txt", false));
st.take_access_changes();
shared_node.membership = Some(membership(6));
assert_eq!(st.intern(owned_root, shared_node), shared);
assert_eq!(st.entries[&shared].access, Access::Editor);
assert_eq!(st.entries[&child].access, Access::Editor);
let changed = st.take_access_changes();
assert!(changed.contains(&shared));
assert!(changed.contains(&child));
}
#[test]
fn revoked_root_tombstone_keeps_resident_and_persisted_descendants_read_only() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(6));
let shared = st.intern(owned_root, shared_node);
let child = st.intern(shared, node("child", "shared", "folder", true));
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
st.db
.set_share_access(&uid("shared"), Access::Viewer)
.unwrap();
let changed = st.downgrade_shared_subtree(&uid("shared"));
assert!(changed.contains(&shared));
assert!(changed.contains(&child));
assert!(changed.contains(&leaf));
st.forget(&uid("shared"));
assert!(!st.entries.contains_key(&shared));
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(st.entries[&leaf].access, Access::Viewer);
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
assert_eq!(
st.db.effective_node_access(&uid("leaf")).unwrap(),
Some(Access::Viewer)
);
// Restart-shaped reconstruction: the deleted root is absent from the
// inode map, so its direct child is attached to the orphan inode. Cold
// hydration must still follow the persisted parent UID to the retained
// share_access tombstone.
let child_node = st.entries[&child].node.clone();
let leaf_node = st.entries[&leaf].node.clone();
st.entries.clear();
st.by_uid.clear();
st.children.clear();
st.by_uid.insert(child_node.uid.clone(), child);
st.by_uid.insert(leaf_node.uid.clone(), leaf);
st.entries.insert(
leaf,
Entry {
uid: leaf_node.uid.clone(),
parent: child,
node: leaf_node,
access: Access::Unknown,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
st.entries.insert(
child,
Entry {
uid: child_node.uid.clone(),
parent: 0,
node: child_node,
access: Access::Unknown,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
st.hydrate_access();
assert_eq!(st.entries[&child].access, Access::Viewer);
assert_eq!(st.entries[&leaf].access, Access::Viewer);
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
}
#[test]
fn global_access_loss_downgrades_only_recorded_shared_subtrees() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let owned_child = st.intern(owned_root, node("owned", "root", "Owned", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(6));
let shared = st.intern(owned_root, shared_node);
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
st.db.downgrade_all_share_access().unwrap();
let changed = st.downgrade_known_shared_access();
assert!(changed.contains(&shared));
assert!(changed.contains(&shared_child));
assert_eq!(st.entries[&shared].access, Access::Viewer);
assert_eq!(st.entries[&shared_child].access, Access::Viewer);
assert_eq!(st.entries[&owned_root].access, Access::Owner);
assert_eq!(st.entries[&owned_child].access, Access::Owner);
}
#[test]
fn global_access_loss_visits_a_deep_shared_tree_once_per_changed_inode() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared_node = node("shared", "root", "Shared", true);
shared_node.membership = Some(membership(6));
let shared = st.intern(owned_root, shared_node);
let mut parent = shared;
for index in 0..1_024 {
let link = format!("deep-{index}");
let parent_link = st.entries[&parent].uid.link_id.to_string();
parent = st.intern(parent, node(&link, &parent_link, &link, true));
}
st.db.downgrade_all_share_access().unwrap();
let changed = st.downgrade_known_shared_access();
let unique: HashSet<u64> = changed.iter().copied().collect();
assert_eq!(changed.len(), 1_025);
assert_eq!(unique.len(), changed.len());
assert_eq!(st.entries[&owned_root].access, Access::Owner);
assert!(
st.entries
.values()
.all(|entry| { entry.uid == uid("root") || entry.access == Access::Viewer })
);
}
#[test]
fn unknown_membership_permissions_fail_closed() {
let (mut st, _dir) = state();
let owned_root = st.intern(0, node("root", "none", "My Files", true));
let mut shared = node("shared", "root", "Shared", true);
shared.membership = Some(membership(38));
let shared = st.intern(owned_root, shared);
assert_eq!(st.entries[&shared].access, Access::Viewer);
}
}
+5 -1
View File
@@ -160,7 +160,10 @@ impl Core {
self.for_each_state(|st| {
st.forget_or_unlink(uid);
});
self.discard_queued_ops(uid);
if let Err(error) = self.discard_queued_ops(uid) {
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
return;
}
self.cache.evict(uid);
self.evict_reader(uid);
if let Err(e) = self.db.delete_node(uid) {
@@ -346,6 +349,7 @@ mod tests {
is_shared: false,
is_shared_publicly: false,
signature_email: None,
membership: None,
verification: Default::default(),
}
}
+4
View File
@@ -170,6 +170,8 @@ impl Core {
{
kids.push(ino);
}
drop(st);
self.flush_access_changes();
}
// Uploaded fine but the metadata refresh failed; it will
// appear on the next directory enumeration regardless.
@@ -235,6 +237,8 @@ impl Core {
{
kids.push(ino);
}
drop(st);
self.flush_access_changes();
Ok((ino, new_uid))
}