fixed fuse bugs from BUGS.md

- Durable scratch/staging publication and preservation on failure.
      - Transactional pending-operation supersession.
      - Conflict copies cannot overwrite older copies or lose the source.
      - Single-entry wipe protection and incomplete-scan aborts.
      - Failed local deletion retains its baseline.
      - Downloads are fsynced before baseline settlement.
      - Future database schemas are refused.
      - Event cursors advance only after successful invalidation and persistence.
      - IPC frame, timeout, and concurrency limits.
      - Atomic config saves; malformed configs are preserved.
      - Sensitive directories require correct ownership, real directories, and 0700.
      - Debian package now includes service and tray autostart files.
      - Release workflow enforces versions, tests, lint, formatting, and offline acceptance.
      - CI now runs offline filesystem acceptance.
This commit is contained in:
nvrl committed 2026-07-22 14:49:06 +02:00
1 parent a76f36bdff
commit 8be0f04b61
16 files changed
+976 -125

No files matched your search

+20 -12
View File
@@ -126,12 +126,16 @@ pub(super) async fn run_event_sync(
match client.enumerate_events(&scope, None).await {
Ok(events) => {
let head = events.last().map(|e| e.id().clone());
if let Some(c) = &head
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist seed cursor failed");
let Some(c) = &head else {
break None;
};
if let Err(e) = db.set_event_cursor(c.as_str()) {
warn!(error = %e, ?delay, "persist seed cursor failed; retrying");
tokio::time::sleep(delay).await;
delay = (delay * 2).min(ONLINE_PROBE_MAX);
continue;
}
break head;
break Some(c.clone());
}
Err(e) => {
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
@@ -168,15 +172,19 @@ pub(super) async fn run_event_sync(
// the life of the daemon (SDK plan #9). `apply_event` only touches
// our FUSE state, so nothing else does this.
if let Err(e) = client.invalidate_caches_for_event(event).await {
warn!(error = %e, "sdk cache invalidation for event failed");
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
break;
}
apply_event(&state, &content, &pending, &notifier, event);
}
cursor = events.last().map(|e| e.id().clone());
if let Some(c) = &cursor
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist event cursor failed");
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;
}
// 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.
cursor = Some(applied);
}
}
}
+121 -7
View File
@@ -12,8 +12,8 @@
use std::io::{BufRead, BufReader, Write as _};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::Instant;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
@@ -27,6 +27,67 @@ use tracing::{info, warn};
use super::transfers::CountingReader;
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_CONTROL_HANDLERS: usize = 64;
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
struct ControlHandlerPermit;
impl ControlHandlerPermit {
fn acquire() -> Option<Self> {
ACTIVE_CONTROL_HANDLERS
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
})
.ok()
.map(|_| Self)
}
}
impl Drop for ControlHandlerPermit {
fn drop(&mut self) {
ACTIVE_CONTROL_HANDLERS.fetch_sub(1, Ordering::AcqRel);
}
}
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
let mut bytes = Vec::new();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
break;
}
let take = available
.iter()
.position(|byte| *byte == b'\n')
.map_or(available.len(), |index| index + 1);
if bytes.len().saturating_add(take) > MAX_CONTROL_REQUEST_BYTES as usize {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"control request exceeds size limit",
));
}
bytes.extend_from_slice(&available[..take]);
reader.consume(take);
if bytes.ends_with(b"\n") {
break;
}
}
if bytes.is_empty() {
return Ok(None);
}
if bytes.len() as u64 > MAX_CONTROL_REQUEST_BYTES || !bytes.ends_with(b"\n") {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"control request exceeds limit or is not newline terminated",
));
}
String::from_utf8(bytes)
.map(Some)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
/// must live under `mountpoint`; a relative path is taken as already relative to
/// the mount root.
@@ -44,6 +105,10 @@ fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
/// Handle one control-socket connection: read a single JSON request line,
/// dispatch it against `core`, and write back a JSON response line.
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
warn!(error = %e, "control: could not set request timeout");
return;
}
let mut reader = BufReader::new(match stream.try_clone() {
Ok(s) => s,
Err(e) => {
@@ -51,10 +116,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
return;
}
});
let mut line = String::new();
if reader.read_line(&mut line).is_err() || line.trim().is_empty() {
return;
}
let line = match read_request_line(&mut reader) {
Ok(Some(line)) if !line.trim().is_empty() => line,
Ok(_) => return,
Err(e) => {
warn!(error = %e, "control: rejected request");
return;
}
};
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
Ok(CtlRequest::Status) => {
let pins = core.cache.list_pins();
@@ -792,12 +861,22 @@ pub(crate) fn run_control_socket(
for conn in listener.incoming() {
match conn {
Ok(stream) => {
let Some(permit) = ControlHandlerPermit::acquire() else {
warn!(
limit = MAX_CONTROL_HANDLERS,
"control: rejecting connection at handler limit"
);
continue;
};
let core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.clone();
if let Err(e) = std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || handle_control_conn(&core, &username, &mountpoint, stream))
.spawn(move || {
let _permit = permit;
handle_control_conn(&core, &username, &mountpoint, stream);
})
{
warn!(error = %e, "control: spawn handler failed");
}
@@ -808,3 +887,38 @@ pub(crate) fn run_control_socket(
}
}
}
#[cfg(test)]
mod request_limit_tests {
use super::*;
use std::io::Cursor;
#[test]
fn accepts_one_bounded_newline_terminated_request() {
let mut input = Cursor::new(b"{\"Status\":null}\n".to_vec());
assert_eq!(
read_request_line(&mut input).unwrap().as_deref(),
Some("{\"Status\":null}\n")
);
}
#[test]
fn rejects_a_request_without_a_terminating_newline() {
let mut input = Cursor::new(b"{\"Status\":null}".to_vec());
assert_eq!(
read_request_line(&mut input).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
}
#[test]
fn rejects_a_request_larger_than_the_limit() {
let mut bytes = vec![b'x'; MAX_CONTROL_REQUEST_BYTES as usize + 1];
bytes.push(b'\n');
let mut input = Cursor::new(bytes);
assert_eq!(
read_request_line(&mut input).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
}
}
+7 -8
View File
@@ -1129,17 +1129,16 @@ impl Filesystem for 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 newname != name {
if let Err(e) = self
if newname != name
&& let Err(e) = self
.core
.rt
.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;
}
{
error!(%uid, error = %e, "rename failed");
self.core.restore_replaced(victim.as_ref(), &newname);
reply.error(Errno::EIO);
return;
}
if newparent != parent {
let mut attempts = 0u32;
+26 -2
View File
@@ -1548,8 +1548,32 @@ impl Core {
);
}
Err(e) => {
error!(%uid, error = %e, "staging write failed; bytes lost");
let _ = std::fs::remove_file(src);
// `src` is the only known copy of bytes the kernel already
// accepted. A failure to publish them into staging must never
// turn into permission to delete them: leave the scratch file
// in place so startup recovery or a human can still salvage it.
error!(
%uid,
error = %e,
scratch = %src.display(),
"staging write failed; retaining the only scratch copy"
);
let name = {
let st = self.state.lock();
st.entries
.get(&ino)
.map(|entry| entry.node.name.clone())
.unwrap_or_default()
};
self.log_activity(
ActivityKind::Upload,
&name,
format!(
"write not queued and could not be staged; recovery copy retained at {}",
src.display()
),
false,
);
}
}
}
+146 -29
View File
@@ -55,9 +55,11 @@ use local_scan::is_write_mode;
use local_scan::open_for_write_set;
use model::{LocalItem, Outcome, PassAbort, Pending, RemoteItem};
pub(crate) use planner::base_name;
#[cfg(test)]
use planner::conflict_path;
use planner::{
FilePlan, classification_order, conflict_path, filter_baseline, guard_local_wipe, join_rel,
parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe,
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
};
/// What a [`Pending`] op did, so the reconcile can update shared state on the
/// engine thread (never inside a task).
@@ -711,7 +713,17 @@ impl Core {
}
},
FilePlan::DeleteLocal => {
let _ = std::fs::remove_file(local_root.join(rel_to_path(rel)));
if let Err(e) = std::fs::remove_file(local_root.join(rel_to_path(rel))) {
warn!(rel, error = %e, "sync: remove local file failed");
self.log_activity(
ActivityKind::Trash,
base_name(rel),
e.to_string(),
false,
);
outcome.errors += 1;
continue;
}
let _ = self.db.sync_entry_remove(folder_id, rel);
self.log_activity(ActivityKind::Trash, base_name(rel), "removed locally", true);
outcome.deleted += 1;
@@ -758,7 +770,12 @@ impl Core {
// Deferred folder deletions, deepest first.
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
for rel in delete_local_dirs {
let _ = std::fs::remove_dir_all(local_root.join(rel_to_path(&rel)));
if let Err(e) = std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))) {
warn!(rel, error = %e, "sync: remove local folder failed");
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
outcome.errors += 1;
continue;
}
let _ = self.db.sync_entry_remove(folder_id, &rel);
self.log_activity(
ActivityKind::Trash,
@@ -802,18 +819,24 @@ impl Core {
out: &mut HashMap<String, LocalItem>,
) -> Result<(), String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
for entry in entries.filter_map(|e| e.ok()) {
for entry in entries {
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
let path = entry.path();
let meta = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
let Ok(stripped) = path.strip_prefix(root) else {
continue;
};
let Some(rel) = stripped.to_str() else {
continue;
};
let meta = std::fs::symlink_metadata(&path)
.map_err(|e| format!("stat {}: {e}", path.display()))?;
let stripped = path.strip_prefix(root).map_err(|e| {
format!(
"local path {} escaped sync root {}: {e}",
path.display(),
root.display()
)
})?;
let rel = stripped.to_str().ok_or_else(|| {
format!(
"local path {} is not valid UTF-8; refusing a destructive sync pass",
path.display()
)
})?;
// Ignore our own in-flight download temp files.
if rel.contains(".pdfs-tmp-") {
continue;
@@ -1153,12 +1176,9 @@ impl Core {
// pass), then take the remote version as the shared truth.
let path = local_root.join(rel_to_path(rel));
if path.exists() {
let conflict = conflict_path(&path, now_secs());
if let Err(e) = std::fs::rename(&path, &conflict) {
warn!(rel, error = %e, "sync: could not set aside conflict copy");
} else {
info!(rel, "sync: kept local changes as a conflict copy");
}
preserve_conflict_copy(&path, now_secs())
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
info!(rel, "sync: kept local changes as a conflict copy");
}
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
.await?;
@@ -1171,8 +1191,7 @@ impl Core {
// evicted. Push the conflict copy now, while it still exists, and
// leave the remote file holding the original path.
let path = local_root.join(rel_to_path(rel));
let conflict = conflict_path(&path, now_secs());
std::fs::rename(&path, &conflict)
let conflict = preserve_conflict_copy(&path, now_secs())
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
let name = conflict
.file_name()
@@ -1297,13 +1316,24 @@ impl Core {
let _ = std::fs::remove_file(&tmp);
format!("download {rel}: {e}")
})?;
out.into_inner().flush().ok();
let mut out = out.into_inner();
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
}
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
// Match local mtime to the remote's so neither side looks "changed" next pass.
if let Ok(f) = std::fs::File::options().write(true).open(&path) {
let _ =
f.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime.max(0) as u64));
let f = std::fs::File::options()
.write(true)
.open(&path)
.map_err(|e| format!("open placed file {rel}: {e}"))?;
f.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime.max(0) as u64))
.map_err(|e| format!("set mtime for {rel}: {e}"))?;
f.sync_all()
.map_err(|e| format!("sync placed file {rel}: {e}"))?;
if let Some(parent) = path.parent() {
std::fs::File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
}
self.record_file_baseline(folder_id, rel, &path, uid).await
}
@@ -1350,6 +1380,46 @@ impl Core {
}
}
/// Publish a conflict copy without overwriting an earlier conflict bearing the
/// same timestamp. The original is removed only after the new copy is durable.
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
let metadata = std::fs::metadata(path)?;
for suffix in 0..=u32::MAX {
let conflict = conflict_path_with_suffix(path, stamp, suffix);
let mut source = std::fs::File::open(path)?;
let mut target = match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&conflict)
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
};
if let Err(e) = std::io::copy(&mut source, &mut target).and_then(|_| target.sync_all()) {
let _ = std::fs::remove_file(&conflict);
return Err(e);
}
target.set_permissions(metadata.permissions())?;
if let Ok(modified) = metadata.modified() {
target.set_modified(modified)?;
}
target.sync_all()?;
sync_parent_dir(&conflict)?;
std::fs::remove_file(path)?;
sync_parent_dir(&conflict)?;
return Ok(conflict);
}
unreachable!("u32 conflict suffix space exhausted")
}
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
match path.parent() {
Some(parent) => std::fs::File::open(parent)?.sync_all(),
None => Ok(()),
}
}
// ---- helpers --------------------------------------------------------------
/// A remote node's `(mtime, size)` change signature. Size prefers the plaintext
@@ -1609,9 +1679,10 @@ mod tests {
guard_local_wipe(&base, &one).is_ok(),
"one survivor means the tree is readable; the rest are real deletions"
);
// A one-entry baseline is a genuine single delete, not a wipe to detect.
// A one-entry folder can disappear because its mount or parent became
// unavailable just as easily as a larger tree can.
let single: HashMap<String, ()> = [("a.txt".to_string(), ())].into_iter().collect();
assert!(guard_local_wipe(&single, &none).is_ok());
assert!(guard_local_wipe(&single, &none).is_err());
// Nothing synced yet: a first pass over an empty folder is not a wipe.
assert!(guard_local_wipe(&none, &none).is_ok());
// Local paths that are not in the baseline are new uploads, and do not
@@ -1781,6 +1852,52 @@ mod tests {
assert_eq!(no_ext, PathBuf::from("/home/me/README (sync-conflict 42)"));
}
#[test]
fn conflict_preservation_never_replaces_an_existing_copy() {
let dir = sync_test_dir("conflict-collision");
let source = dir.join("notes.txt");
let existing = conflict_path(&source, 1700);
std::fs::write(&source, b"new local edit").unwrap();
std::fs::write(&existing, b"older conflict").unwrap();
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
assert_eq!(std::fs::read(&existing).unwrap(), b"older conflict");
assert_eq!(std::fs::read(&preserved).unwrap(), b"new local edit");
assert_eq!(
preserved.file_name().unwrap(),
"notes (sync-conflict 1700-1).txt"
);
assert!(!source.exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn conflict_preservation_failure_keeps_the_source() {
let dir = sync_test_dir("conflict-failure");
// A directory cannot be copied through the regular-file preservation
// path, deterministically forcing publication to fail after the source
// has been discovered.
let source = dir.join("not-a-file.txt");
std::fs::create_dir(&source).unwrap();
assert!(preserve_conflict_copy(&source, 1700).is_err());
assert!(source.is_dir(), "failure must not remove the source");
assert!(!conflict_path(&source, 1700).exists());
std::fs::remove_dir_all(dir).unwrap();
}
fn sync_test_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("pdfs-sync-{label}-{}-{nonce}", std::process::id()));
std::fs::create_dir(&path).unwrap();
path
}
// ---- open-for-write detection tests -----------------------------------
#[test]
+15 -3
View File
@@ -99,7 +99,7 @@ pub(super) fn guard_local_wipe<B, L>(
baseline: &HashMap<String, B>,
local: &HashMap<String, L>,
) -> Result<(), String> {
if baseline.len() >= 2 && baseline.keys().all(|rel| !local.contains_key(rel)) {
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
return Err(format!(
"every one of the {} synced paths is missing locally; refusing to trash \
them on Drive. Check that the folder is mounted and readable.",
@@ -162,12 +162,24 @@ pub(super) fn rel_to_path(rel: &str) -> PathBuf {
}
/// The name for a conflict copy of `path`, e.g. `notes (sync-conflict 1700000000).txt`.
#[cfg(test)]
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
conflict_path_with_suffix(path, stamp, 0)
}
/// As [`conflict_path`], with a deterministic suffix used when that name already
/// exists. This keeps conflict preservation from ever replacing an older copy.
pub(super) fn conflict_path_with_suffix(path: &Path, stamp: i64, suffix: u32) -> PathBuf {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = path.extension().and_then(|s| s.to_str());
let suffix = if suffix == 0 {
String::new()
} else {
format!("-{suffix}")
};
let name = match ext {
Some(ext) => format!("{stem} (sync-conflict {stamp}).{ext}"),
None => format!("{stem} (sync-conflict {stamp})"),
Some(ext) => format!("{stem} (sync-conflict {stamp}{suffix}).{ext}"),
None => format!("{stem} (sync-conflict {stamp}{suffix})"),
};
match path.parent() {
Some(dir) => dir.join(name),