Files
proton-drive-linux/crates/pdfs-core/src/mountpoint.rs
T
nvrl 5048492a83 fix(core,fuse,cli,gui): refuse to mount My files over a folder with files in it
FUSE mounts over a non-empty folder without complaint, so files already
in the My files folder were hidden under the mount: not uploaded, and
back only once the service stopped, so they looked deleted (#67, B277).

- pdfs-core: new `mountpoint` module. `not_empty` lists what is in a
  folder (hidden entries count); `mounted_fs_type` moves here from the
  CLI so the daemon can use it. `MountFailure` gains an optional
  `not_empty` field, which older readers ignore.
- pdfs-fuse: `mount_with` checks the folder before the root fetch and
  before any worker starts. A dead mount is cleared first; one of our
  own live mounts is left to `spawn_session`, as before.
- pdfs-cli: `cmd_daemon` records the folder in the failure record. The
  refusal backs off and retries like any mount failure, so emptying the
  folder is enough.
- pdfs-gui: the status pages explain the refusal, and a dialog offers
  to open the folder or choose another one, once per run of failures.
  The folder picker refuses a folder with files in it.

Translated in all catalogs. Docs: BUGS (B277), CHANGELOG, FAQ,
TROUBLESHOOTING, CONFIGURATION.
2026-11-01 15:30:28 +01:00

148 lines
5.1 KiB
Rust

//! Whether a folder can take the My files mount without hiding anything.
//!
//! FUSE mounts over a folder that already has files in it without a word. The
//! files then sit under the mount, out of sight: not uploaded, and back only
//! once the service stops, so they look deleted (`docs/BUGS.md` B277, #67).
//! The daemon refuses such a folder, and the app refuses it when it is picked.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The file system type the kernel reports for this client's own mounts.
pub const OWN_FS_TYPE: &str = "fuse.protondrive";
/// How many of a folder's entries an error names.
const NAMED_ENTRIES: usize = 3;
/// A folder that already has something in it.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct NotEmpty {
/// The folder.
pub path: PathBuf,
/// The first few names in it, sorted.
pub entries: Vec<String>,
/// How many entries it has in all.
pub count: usize,
}
impl std::fmt::Display for NotEmpty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} is not empty (holds ", self.path.display())?;
write!(f, "{}", self.entries.join(", "))?;
let rest = self.count.saturating_sub(self.entries.len());
if rest > 0 {
write!(f, " and {rest} more")?;
}
write!(
f,
"); My files would hide those files. Move them out, or choose another folder for My files"
)
}
}
impl std::error::Error for NotEmpty {}
/// What is in `dir`, if anything. A folder that does not exist yet is empty.
/// Hidden entries count: a `.directory` or a `.Trash-1000` hidden under the
/// mount is as lost as any other file.
pub fn not_empty(dir: &Path) -> std::io::Result<Option<NotEmpty>> {
let read = match std::fs::read_dir(dir) {
Ok(read) => read,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut names = read
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
.collect::<std::io::Result<Vec<_>>>()?;
if names.is_empty() {
return Ok(None);
}
names.sort();
let count = names.len();
names.truncate(NAMED_ENTRIES);
Ok(Some(NotEmpty {
path: dir.to_path_buf(),
entries: names,
count,
}))
}
/// The file system type mounted at `path`, as `/proc/self/mountinfo` names it
/// (`ext4`, `fuse.protondrive`, …), or `None` when nothing is mounted there.
pub fn mounted_fs_type(path: &Path) -> Option<String> {
let wanted = path.canonicalize().ok()?;
let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
mounts
.lines()
.filter_map(|line| {
let fields: Vec<_> = line.split_whitespace().collect();
let separator = fields.iter().position(|field| *field == "-")?;
let encoded = *fields.get(4)?;
let decoded = encoded.replace("\\040", " ").replace("\\011", "\t");
let mountpoint = PathBuf::from(decoded);
if mountpoint.canonicalize().ok()? == wanted {
Some(fields.get(separator + 1)?.to_string())
} else {
None
}
})
.next()
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("pdfs-mountpoint-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn an_empty_or_missing_folder_takes_the_mount() {
let dir = temp_dir("empty");
assert_eq!(not_empty(&dir).unwrap(), None);
assert_eq!(not_empty(&dir.join("missing")).unwrap(), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_folder_with_files_is_refused_and_named() {
let dir = temp_dir("files");
for name in ["report.pdf", "Notes", ".directory", "zeta.txt"] {
std::fs::write(dir.join(name), b"x").unwrap();
}
let found = not_empty(&dir).unwrap().expect("must refuse");
assert_eq!(found.count, 4);
assert_eq!(found.entries, [".directory", "Notes", "report.pdf"]);
let message = found.to_string();
assert!(message.contains("is not empty"), "{message}");
assert!(
message.contains(".directory, Notes, report.pdf and 1 more"),
"{message}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_hidden_file_alone_is_enough_to_refuse() {
let dir = temp_dir("hidden");
std::fs::write(dir.join(".Trash-1000"), b"").unwrap();
let found = not_empty(&dir).unwrap().expect("must refuse");
assert_eq!(found.count, 1);
assert!(!found.to_string().contains("more"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_plain_folder_is_not_a_mount() {
let dir = temp_dir("plain");
assert_eq!(mounted_fs_type(&dir), None);
let _ = std::fs::remove_dir_all(&dir);
}
}