mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fixed fs bugs + compleance test for fuse fs
This commit is contained in:
1 parent
d0b49d3c8c
commit
a76f36bdff
8 files changed
+459
-60
No files matched your search
@@ -5,7 +5,13 @@ use super::*;
|
||||
impl Filesystem for ProtonFs {
|
||||
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
|
||||
let parent = parent.0;
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
let name = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// A folder that has not been listed yet is enumerated from the remote,
|
||||
// so serve it from a worker rather than stalling the dispatch loop. A
|
||||
// listed folder — the common case — is a map hit, and answering it
|
||||
@@ -307,7 +313,13 @@ impl Filesystem for ProtonFs {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
let name = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let parent_uid = {
|
||||
let st = self.core.state.lock();
|
||||
match st.entries.get(&parent) {
|
||||
@@ -761,7 +773,13 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
|
||||
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||
let name_str = name.to_string_lossy();
|
||||
let name_str = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Ok((ino, _)) = self.core.lookup_child(parent.0, &name_str) {
|
||||
let st = self.core.state.lock();
|
||||
if let Some(entry) = st.entries.get(&ino)
|
||||
@@ -775,7 +793,13 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
|
||||
fn rmdir(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||
let name_str = name.to_string_lossy();
|
||||
let name_str = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Ok((ino, _)) = self.core.lookup_child(parent.0, &name_str) {
|
||||
{
|
||||
let st = self.core.state.lock();
|
||||
@@ -817,7 +841,13 @@ impl Filesystem for ProtonFs {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
let name = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let parent_uid = {
|
||||
let st = self.core.state.lock();
|
||||
match st.entries.get(&parent) {
|
||||
@@ -899,8 +929,20 @@ impl Filesystem for ProtonFs {
|
||||
) {
|
||||
let parent = parent.0;
|
||||
let newparent = newparent.0;
|
||||
let name = name.to_string_lossy().into_owned();
|
||||
let newname = newname.to_string_lossy().into_owned();
|
||||
let name = match fuse_name(name) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let newname = match fuse_name(newname) {
|
||||
Ok(name) => name,
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (ino, uid) = match self.core.lookup_child(parent, &name) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
@@ -1050,12 +1092,31 @@ impl Filesystem for ProtonFs {
|
||||
// take the synchronous path, so a genuine API refusal (permissions, a
|
||||
// name clash) surfaces to the caller instead of becoming a queued op
|
||||
// that can only ever conflict.
|
||||
if !self.core.online.load(Ordering::Relaxed) || is_local_uid(&new_parent_uid) {
|
||||
if rename_needs_queue(
|
||||
self.core.online.load(Ordering::Relaxed),
|
||||
is_local_uid(&new_parent_uid),
|
||||
newparent != parent,
|
||||
newname != name,
|
||||
) {
|
||||
match self
|
||||
.core
|
||||
.queue_rename(ino, &uid, newparent, &new_parent_uid, &newname)
|
||||
{
|
||||
Ok(()) => reply.ok(),
|
||||
Ok(()) => {
|
||||
// Send the rename response first. Kernel-side rename cache
|
||||
// updates happen while processing that response; notifying
|
||||
// before it lets those updates overwrite our invalidation
|
||||
// and retain an empty destination readdir page.
|
||||
reply.ok();
|
||||
if let Some(notifier) = self.core.notifier.get() {
|
||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
||||
let _ = notifier.inval_entry(INodeNo(newparent), OsStr::new(&newname));
|
||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
||||
if newparent != parent {
|
||||
let _ = notifier.inval_inode(INodeNo(newparent), 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.core.restore_replaced(victim.as_ref(), &newname);
|
||||
reply.error(e);
|
||||
@@ -1063,54 +1124,58 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Move first if the parent changed, then rename if the name changed.
|
||||
// Rename first if both halves change. Moving first makes the encrypted
|
||||
// name requirements stale and repeatedly failed with InvalidRequirements.
|
||||
// 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 newparent != parent
|
||||
&& let Err(e) = self
|
||||
if newname != name {
|
||||
if let Err(e) = self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.move_node(&uid, &new_parent_uid))
|
||||
{
|
||||
error!(%uid, error = %e, "move failed");
|
||||
self.core.restore_replaced(victim.as_ref(), &newname);
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
.block_on(self.core.client.rename_node(&uid, &newname, None))
|
||||
{
|
||||
error!(%uid, error = %e, "rename failed");
|
||||
self.core.restore_replaced(victim.as_ref(), &newname);
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if newname != name {
|
||||
// A move changes the node's encrypted name requirements. Proton's
|
||||
// link-details read can remain briefly stale immediately after the
|
||||
// move, making the following rename fail with InvalidRequirements
|
||||
// ("out of date"). Re-read/retry that narrow eventual-consistency
|
||||
// error; other refusals are stable and must surface immediately.
|
||||
if newparent != parent {
|
||||
let mut attempts = 0u32;
|
||||
let renamed = loop {
|
||||
let moved = loop {
|
||||
match self
|
||||
.core
|
||||
.rt
|
||||
.block_on(self.core.client.rename_node(&uid, &newname, None))
|
||||
.block_on(self.core.client.move_node(&uid, &new_parent_uid))
|
||||
{
|
||||
Ok(()) => break Ok(()),
|
||||
Err(e)
|
||||
if newparent != parent
|
||||
if newname != name
|
||||
&& api_code(&e) == Some(ResponseCode::InvalidRequirements)
|
||||
&& attempts < 4 =>
|
||||
&& attempts < 20 =>
|
||||
{
|
||||
attempts += 1;
|
||||
std::thread::sleep(Duration::from_millis(100 * (1 << attempts)));
|
||||
// Requirement propagation routinely takes longer than
|
||||
// three seconds. Keep a bounded ten-second window; a
|
||||
// successful POSIX reply must mean both remote halves
|
||||
// landed, not merely that the second half was queued.
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
};
|
||||
if let Err(e) = renamed {
|
||||
error!(%uid, attempts, error = %e, "rename after move failed");
|
||||
// The move half already landed. Keep our inode model honest
|
||||
// even though the combined POSIX operation has to return EIO.
|
||||
self.core
|
||||
.state
|
||||
.lock()
|
||||
.relocate(ino, parent, newparent, &new_parent_uid, &name);
|
||||
if let Err(e) = moved {
|
||||
error!(%uid, attempts, error = %e, "move after rename failed");
|
||||
// The rename half landed in the source directory.
|
||||
let mut state = self.core.state.lock();
|
||||
let old_parent_uid = state
|
||||
.entries
|
||||
.get(&parent)
|
||||
.map(|entry| entry.uid.clone())
|
||||
.unwrap_or_else(|| uid.clone());
|
||||
state.rename_in_place(ino, parent, &old_parent_uid, &newname);
|
||||
drop(state);
|
||||
self.core.restore_replaced(victim.as_ref(), &newname);
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
@@ -1385,6 +1450,7 @@ impl ProtonFs {
|
||||
}
|
||||
// Only remove the namespace entry after Drive accepted the mutation.
|
||||
// Otherwise an EIO response still made the path disappear locally.
|
||||
self.core.hidden.lock().insert(uid.clone());
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
if !open_now {
|
||||
self.core.discard_queued_ops(&uid);
|
||||
|
||||
+107
-3
@@ -36,6 +36,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::fs::FileExt;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -225,6 +226,9 @@ struct Core {
|
||||
/// staged blob *is* the file's content and the remote still holds the old
|
||||
/// revision; and the drain worker, which performs the uploads.
|
||||
pending: Arc<Mutex<HashMap<NodeUid, PendingRevision>>>,
|
||||
/// Nodes removed through this daemon which a briefly stale remote listing
|
||||
/// may still return. Uids are not reused; an explicit restore clears one.
|
||||
hidden: Arc<Mutex<HashSet<NodeUid>>>,
|
||||
/// Nudges the drain worker: set true and notify to have it re-examine the
|
||||
/// queue instead of waiting out its backoff.
|
||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||
@@ -654,6 +658,7 @@ impl Core {
|
||||
// Before the lock: a DB row carries the size the server last
|
||||
// sealed, which a queued write is ahead of (B11).
|
||||
self.stamp_pending_sizes(&mut nodes);
|
||||
let hidden = self.hidden.lock().clone();
|
||||
let mut st = self.state.lock();
|
||||
if st.children.contains_key(&ino) {
|
||||
return Ok(());
|
||||
@@ -661,7 +666,7 @@ impl Core {
|
||||
let mut child_inos = Vec::with_capacity(nodes.len());
|
||||
let mut needs_size = Vec::new();
|
||||
for node in nodes {
|
||||
if node.trashed || node.uid == folder_uid {
|
||||
if !node_visible(&node, &folder_uid, &hidden) {
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
@@ -712,6 +717,7 @@ 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 hidden = self.hidden.lock().clone();
|
||||
|
||||
let mut st = self.state.lock();
|
||||
// Lost the race? Another thread already populated it.
|
||||
@@ -721,7 +727,7 @@ impl Core {
|
||||
let mut child_inos = Vec::with_capacity(nodes.len());
|
||||
let filtered_nodes: Vec<Node> = nodes
|
||||
.into_iter()
|
||||
.filter(|node| !node.trashed && node.uid != folder_uid)
|
||||
.filter(|node| node_visible(node, &folder_uid, &hidden))
|
||||
.collect();
|
||||
// Files whose real size the cheap enumeration could not read. Collected
|
||||
// before interning so the upgrade below has the uids without re-walking.
|
||||
@@ -1488,6 +1494,7 @@ impl Core {
|
||||
error!(%uid, error = %e, "queueing trash failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
self.hidden.lock().insert(uid.clone());
|
||||
self.state.lock().forget(uid);
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
@@ -2334,6 +2341,12 @@ impl Core {
|
||||
self.rt
|
||||
.block_on(self.client.restore_nodes(&parsed))
|
||||
.map_err(|e| CoreError::from_api(&e, "restore"))?;
|
||||
{
|
||||
let mut hidden = self.hidden.lock();
|
||||
for uid in &parsed {
|
||||
hidden.remove(uid);
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut st = self.state.lock();
|
||||
for parent in parents {
|
||||
@@ -2923,6 +2936,38 @@ fn copy_pending_for_truncate(pending: &PendingRevision, destination: &File) -> s
|
||||
destination.set_len(offset)
|
||||
}
|
||||
|
||||
/// Whether a rename must be represented as a durable desired end state.
|
||||
/// Offline operations and operations targeting a not-yet-uploaded directory
|
||||
/// cannot use the API immediately. Online remote-to-remote operations stay
|
||||
/// synchronous so a successful kernel reply means the remote namespace has
|
||||
/// already reached the same end state.
|
||||
fn rename_needs_queue(
|
||||
online: bool,
|
||||
destination_is_local: bool,
|
||||
_parent_changed: bool,
|
||||
_name_changed: bool,
|
||||
) -> bool {
|
||||
!online || destination_is_local
|
||||
}
|
||||
|
||||
/// Convert one kernel pathname component into the UTF-8 name accepted by Drive.
|
||||
/// Linux filesystems conventionally cap a component at `NAME_MAX` (255 bytes),
|
||||
/// but the FUSE kernel path can pass a longer component through to userspace.
|
||||
fn fuse_name(name: &OsStr) -> Result<String, Errno> {
|
||||
let bytes = name.as_bytes();
|
||||
if bytes.len() > 255 {
|
||||
return Err(Errno::ENAMETOOLONG);
|
||||
}
|
||||
if bytes.is_empty() || bytes == b"." || bytes == b".." {
|
||||
return Err(Errno::EINVAL);
|
||||
}
|
||||
name.to_str().map(str::to_owned).ok_or(Errno::EILSEQ)
|
||||
}
|
||||
|
||||
fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet<NodeUid>) -> bool {
|
||||
!node.trashed && node.uid != *folder_uid && !hidden.contains(&node.uid)
|
||||
}
|
||||
|
||||
/// Bytes rendered with a binary unit and one decimal place (e.g. `"1.2 GB"`),
|
||||
/// for the activity log. Uses 1024-based steps but the shorter SI labels.
|
||||
fn human_bytes(bytes: u64) -> String {
|
||||
@@ -3287,7 +3332,8 @@ mod pending_size_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Intervals, PendingRevision, conflict_name, copy_pending_for_truncate, is_stale_mount,
|
||||
Intervals, PendingRevision, conflict_name, copy_pending_for_truncate, fuse_name,
|
||||
is_stale_mount, node_visible, rename_needs_queue,
|
||||
};
|
||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||
|
||||
@@ -3456,6 +3502,64 @@ mod tests {
|
||||
assert_ne!(pending.meta.authored, vec![(0, pending.meta.len)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_combined_cross_directory_rename_stays_synchronous() {
|
||||
assert!(!rename_needs_queue(true, false, true, true));
|
||||
assert!(rename_needs_queue(false, false, false, true));
|
||||
assert!(rename_needs_queue(true, true, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_step_online_namespace_changes_stay_synchronous() {
|
||||
assert!(!rename_needs_queue(true, false, false, true));
|
||||
assert!(!rename_needs_queue(true, false, true, false));
|
||||
assert!(!rename_needs_queue(true, false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuse_names_enforce_linux_component_limits() {
|
||||
use std::ffi::OsStr;
|
||||
|
||||
assert_eq!(
|
||||
fuse_name(OsStr::new("x".repeat(255).as_str()))
|
||||
.unwrap()
|
||||
.len(),
|
||||
255
|
||||
);
|
||||
assert_eq!(
|
||||
fuse_name(OsStr::new("x".repeat(256).as_str()))
|
||||
.expect_err("256-byte component must be refused")
|
||||
.code(),
|
||||
libc::ENAMETOOLONG
|
||||
);
|
||||
assert_eq!(
|
||||
fuse_name(OsStr::new("."))
|
||||
.expect_err("dot is not an ordinary component")
|
||||
.code(),
|
||||
libc::EINVAL
|
||||
);
|
||||
assert_eq!(
|
||||
fuse_name(OsStr::new("unicodé-文件")).unwrap(),
|
||||
"unicodé-文件"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_trash_tombstones_hide_stale_remote_entries() {
|
||||
let folder_uid = NodeUid::new(VolumeId::from("vol"), LinkId::from("folder"));
|
||||
let node = node_helper("child", "folder", "child", false);
|
||||
assert!(node_visible(
|
||||
&node,
|
||||
&folder_uid,
|
||||
&std::collections::HashSet::new()
|
||||
));
|
||||
assert!(!node_visible(
|
||||
&node,
|
||||
&folder_uid,
|
||||
&std::collections::HashSet::from([node.uid.clone()])
|
||||
));
|
||||
}
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ pub fn mount(
|
||||
db,
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
|
||||
@@ -219,10 +219,12 @@ impl State {
|
||||
|
||||
/// Check if a directory inode has any child nodes in memory or in the database.
|
||||
pub(crate) fn has_children(&self, parent: u64) -> bool {
|
||||
if let Some(kids) = self.children.get(&parent)
|
||||
&& !kids.is_empty()
|
||||
{
|
||||
return true;
|
||||
// A resident listing is authoritative even when empty. Falling through
|
||||
// from `Some([])` to SQLite lets an obsolete child row contradict the
|
||||
// listing `ensure_children` just established, so readdir says empty but
|
||||
// rmdir returns ENOTEMPTY forever.
|
||||
if let Some(kids) = self.children.get(&parent) {
|
||||
return !kids.is_empty();
|
||||
}
|
||||
if let Some(entry) = self.entries.get(&parent)
|
||||
&& let Ok(has_kids) = self.db.has_children(&entry.uid)
|
||||
@@ -607,6 +609,11 @@ mod tests {
|
||||
st.db
|
||||
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!st.has_children(folder),
|
||||
"a resident empty listing overrides stale DB children"
|
||||
);
|
||||
st.children.remove(&folder);
|
||||
assert!(st.has_children(folder), "has child in db");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user