//! One simulated daemon: a real FUSE mount on its own state directory, whose //! Drive is a [`FakeClient`]. //! //! Everything the daemon does runs as in production (drain workers, sync //! engine, event feed, online probe, control socket) except what the host //! changes (`mount::Host::Simulation`): a channel stops it instead of a signal, //! and it does not index the developer's home. use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::mpsc::SyncSender; use std::thread::JoinHandle; use std::time::{Duration, Instant}; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppConfig; use pdfs_core::control::{self, PendingOpInfo, Request, Response}; use pdfs_core::db::Db; use proton_drive_rs::ProtonDriveClient; use proton_drive_rs::proton_sdk::config::ProtonClientConfiguration; use proton_drive_rs::proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use super::fake_drive::FakeClient; use crate::mount::{Host, MountOptions, MountOutcome, mount_with}; /// How long a mount may take to come up. const MOUNT_DEADLINE: Duration = Duration::from_secs(20); /// How long a restart may wait for the last run to let go of its database. const LAST_RUN_DEADLINE: Duration = Duration::from_secs(30); pub(crate) struct Daemon { pub(crate) client: FakeClient, pub(crate) mountpoint: PathBuf, dir: PathBuf, socket: PathBuf, stop: SyncSender<()>, mount: Option>>, rt: Option, } impl Daemon { /// Mount a daemon on the state under `dir`, creating it on the first start. /// A later start on the same `dir` is a restart: it finds the queue, the /// cache and the database the last run left. pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result { log_to_test_output(); isolate_from_the_developer(); let mountpoint = dir.join("mnt"); let socket = dir.join("control.sock"); std::fs::create_dir_all(&mountpoint)?; // A thread of the last run that is still in a request holds the // database, and with it the single-writer lock, until it returns. A // real restart waits for the old process to exit in the same way. let mut db = Db::open(&dir.join("pdfs.db")); let deadline = Instant::now() + LAST_RUN_DEADLINE; while db .as_ref() .is_err_and(|error| error.to_string().contains("already using")) && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(100)); db = Db::open(&dir.join("pdfs.db")); } let db = Arc::new(db.map_err(io::Error::other)?); let cache = ContentCache::open(dir.join("cache"), dir.join("pins.json"), 0, db.clone()) .map_err(io::Error::other)?; let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(4) .enable_all() .build()?; let (stop, stopped) = std::sync::mpsc::sync_channel(1); let mount = { let drive = Arc::new(client.clone()); let handle = rt.handle().clone(); let mountpoint = mountpoint.clone(); let socket = socket.clone(); std::thread::Builder::new() .name("sim-mount".into()) .spawn(move || { mount_with( offline_client(), drive, handle, &mountpoint, cache, &socket, db, MountOptions { username: "sim".into(), sweep_mode: AppConfig::default().resolved_conflict_sweep(), upload_limit: 0, download_limit: 0, local_first: AppConfig::default().local_first.unwrap_or(true), }, Host::Simulation(stopped), ) })? }; let deadline = Instant::now() + MOUNT_DEADLINE; while !is_mounted(&mountpoint) { if mount.is_finished() { return Err(match mount.join() { Ok(Err(error)) => error, _ => io::Error::other("mount ended before it came up"), }); } if Instant::now() > deadline { return Err(io::Error::other("mount did not come up")); } std::thread::sleep(Duration::from_millis(20)); } Ok(Self { client, mountpoint, dir: dir.to_path_buf(), socket, stop, mount: Some(mount), rt: Some(rt), }) } /// What the daemon still owes Drive, as `pdfs sync queue` shows it. pub(crate) fn pending(&self) -> io::Result> { match control::send(&self.socket, &Request::ListPendingOps).map_err(io::Error::other)? { Response::PendingOps { items } => Ok(items), other => Err(io::Error::other(format!("unexpected answer {other:?}"))), } } /// Whether nothing is queued and no upload is still landing, as `pdfs /// status` counts it. An empty [`Self::pending`] is not enough before a /// read that needs the cache: the op goes before the uploaded bytes are /// cached, so a test that went offline at once raced the drain. pub(crate) fn drained(&self) -> bool { self.pending().is_ok_and(|items| items.is_empty()) && matches!( self.request(&Request::Status), Ok(Response::Status { pending_uploads: 0, pending_changes: 0, .. }) ) } /// Whether the mount believes Proton is reachable, as `pdfs status` shows /// it. pub(crate) fn online(&self) -> io::Result { match self.request(&Request::Status)? { Response::Status { online, .. } => Ok(online), other => Err(io::Error::other(format!("unexpected answer {other:?}"))), } } /// Send one control request, as the `pdfs` CLI does, and fail on a /// refusal. pub(crate) fn request(&self, request: &Request) -> io::Result { match control::send(&self.socket, request).map_err(io::Error::other)? { Response::Error { message, .. } => Err(io::Error::other(message)), response => Ok(response), } } /// Write the new file `path` under the mount, and let the drain send its /// create only once the create holds the bytes. Otherwise a drain worker /// still busy with the op before can claim the create before the release /// attaches them, which the kernel sends after `close` returns. The file /// then goes out empty, and the bytes follow as a revision, past a hook on /// the next create. pub(crate) fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> { let rel = path .strip_prefix(&self.mountpoint) .map_err(io::Error::other)? .to_string_lossy() .into_owned(); let paused = |paused| Request::SetSyncPaused { paused, until: None, }; self.request(&paused(true))?; std::fs::write(path, bytes)?; let attached = wait_until(Duration::from_secs(30), || { self.pending().is_ok_and(|items| { items .iter() .any(|item| item.kind == "create" && item.path == rel && item.exportable) }) }); self.request(&paused(false))?; match attached { true => Ok(()), false => Err(io::Error::other(format!( "{rel}: create never held the bytes" ))), } } /// Stop the daemon and start it again on the same state and link. pub(crate) fn restart(mut self) -> io::Result { self.shut_down()?; Self::start(&self.dir.clone(), self.client.clone()) } /// Stop the daemon the way `systemctl --user stop` does. pub(crate) fn stop(mut self) -> io::Result { self.shut_down() } fn shut_down(&mut self) -> io::Result { let Some(mount) = self.mount.take() else { return Ok(MountOutcome::Shutdown); }; let _ = self.stop.try_send(()); let outcome = mount .join() .unwrap_or_else(|_| Err(io::Error::other("mount thread panicked"))); if let Some(rt) = self.rt.take() { rt.shutdown_timeout(Duration::from_secs(5)); } outcome } } impl Drop for Daemon { fn drop(&mut self) { let _ = self.shut_down(); } } /// Whether a file system is mounted at `path`: it sits on another device than /// its parent. fn is_mounted(path: &Path) -> bool { use std::os::unix::fs::MetadataExt; let device = |path: &Path| std::fs::metadata(path).map(|meta| meta.dev()).ok(); let parent = path.parent().and_then(device); device(path).is_some_and(|dev| Some(dev) != parent) } /// The daemons' logs, filtered by `RUST_LOG` and shown with `--nocapture`. /// What they log at `INFO` and above is also kept for [`take_logged`], /// whatever the filter. fn log_to_test_output() { use tracing_subscriber::prelude::*; let _ = tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() .with_test_writer() .with_filter(tracing_subscriber::EnvFilter::from_default_env()), ) .with(KeepLogged.with_filter(tracing_subscriber::filter::LevelFilter::INFO)) .try_init(); } /// What the daemons logged at `INFO` and above since the last call, each as /// its message followed by its fields: `message key=value ...`. pub(crate) fn take_logged() -> Vec { std::mem::take(&mut *LOGGED.lock()) } static LOGGED: parking_lot::Mutex> = parking_lot::Mutex::new(Vec::new()); struct KeepLogged; impl tracing_subscriber::Layer for KeepLogged { fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) { let mut message = Message(String::new()); event.record(&mut message); LOGGED.lock().push(message.0); } } struct Message(String); impl tracing::field::Visit for Message { fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { if field.name() == "message" { self.0.insert_str(0, &format!("{value:?}")); } else { self.0.push_str(&format!(" {}={value:?}", field.name())); } } } /// Point the XDG directories of the whole test process at a scratch /// directory, once, before the first daemon starts. /// /// The daemon reads `config.json` through `AppDirs` in a few places (the /// adopted device, the ignore rules, the profile), and those would otherwise /// be the developer's own: an adopted device of their real account made every /// sync folder fail to register against the fake Drive. fn isolate_from_the_developer() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { let home = scratch("xdg"); for (var, sub) in [ ("XDG_CONFIG_HOME", "config"), ("XDG_STATE_HOME", "state"), ("XDG_CACHE_HOME", "cache"), ("XDG_DATA_HOME", "data"), ] { let path = home.join(sub); std::fs::create_dir_all(&path).expect("create a simulation XDG directory"); // SAFETY: the simulations run one at a time (`--test-threads=1`, // docs/TESTING.md), and this runs before the first of them starts a // daemon, so no other thread reads the environment meanwhile. unsafe { std::env::set_var(var, &path) }; } }); } /// A client for what `DriveApi` does not cover (sharing, Photos, devices), /// pointed at a closed local port so none of it leaves the machine. fn offline_client() -> ProtonDriveClient { let config = ProtonClientConfiguration::new("external-drive-pdfs-sim@0.0.0-dev") .with_base_url("http://127.0.0.1:9"); let session = ProtonApiSession::resume( config, ResumeParameters { session_id: "sim".to_owned().into(), username: "sim".into(), user_id: "sim".to_owned().into(), access_token: String::new(), refresh_token: String::new(), scopes: Vec::new(), is_waiting_for_second_factor_code: false, password_mode: PasswordMode::Single, }, ) .expect("an offline session needs no network"); ProtonDriveClient::new(&session, Vec::new()) } /// A fresh directory for one simulation, under the system temp dir. pub(crate) fn scratch(label: &str) -> PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; static N: AtomicU64 = AtomicU64::new(0); let dir = std::env::temp_dir().join(format!( "pdfs-sim-{label}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed) )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create a simulation directory"); dir } /// Wait up to `limit` for `done`, polling. pub(crate) fn wait_until(limit: Duration, mut done: impl FnMut() -> bool) -> bool { let deadline = Instant::now() + limit; loop { if done() { return true; } if Instant::now() > deadline { return false; } std::thread::sleep(Duration::from_millis(50)); } } #[cfg(test)] mod tests { use super::*; use crate::sim::fake_drive::{Entry, FakeDrive, Faults}; use pdfs_core::control::{RefreshScope, SyncIssue}; use std::collections::BTreeMap; #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_daemon_on_the_fake_drive_syncs_both_ways() { let drive = FakeDrive::new(); let dir = scratch("smoke"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); std::fs::write(daemon.mountpoint.join("hello.txt"), b"hi").unwrap(); assert!( wait_until(Duration::from_secs(30), || { drive.tree().get("hello.txt") == Some(&Entry::File(Arc::new(b"hi".to_vec()))) }), "a local write landed on Drive: {:?}", drive.tree() ); drive.device().write("phone.txt", b"from the phone"); let remote = daemon.mountpoint.join("phone.txt"); assert!( wait_until(Duration::from_secs(30), || { std::fs::read(&remote).ok().as_deref() == Some(b"from the phone".as_slice()) }), "a remote write reached the mount" ); daemon.client.set_online(false); std::fs::write(daemon.mountpoint.join("offline.txt"), b"queued").unwrap(); std::thread::sleep(Duration::from_secs(2)); assert!(!drive.tree().contains_key("offline.txt")); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(60), || drive .tree() .contains_key("offline.txt")), "a write made offline landed once the link came back" ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } /// A folder with files in it is not mounted over, and its files stay where /// they are; once it is empty, the mount comes up (B277, #67). #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_mount_over_local_files_is_refused_and_leaves_them_alone() { let drive = FakeDrive::new(); let dir = scratch("not-empty"); let mountpoint = dir.join("mnt"); std::fs::create_dir_all(mountpoint.join("Work")).unwrap(); std::fs::write(mountpoint.join("notes.txt"), b"local only").unwrap(); let error = Daemon::start(&dir, drive.client(1, Faults::lan())) .err() .expect("the mount must be refused"); let found = error .get_ref() .and_then(|inner| inner.downcast_ref::()) .unwrap_or_else(|| panic!("refused for another reason: {error}")); assert_eq!(found.entries, ["Work", "notes.txt"]); assert!(!is_mounted(&mountpoint)); assert_eq!( std::fs::read(mountpoint.join("notes.txt")).unwrap(), b"local only" ); assert!( drive.tree().is_empty(), "nothing went to Drive: {:?}", drive.tree() ); std::fs::remove_file(mountpoint.join("notes.txt")).unwrap(); std::fs::remove_dir(mountpoint.join("Work")).unwrap(); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } /// Write `bytes` at `offset` into an existing file, without truncating it. fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> { use std::os::unix::fs::FileExt; std::fs::OpenOptions::new() .write(true) .open(path)? .write_all_at(bytes, offset) } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_closed_empty_takes_a_second_write_at_once() { // The empty revision never counted as complete, so the next write // open before it drained was refused with EIO (B123). let drive = FakeDrive::new(); let dir = scratch("empty-rewrite"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("empty.txt"); std::fs::write(&path, b"old").unwrap(); assert!(wait_until(Duration::from_secs(30), || { drive.tree().contains_key("empty.txt") })); std::fs::write(&path, b"").unwrap(); write_at(&path, 0, b"filled").unwrap(); assert!( wait_until(Duration::from_secs(30), || { drive.tree().get("empty.txt") == Some(&Entry::File(Arc::new(b"filled".to_vec()))) }), "the second write landed: {:?}", drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_folder_made_while_the_drain_is_idle_reaches_drive_at_once() { // A new folder queued its create without waking the drain, so it sat // until the workers' 30 s idle poll (B160). let drive = FakeDrive::new(); let dir = scratch("idle-mkdir"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); std::fs::create_dir(daemon.mountpoint.join("x")).unwrap(); assert!( wait_until(Duration::from_secs(10), || drive.lookup("x").is_some()), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_made_empty_while_the_drain_is_idle_reaches_drive_at_once() { // A new file's create waits for the release that attaches its bytes // to wake the drain. A file closed without any attached none, so it // sat until the workers' 30 s idle poll (B192). let drive = FakeDrive::new(); let dir = scratch("idle-empty-create"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); std::fs::write(daemon.mountpoint.join("e.md"), b"").unwrap(); assert!( wait_until(Duration::from_secs(10), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("e.md").is_some() }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_whose_upload_was_not_read_back_takes_two_writes_at_once() { // The read-back after an upload went unanswered, and the drain let go of // the uploaded bytes without caching them. A partial write had nothing // to fill its gaps from, and the next open was refused with EIO (B157). let drive = FakeDrive::new(); let dir = scratch("upload-not-read-back"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); std::fs::write(&path, b"first").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"first") })); daemon.client.lose_read_back_of_next_revision(); std::fs::write(&path, b"0123456789").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"0123456789") })); write_at(&path, 2, b"ab").unwrap(); write_at(&path, 6, b"cd").unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"01ab45cd89") }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_partial_write_over_bytes_never_read_takes_another_write_at_once() { // Nothing here ever read the file, so the release of a partial write // had no cached base to fill its gaps from and queued an incomplete // revision. The next write open on it failed with EIO (B204). let drive = FakeDrive::new(); drive.device().write("f.txt", b"0123456789"); let dir = scratch("partial-write-unread-base"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); write_at(&path, 2, b"ab").unwrap(); write_at(&path, 6, b"cd").unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"01ab45cd89") }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() { // Queuing the trash dropped the cached bytes the open handle reads, // and a partial write offline failed with EIO (B186). use std::io::{Read, Seek, SeekFrom, Write}; let drive = FakeDrive::new(); let dir = scratch("unlink-open-offline"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); std::fs::write(&path, b"0123456789").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.drained() && drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec()))) })); daemon.client.set_online(false); let mut file = std::fs::OpenOptions::new() .read(true) .write(true) .open(&path) .unwrap(); std::fs::remove_file(&path).unwrap(); file.seek(SeekFrom::End(0)).unwrap(); file.write_all(b"ab").unwrap(); let mut read = Vec::new(); file.seek(SeekFrom::Start(0)).unwrap(); file.read_to_end(&mut read).unwrap(); assert_eq!(read, b"0123456789ab"); drop(file); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty() }), "{:?} {:?}", daemon.pending(), drive.tree() ); // The bytes kept for the handle went once the trash landed with it // closed. The queue empties as the trash's row goes, a moment before // the eviction, so this waits too. let cached = || -> Vec<_> { std::fs::read_dir(dir.join("cache")) .unwrap() .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_ok_and(|t| t.is_file())) .map(|e| e.file_name()) .collect() }; assert!( wait_until(Duration::from_secs(30), || cached().is_empty()), "{:?}", cached() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_stop_keeps_what_a_handle_wrote_before_its_release() { // close(2) returns before the kernel sends the release, and the stop // aborted the connection with the release still queued: the bytes of a // file just saved were never queued, and Drive kept it empty (B190). // A handle still open at the stop is the same case, held open. use std::io::Write; let drive = FakeDrive::new(); let dir = scratch("stop-before-release"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mut file = std::fs::File::create(daemon.mountpoint.join("f.txt")).unwrap(); file.write_all(b"0123456789").unwrap(); let daemon = daemon.restart().unwrap(); drop(file); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec()))) }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_write_that_failed_on_a_short_outage_goes_out_soon_after_the_link_returns() { // Only the drain met the outage, and it never took the mount offline: // no probe saw the link come back, and the write sat out its backoff // (B189). let drive = FakeDrive::new(); let dir = scratch("short-outage"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); std::fs::write(daemon.mountpoint.join("g.txt"), b"g").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().len() == 1 })); daemon.client.set_online(false); std::fs::write(daemon.mountpoint.join("f.txt"), b"0123456789").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon .pending() .is_ok_and(|items| items.iter().any(|item| item.attempts > 0)) })); // The drain records the failure just before it takes the mount // offline. Left online, only the event poll would, much later. assert!(wait_until(Duration::from_secs(2), || daemon .online() .is_ok_and(|online| !online))); daemon.client.set_online(true); // The probe that sees the link return runs 2 s after the drop, then // 6 s, and a loaded run can reach this line after the first one. So // the 3 s below count from the mount being back, not from the link: // an op left on its backoff waits 4 s or more after its failure. assert!(wait_until(Duration::from_secs(30), || daemon .online() .unwrap_or(false))); assert!( wait_until(Duration::from_secs(3), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec()))) }), "{:?}", daemon.pending() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_new_file_deleted_while_open_as_its_create_lands_still_reads() { // The create's op was retired and the tree still had the placeholder. // The queue read empty, and the delete trashed the node by its real // uid, which the tree did not have yet: it took the open file for // closed and dropped the bytes its handle reads (B257). use std::io::{Read, Seek, SeekFrom, Write}; let drive = FakeDrive::new(); let dir = scratch("delete-open-as-create-lands"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); let landing = daemon.client.hold_next_landing_of_any(); std::fs::write(&path, b"0123456789").unwrap(); assert!(wait_until(Duration::from_secs(30), || landing.reached())); assert!(!daemon.drained(), "the landing create is counted"); let mut file = std::fs::OpenOptions::new() .read(true) .write(true) .open(&path) .unwrap(); drop(landing); assert!(wait_until(Duration::from_secs(30), || daemon.drained())); daemon.client.set_online(false); std::fs::remove_file(&path).unwrap(); file.seek(SeekFrom::End(0)).unwrap(); file.write_all(b"ab").unwrap(); let mut read = Vec::new(); file.seek(SeekFrom::Start(0)).unwrap(); file.read_to_end(&mut read).unwrap(); assert_eq!(read, b"0123456789ab"); drop(file); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(30), || { daemon.drained() && drive.tree().is_empty() }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() { // The bytes kept for the open handle outlived a restart, which queued // them again as a revision; the trash then landed and removed them, and // the revision retried on a missing blob for good (B188). use std::io::Write; let drive = FakeDrive::new(); let dir = scratch("unlink-open-restart"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); std::fs::write(&path, b"0123456789").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.drained() && drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec()))) })); daemon.client.set_online(false); std::fs::write(&path, b"abcdef").unwrap(); let mut file = std::fs::OpenOptions::new() .read(true) .write(true) .open(&path) .unwrap(); std::fs::remove_file(&path).unwrap(); file.write_all(b"xy").unwrap(); drop(file); let daemon = daemon.restart().unwrap(); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty() }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_write_opened_as_an_upload_lands_takes_the_landed_revision_as_its_base() { // An upload landed and the drain let go of its blob before the tree had // the node. A partial write opened in between took the revision the // upload replaced as its base, found none of its bytes cached, and // queued an incomplete revision: the next write open failed with EIO // (B170). let drive = FakeDrive::new(); let dir = scratch("write-opened-as-upload-lands"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); std::fs::write(&path, b"first").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"first") })); let uid = drive.lookup("f.txt").unwrap(); let landing = daemon.client.hold_next_landing(&uid); std::fs::write(&path, b"0123456789").unwrap(); assert!(wait_until(Duration::from_secs(30), || landing.reached())); write_at(&path, 2, b"ab").unwrap(); drop(landing); write_at(&path, 6, b"cd").unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"01ab45cd89") }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_renamed_while_its_upload_is_read_back_keeps_its_new_name() { // The rename landed while the drain read the uploaded file back. The // answer still had the old name, and the drain found no rename queued, // so the mount showed the file under its old name and the new one // answered ENOENT (B172). let drive = FakeDrive::new(); let dir = scratch("renamed-while-read-back"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); std::fs::write(mnt.join("a.txt"), b"first").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("a.txt").cloned() == file(b"first") })); let uid = drive.lookup("a.txt").unwrap(); let mut held = daemon.client.hold_answer_to_next_read(&uid); std::fs::write(mnt.join("a.txt"), b"second").unwrap(); assert!(wait_until(Duration::from_secs(30), || held.reached())); std::fs::rename(mnt.join("a.txt"), mnt.join("f.txt")).unwrap(); // Each read is answered in turn until the upload's op is retired; the // answer to the read-back after it is held until the rename has landed. let reading_back = loop { if daemon .pending() .is_ok_and(|items| items.iter().all(|op| op.kind != "revision")) { break held; } let next = daemon.client.hold_answer_to_next_read(&uid); drop(held); assert!(wait_until(Duration::from_secs(30), || next.reached())); held = next; }; assert!( wait_until(Duration::from_secs(30), || drive.lookup("f.txt").is_some()), "{:?} {:?}", daemon.pending(), drive.tree() ); drop(reading_back); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"second") }), "{:?} {:?}", daemon.pending(), drive.tree() ); // Long enough for the drain to have put the read-back into the tree. std::thread::sleep(Duration::from_millis(200)); assert_eq!(std::fs::read(mnt.join("f.txt")).unwrap(), b"second"); assert!(!mnt.join("a.txt").exists()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_deleted_as_its_create_lands_is_trashed_on_drive() { // The drain had retired the create and the tree still had the file // under its stand-in. The delete found no op left to drop and took the // file for one Drive had never seen, so Drive kept it (B173). let drive = FakeDrive::new(); let dir = scratch("deleted-as-create-lands"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("c.bin"); let create = daemon.client.hold_reply_to_next_create(); daemon.write_new(&path, b"abc").unwrap(); assert!(wait_until(Duration::from_secs(30), || create.reached())); let uid = drive.lookup("c.bin").unwrap(); let landing = daemon.client.hold_next_landing(&uid); drop(create); assert!(wait_until(Duration::from_secs(30), || landing.reached())); std::fs::remove_file(&path).unwrap(); drop(landing); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("c.bin").is_none() }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(!path.exists()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_replaced_as_its_create_lands_is_trashed_on_drive() { // The same race as B173, through a rename onto the file: the replace // found no op left to drop and took the file for one Drive had never // seen. Drive kept it under the name, and the next rename of that name // moved it instead of the file that replaced it (B175). let drive = FakeDrive::new(); let dir = scratch("replaced-as-create-lands"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); std::fs::write(mnt.join("b.txt"), b"new").unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("b.txt").is_some() }), "{:?} {:?}", daemon.pending(), drive.tree() ); let create = daemon.client.hold_reply_to_next_create(); daemon.write_new(&mnt.join("a.txt"), b"old").unwrap(); assert!(wait_until(Duration::from_secs(30), || create.reached())); let uid = drive.lookup("a.txt").unwrap(); let landing = daemon.client.hold_next_landing(&uid); drop(create); assert!(wait_until(Duration::from_secs(30), || landing.reached())); std::fs::rename(mnt.join("b.txt"), mnt.join("a.txt")).unwrap(); drop(landing); std::fs::rename(mnt.join("a.txt"), mnt.join("d")).unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("a.txt").is_none() && drive.tree().get("d") == Some(&Entry::File(Arc::new(b"new".to_vec()))) }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_eq!(std::fs::read(mnt.join("d")).unwrap(), b"new"); assert!(!mnt.join("a.txt").exists()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_read_as_its_create_lands_has_its_bytes() { // The landing moved the queued write to the uid Drive gave the file. // A read that still knew the file by its stand-in found no write // queued under it, took the file for one never written and read it // empty (B179). let drive = FakeDrive::new(); let dir = scratch("read-as-create-lands"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); let create = daemon.client.hold_reply_to_next_create(); daemon.write_new(&mnt.join("a.txt"), b"old").unwrap(); assert!(wait_until(Duration::from_secs(30), || create.reached())); std::fs::write(mnt.join("a.txt"), b"newer").unwrap(); let uid = drive.lookup("a.txt").unwrap(); let landing = daemon.client.hold_next_landing(&uid); drop(create); assert!(wait_until(Duration::from_secs(30), || landing.reached())); assert_eq!(std::fs::read(mnt.join("a.txt")).unwrap(), b"newer"); drop(landing); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("a.txt") == Some(&Entry::File(Arc::new(b"newer".to_vec()))) }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_discarded_change_is_undone_here_and_never_sent() { let drive = FakeDrive::new(); for name in ["keep.txt", "edit.txt", "gone.txt"] { drive.device().write(name, b"remote"); } let before = drive.tree(); let dir = scratch("discard"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); let paused = |paused| Request::SetSyncPaused { paused, until: None, }; daemon.request(&paused(true)).unwrap(); std::fs::rename(mnt.join("keep.txt"), mnt.join("moved.txt")).unwrap(); std::fs::write(mnt.join("edit.txt"), b"local").unwrap(); std::fs::remove_file(mnt.join("gone.txt")).unwrap(); std::fs::write(mnt.join("new.txt"), b"new").unwrap(); std::fs::create_dir(mnt.join("dir")).unwrap(); // Discarding one change drops the others queued on its node. for _ in 0..10 { let Some(op) = daemon.pending().unwrap().into_iter().next() else { break; }; let reply = daemon.request(&Request::DiscardPendingOp { id: op.id }); assert!( matches!(reply, Ok(Response::Ok { .. })), "{op:?}: {reply:?}" ); } assert!(daemon.pending().unwrap().is_empty()); let mut names: Vec<_> = std::fs::read_dir(&mnt) .unwrap() .map(|entry| entry.unwrap().file_name().into_string().unwrap()) .filter(|name| name.ends_with(".txt") || name == "dir") .collect(); names.sort(); assert_eq!(names, ["edit.txt", "gone.txt", "keep.txt"]); for name in &names { assert_eq!(std::fs::read(mnt.join(name)).unwrap(), b"remote", "{name}"); } daemon.request(&paused(false)).unwrap(); std::thread::sleep(Duration::from_secs(2)); assert_eq!(drive.tree(), before); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_refused_change_shows_on_its_node_until_it_is_discarded() { let drive = FakeDrive::new(); let dir = scratch("issue-on-node"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); drive.set_quota(10); std::fs::write(mnt.join("big.bin"), [0u8; 64]).unwrap(); std::fs::write(mnt.join("small.txt"), b"ok").unwrap(); let list = || { let path = mnt.to_string_lossy().into_owned(); match daemon.request(&Request::ListDir { path }) { Ok(Response::Entries { entries }) => entries .into_iter() .filter(|entry| entry.name.contains('.')) .map(|entry| (entry.name, entry.issue)) .collect::>(), other => panic!("the daemon did not list the root: {other:?}"), } }; let refused = BTreeMap::from([ ("big.bin".to_string(), Some(SyncIssue::Quota)), ("small.txt".to_string(), None), ]); assert!( wait_until(Duration::from_secs(30), || list() == refused), "{:?} {:?}", list(), daemon.pending() ); let op = daemon.pending().unwrap().remove(0); let reply = daemon.request(&Request::DiscardPendingOp { id: op.id }); assert!(matches!(reply, Ok(Response::Ok { .. })), "{reply:?}"); assert_eq!(list(), BTreeMap::from([("small.txt".to_string(), None)])); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_folder_whose_mkdir_is_queued_lists_after_a_refresh() { // Once its listing was dropped, the folder was asked of Drive by its // stand-in, and every listing and lookup in it failed with EIO until // its mkdir landed (B125). let drive = FakeDrive::new(); let dir = scratch("queued-folder-refresh"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); let paused = |paused| Request::SetSyncPaused { paused, until: None, }; daemon.request(&paused(true)).unwrap(); std::fs::create_dir(mnt.join("d")).unwrap(); std::fs::write(mnt.join("d/f.txt"), b"queued").unwrap(); let path = mnt.join("d").to_string_lossy().into_owned(); let refresh = Request::Refresh { scope: RefreshScope::Dir { path: path.clone() }, }; assert!(matches!(daemon.request(&refresh), Ok(Response::Ok { .. }))); let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir { path }) else { panic!("the daemon did not list d"); }; let names: Vec<_> = entries.iter().map(|entry| entry.name.as_str()).collect(); assert_eq!(names, ["f.txt"]); assert_eq!(std::fs::read(mnt.join("d/f.txt")).unwrap(), b"queued"); daemon.request(&paused(false)).unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("d/f.txt") == Some(&Entry::File(Arc::new(b"queued".to_vec()))) }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_moved_while_its_create_was_on_the_wire_keeps_its_new_name() { // The move queued behind the create landed while the drain read the // new node back. The answer still had the old name, and the drain no // longer found the move queued, so the mount showed the old name in // the new folder (B161). let drive = FakeDrive::new(); let dir = scratch("moved-while-created"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); std::fs::create_dir(mnt.join("x")).unwrap(); std::fs::create_dir(mnt.join("y")).unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("y").is_some() }), "{:?} {:?}", daemon.pending(), drive.tree() ); let create = daemon.client.hold_reply_to_next_create(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap(); assert!(wait_until(Duration::from_secs(30), || create.reached())); std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap(); let uid = drive.lookup("x/c.bin").unwrap(); // Each read-back is answered in turn until the create is retired; the // answer to the next one is held until the move has landed. A new // folder wakes the drain for the move. let mut held = create; let adopting = loop { let next = daemon.client.hold_answer_to_next_read(&uid); drop(held); assert!(wait_until(Duration::from_secs(30), || next.reached())); if daemon .pending() .is_ok_and(|items| items.iter().all(|op| op.kind != "create")) { break next; } held = next; }; std::fs::create_dir(mnt.join("z")).unwrap(); assert!( wait_until(Duration::from_secs(10), || drive.lookup("y/d").is_some()), "{:?} {:?}", daemon.pending(), drive.tree() ); drop(adopting); let file = Some(Entry::File(Arc::new(b"abc".to_vec()))); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("y/d").cloned() == file }), "{:?} {:?}", daemon.pending(), drive.tree() ); let names: Vec<_> = std::fs::read_dir(mnt.join("y")) .unwrap() .map(|e| e.unwrap().file_name().into_string().unwrap()) .collect(); assert_eq!(names, ["d"]); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_read_back_before_drive_committed_it_keeps_its_bytes() { // Drive listed the new file a moment before its create was committed, // with no revision and no size. The drain took that answer for the // file, and a read right after the create landed found it empty // (B163). let drive = FakeDrive::new(); let dir = scratch("read-back-unrevised"); // The feed reports the file as it is long after the read. let faults = Faults { event_delay: Duration::from_secs(60), ..Faults::lan() }; let daemon = Daemon::start(&dir, drive.client(1, faults)).unwrap(); let mnt = daemon.mountpoint.clone(); // A folder made here is listed from the tree, not from Drive. std::fs::create_dir(mnt.join("x")).unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some() }), "{:?} {:?}", daemon.pending(), drive.tree() ); take_logged(); let create = daemon.client.hold_reply_to_next_create(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap(); assert!(wait_until(Duration::from_secs(30), || create.reached())); let uid = drive.lookup("x/c.bin").unwrap(); // The read-back after the create, the one adopting it, and the size // fetch a listing makes. daemon.client.answer_reads_unrevised(&uid, 3); drop(create); let mut logged = Vec::new(); assert!( wait_until(Duration::from_secs(30), || { logged.extend(take_logged()); logged .iter() .any(|line| line.starts_with("pending create landed") && line.contains("c.bin")) }), "{logged:?}" ); // A listing refreshes the size the kernel holds, as the account run's // did before it read the file. let sizes: Vec<_> = std::fs::read_dir(mnt.join("x")) .unwrap() .map(|e| e.unwrap().metadata().unwrap().len()) .collect(); assert_eq!(sizes, [3]); assert_eq!(std::fs::read(mnt.join("x/c.bin")).unwrap(), b"abc"); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_create_drive_lists_before_it_is_read_back_is_listed_once() { // The feed reported a new file while the drain still read it back, so // the folder was listed from Drive again, and the file came twice: as // Drive's node and as the queued create's stand-in. A 128-file folder // listed 129 (B164). let drive = FakeDrive::new(); let dir = scratch("listed-before-retired"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); std::fs::create_dir(mnt.join("x")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some() })); let create = daemon.client.hold_reply_to_next_create(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap(); assert!(wait_until(Duration::from_secs(30), || create.reached())); let uid = drive.lookup("x/c.bin").unwrap(); let read_back = daemon.client.hold_next_read(&uid); drop(create); assert!(wait_until(Duration::from_secs(30), || read_back.reached())); // Long enough for the next event poll to bring the file's event. std::thread::sleep(crate::POLL_INTERVAL + Duration::from_secs(3)); let names: Vec<_> = std::fs::read_dir(mnt.join("x")) .unwrap() .map(|e| e.unwrap().file_name()) .collect(); drop(read_back); assert_eq!(names, ["c.bin"]); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); assert_eq!(std::fs::read(mnt.join("x/c.bin")).unwrap(), b"abc"); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_listing_taken_before_a_create_commits_keeps_the_landed_file() { // Drive listed a new file before its create was committed, with no // revision, and the answer came back after the create landed: the // queue no longer held the file, so Drive's empty node was taken, and // a 17-byte file read back as 0 bytes (B166). The size upgrade's read // still finds it so, and the daemon's own listing is checked: the // kernel still has the size the write left. let drive = FakeDrive::new(); let dir = scratch("listed-before-commit"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); std::fs::create_dir(mnt.join("x")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some() })); let create = daemon.client.hold_reply_to_next_create(); daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap(); assert!(wait_until(Duration::from_secs(30), || create.reached())); // Long enough for the next event poll to bring the file's event, which // drops the folder's listing. std::thread::sleep(crate::POLL_INTERVAL + Duration::from_secs(3)); let folder = drive.lookup("x").unwrap(); let uid = drive.lookup("x/c.bin").unwrap(); let listing = daemon.client.hold_answer_to_next_listing(&folder, &uid); let lister = { let x = mnt.join("x"); std::thread::spawn(move || std::fs::read_dir(x).unwrap().count()) }; assert!(wait_until(Duration::from_secs(30), || listing.reached())); drop(create); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); daemon.client.answer_reads_unrevised(&uid, 1); drop(listing); assert_eq!(lister.join().unwrap(), 1); let path = mnt.join("x").to_string_lossy().into_owned(); let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir { path }) else { panic!("the daemon did not list x"); }; let sizes: Vec<_> = entries .iter() .map(|entry| (entry.name.as_str(), entry.size)) .collect(); assert_eq!(sizes, [("c.bin", 3)]); assert_eq!(std::fs::read(mnt.join("x/c.bin")).unwrap(), b"abc"); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } /// The database of a daemon just stopped, once the threads it left in a /// request have let go of it, as [`Daemon::start`] waits for them. fn reopen(dir: &Path) -> Db { let mut db = None; assert!(wait_until(LAST_RUN_DEADLINE, || { db = Db::open(&dir.join("pdfs.db")).ok(); db.is_some() })); db.unwrap() } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn close_syncs_a_written_file_and_fsync_accepts_a_read_only_one() { // `close` is answered by flush, which now syncs the scratch file so a // failure reaches the program; `fsync` on a descriptor opened for // reading failed with EBADF (B222). use std::io::{Read, Write}; let drive = FakeDrive::new(); let dir = scratch("flush-fsync"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("notes.txt"); let mut file = std::fs::File::create(&path).unwrap(); file.write_all(b"written then closed").unwrap(); drop(file); let mut again = std::fs::OpenOptions::new() .append(true) .open(&path) .unwrap(); again.write_all(b", twice").unwrap(); again.flush().unwrap(); drop(again); let mut read_only = std::fs::File::open(&path).unwrap(); read_only.sync_all().unwrap(); let mut text = String::new(); read_only.read_to_string(&mut text).unwrap(); assert_eq!(text, "written then closed, twice"); drop(read_only); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); assert!(drive.lookup("notes.txt").is_some()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_stop_ends_a_mirror_pass_after_the_uploads_it_is_sending() { // A stop that came while a mirror pass was uploading waited for it, // and the pass went on to the next level of folders until the stop // gave up on it and ended the runtime under it (B169). let drive = FakeDrive::new(); let dir = scratch("mirror-stop"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); std::fs::create_dir(daemon.mountpoint.join("mirrored")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("mirrored").is_some() })); let remote = drive.lookup("mirrored").unwrap(); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let local = dir.join("local"); std::fs::create_dir_all(local.join("deeper")).unwrap(); std::fs::write(local.join("first"), b"abc").unwrap(); std::fs::write(local.join("deeper/second"), b"def").unwrap(); let folder = reopen(&dir) .sync_folder_add(local.to_str().unwrap(), &remote.to_string(), "") .unwrap(); let client = drive.client(1, Faults::lan()); let upload = client.hold_next_create(); let daemon = Daemon::start(&dir, client).unwrap(); assert!(wait_until(Duration::from_secs(30), || upload.reached())); let socket = daemon.socket.clone(); let stopping = std::thread::spawn(move || daemon.stop()); assert!(wait_until(Duration::from_secs(30), || !socket.exists())); drop(upload); assert!(matches!( stopping.join().unwrap(), Ok(MountOutcome::Shutdown) )); assert!(drive.lookup("mirrored/first").is_some()); assert!(drive.lookup("mirrored/deeper").is_some()); assert!(drive.lookup("mirrored/deeper/second").is_none()); let logged = take_logged(); assert!( logged .iter() .any(|line| line.contains("pass cut short by the stop")) ); assert!( !logged .iter() .any(|line| line.contains("did not finish in time")), "{logged:#?}" ); let db = reopen(&dir); assert_ne!( db.sync_folder_get(folder).unwrap().unwrap().state, "syncing" ); let _ = std::fs::remove_dir_all(&dir); } /// A folder `mirrored` on Drive, synced in mirror mode with `dir/local`, /// which holds `files` before the first pass. Returns the daemon, the /// folder's id and the link it uses. fn start_mirror( drive: &Arc, dir: &Path, files: &[(&str, &[u8])], ) -> (Daemon, i64, FakeClient) { let daemon = Daemon::start(dir, drive.client(1, Faults::lan())).unwrap(); std::fs::create_dir(daemon.mountpoint.join("mirrored")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("mirrored").is_some() })); let remote = drive.lookup("mirrored").unwrap(); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let local = dir.join("local"); std::fs::create_dir_all(&local).unwrap(); for (name, bytes) in files { std::fs::write(local.join(name), bytes).unwrap(); } let folder = reopen(dir) .sync_folder_add(local.to_str().unwrap(), &remote.to_string(), "") .unwrap(); let client = drive.client(1, Faults::lan()); let daemon = Daemon::start(dir, client.clone()).unwrap(); (daemon, folder, client) } /// Wait until a pass of the mirror that began after `since` (epoch /// seconds) is over without errors. fn wait_for_pass(daemon: &Daemon, folder: i64, since: i64) -> bool { wait_until(Duration::from_secs(30), || { let Ok(Response::SyncFolders { items }) = daemon.request(&Request::ListSyncFolders) else { return false; }; items.iter().any(|f| { f.id == folder && f.state == "idle" && f.progress.is_none() && f.last_sync >= since }) }) } fn conflict_copies(dir: &Path, stem: &str) -> Vec { let prefix = format!("{stem} (sync-conflict "); std::fs::read_dir(dir) .unwrap() .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with(&prefix)) }) .collect() } fn on_drive(drive: &FakeDrive, path: &str) -> Option> { match drive.tree().get(path) { Some(Entry::File(content)) => Some(content.to_vec()), _ => None, } } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_joining_mirror_keeps_a_same_size_different_file_as_a_conflict() { // A computer joining a folder another one syncs found a file of the // same size at the same path and took it for the same file, so the two // went on holding different content (B248). One that is the same is // still taken as it is. let drive = FakeDrive::new(); let dir = scratch("join-same-size"); let theirs: &[u8] = b"from the other computer"; let ours: &[u8] = b"written on this machine"; assert_eq!(theirs.len(), ours.len()); let daemon = { let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); std::fs::create_dir(daemon.mountpoint.join("mirrored")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("mirrored").is_some() })); daemon }; drive.device().write("mirrored/notes.txt", theirs); drive.device().write("mirrored/same.txt", b"identical"); let remote = drive.lookup("mirrored").unwrap(); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let local = dir.join("local"); std::fs::create_dir_all(&local).unwrap(); std::fs::write(local.join("notes.txt"), ours).unwrap(); std::fs::write(local.join("same.txt"), b"identical").unwrap(); reopen(&dir) .sync_folder_add(local.to_str().unwrap(), &remote.to_string(), "") .unwrap(); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); assert!(wait_until(Duration::from_secs(60), || { conflict_copies(&local, "notes").len() == 1 && std::fs::read(local.join("notes.txt")).is_ok_and(|b| b == theirs) && drive .tree() .keys() .any(|k| k.starts_with("mirrored/notes (sync-conflict ")) })); let copy = &conflict_copies(&local, "notes")[0]; assert_eq!(std::fs::read(copy).unwrap(), ours); assert_eq!(on_drive(&drive, "mirrored/notes.txt").unwrap(), theirs); assert!(conflict_copies(&local, "same").is_empty()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_mirror_edit_does_not_replace_a_revision_another_computer_made_since() { // A local edit went up as a new revision of whatever Drive held at the // time of the upload, from a plan made before another computer saved // its own edit. Its edit was left only in the version history (B249). let drive = FakeDrive::new(); let dir = scratch("mirror-revision-race"); let (daemon, folder, client) = start_mirror(&drive, &dir, &[("a.txt", b"one"), ("b.txt", b"two")]); let local = dir.join("local"); assert!(wait_until(Duration::from_secs(30), || { drive.lookup("mirrored/a.txt").is_some() && drive.lookup("mirrored/b.txt").is_some() })); assert!(wait_for_pass(&daemon, folder, 0)); // The echoes of the first uploads read the files too. std::thread::sleep(Duration::from_secs(2)); let a = drive.lookup("mirrored/a.txt").unwrap(); let check = client.hold_next_read(&a); std::fs::write(local.join("a.txt"), b"edited here").unwrap(); assert!(wait_until(Duration::from_secs(30), || check.reached())); drive .device() .write("mirrored/a.txt", b"edited on the other computer"); drop(check); assert!(wait_until(Duration::from_secs(30), || { take_logged() .iter() .any(|line| line.contains("changed on Drive since this pass looked")) })); daemon .request(&Request::SyncNow { id: Some(folder) }) .unwrap(); assert!(wait_until(Duration::from_secs(60), || { std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"edited on the other computer") && conflict_copies(&local, "a").len() == 1 && drive .tree() .keys() .any(|k| k.starts_with("mirrored/a (sync-conflict ")) })); assert_eq!( std::fs::read(&conflict_copies(&local, "a")[0]).unwrap(), b"edited here" ); assert_eq!( on_drive(&drive, "mirrored/a.txt").unwrap(), b"edited on the other computer" ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_revision_made_during_a_mirror_download_still_arrives() { // The baseline after a download read the node again, and recorded a // revision another computer made while the download ran as synced. // The disk held the older one, and the newer never arrived (B250). let drive = FakeDrive::new(); let dir = scratch("mirror-download-race"); let (daemon, folder, client) = start_mirror(&drive, &dir, &[("a.txt", b"one"), ("b.txt", b"two")]); let local = dir.join("local"); assert!(wait_until(Duration::from_secs(30), || { drive.lookup("mirrored/a.txt").is_some() && drive.lookup("mirrored/b.txt").is_some() })); assert!(wait_for_pass(&daemon, folder, 0)); let a = drive.lookup("mirrored/a.txt").unwrap(); drive.device().write("mirrored/a.txt", b"second"); let download = client.hold_next_download(&a); daemon .request(&Request::SyncNow { id: Some(folder) }) .unwrap(); assert!(wait_until(Duration::from_secs(30), || download.reached())); drive.device().write("mirrored/a.txt", b"thirdd"); drop(download); assert!(wait_until(Duration::from_secs(30), || { std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"second") })); assert!(wait_for_pass(&daemon, folder, 0)); daemon .request(&Request::SyncNow { id: Some(folder) }) .unwrap(); assert!(wait_until(Duration::from_secs(30), || { std::fs::read(local.join("a.txt")).is_ok_and(|b| b == b"thirdd") })); assert!(conflict_copies(&local, "a").is_empty()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_removed_with_its_folder_while_uploading_stays_removed() { // A folder removed while a file in it was on its way to Drive: the // folder's trash landed first, Drive refused the file's create, and // the drain took the folder for trashed elsewhere and made the deleted // file again in the root, failing only because its bytes were gone. // Its queued trash then re-read the folder it had been sent to (B168). let drive = FakeDrive::new(); let dir = scratch("removed-while-uploading"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); std::fs::create_dir(mnt.join("x")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("x").is_some() })); let folder = drive.lookup("x").unwrap(); let create = daemon.client.hold_next_create(); daemon .write_new(&mnt.join("x/removed-while-uploading"), b"abc") .unwrap(); assert!(wait_until(Duration::from_secs(30), || create.reached())); std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap(); std::fs::remove_dir(mnt.join("x")).unwrap(); assert!(wait_until(Duration::from_secs(30), || drive .lookup("x") .is_none())); drop(create); assert!(wait_until(Duration::from_secs(60), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); assert_eq!(drive.names_ever_in_root(), ["x"]); assert!(drive.tree().is_empty()); let rehomed = take_logged().into_iter().find(|line| { line.contains("creating in the root") && line.contains("removed-while-uploading") }); assert_eq!(rehomed, None); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let db = Db::open(&dir.join("pdfs.db")).unwrap(); assert!(db.node_by_uid(&folder.to_string()).unwrap().is_none()); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_folder_made_again_while_the_old_one_is_trashed_is_a_new_folder() { // A folder removed and made again at once: the new one's create met // the old folder, its trash still on the wire, and took it for the // node an unanswered create of its own had made. What went into the // new folder went into the old one, and the trash took it all along // (B162). let drive = FakeDrive::new(); let dir = scratch("remade-while-trashed"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); std::fs::create_dir(mnt.join("z")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("z").is_some() })); let old = drive.lookup("z").unwrap(); let trash = daemon.client.hold_next_trash(&old); std::fs::remove_dir(mnt.join("z")).unwrap(); assert!(wait_until(Duration::from_secs(30), || trash.reached())); std::fs::create_dir(mnt.join("z")).unwrap(); std::fs::write(mnt.join("z/f"), b"abc").unwrap(); // Sent, and either waiting for the name or landed. assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| { !items .iter() .any(|op| op.kind == "mkdir" && op.attempts == 0) }) })); drop(trash); let file = Some(Entry::File(Arc::new(b"abc".to_vec()))); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("z/f").cloned() == file }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_ne!(drive.lookup("z"), Some(old)); assert_eq!(std::fs::read(mnt.join("z/f")).unwrap(), b"abc"); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn the_echo_of_our_own_rename_keeps_the_cached_bytes() { // The echo read as someone else's change and evicted the bytes, so a // partial write could not gap-fill, and the next write open on the // file was refused with EIO (B123). let drive = FakeDrive::new(); let dir = scratch("rename-echo"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let before = daemon.mountpoint.join("a.txt"); std::fs::write(&before, b"0123456789").unwrap(); assert!(wait_until(Duration::from_secs(30), || { drive.tree().contains_key("a.txt") })); assert_eq!(std::fs::read(&before).unwrap(), b"0123456789"); let after = daemon.mountpoint.join("b.txt"); std::fs::rename(&before, &after).unwrap(); // Long enough for the next event poll to bring the echo. std::thread::sleep(crate::POLL_INTERVAL + Duration::from_secs(3)); write_at(&after, 3, b"x").unwrap(); write_at(&after, 5, b"y").unwrap(); assert!( wait_until(Duration::from_secs(30), || { drive.tree().get("b.txt") == Some(&Entry::File(Arc::new(b"012x4y6789".to_vec()))) }), "both writes landed: {:?}", drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_rename_across_folders_ignores_the_new_name_in_the_old_folder() { // The rename went to Drive as a rename in the old folder and then a // move, so a sibling there with the new name failed it with EIO // (B122). let drive = FakeDrive::new(); let dir = scratch("rename-across"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mount = &daemon.mountpoint; std::fs::create_dir(mount.join("x")).unwrap(); std::fs::create_dir(mount.join("z")).unwrap(); std::fs::write(mount.join("x/f.txt"), b"moved").unwrap(); std::fs::write(mount.join("x/d"), b"sibling").unwrap(); std::fs::write(mount.join("z/d"), b"replaced").unwrap(); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); assert!(wait_until(Duration::from_secs(30), || { let tree = drive.tree(); [ ("x/f.txt", b"moved".as_slice()), ("x/d", b"sibling"), ("z/d", b"replaced"), ] .iter() .all(|(path, bytes)| tree.get(*path).cloned() == file(bytes)) })); std::fs::rename(mount.join("x/f.txt"), mount.join("z/d")).unwrap(); assert_eq!(std::fs::read(mount.join("z/d")).unwrap(), b"moved"); // The rename is recorded locally and sent from the queue. assert!( wait_until(Duration::from_secs(30), || { let tree = drive.tree(); tree.get("z/d").cloned() == file(b"moved") && tree.get("x/d").cloned() == file(b"sibling") && !tree.contains_key("x/f.txt") }), "{:?}", drive.tree().keys().collect::>() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_write_over_a_create_whose_read_back_failed_is_not_a_conflict() { // The create landed and the link went down before it was read back. // The next write was based on the file as it was made locally, and // landed as a conflict copy of it (B143). let drive = FakeDrive::new(); let dir = scratch("unadopted"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let path = daemon.mountpoint.join("f.txt"); // Drive stamps a create with the second it arrives, so it has to land // a second after the file was made here for the two to differ. daemon.client.set_online(false); std::fs::write(&path, b"first").unwrap(); std::thread::sleep(Duration::from_millis(1100)); daemon.client.drop_link_after_next_create(); daemon.client.set_online(true); assert!(wait_until(Duration::from_secs(30), || { drive.tree().get("f.txt").cloned() == file(b"first") })); write_at(&path, 5, b" and second").unwrap(); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(30), || { drive.tree().get("f.txt").cloned() == file(b"first and second") }), "{:?}", drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_write_after_a_restart_over_a_create_never_read_back_is_not_a_conflict() { // The create landed and the link went down before it was read back, // then the daemon restarted. What was to read it back did not outlive // the restart, so the next write was based on the file as it was made // here and landed as a conflict copy of it (B182). let drive = FakeDrive::new(); let dir = scratch("unadopted-restart"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let path = daemon.mountpoint.join("f.txt"); // Drive stamps a create with the second it arrives, so it has to land // a second after the file was made here for the two to differ. daemon.client.set_online(false); std::fs::write(&path, b"first").unwrap(); std::thread::sleep(Duration::from_millis(1100)); daemon.client.drop_link_after_next_create(); daemon.client.set_online(true); assert!(wait_until(Duration::from_secs(30), || { drive.tree().get("f.txt").cloned() == file(b"first") && daemon.pending().is_ok_and(|items| items.is_empty()) })); let daemon = daemon.restart().unwrap(); let path = daemon.mountpoint.join("f.txt"); write_at(&path, 5, b" and second").unwrap(); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(30), || { drive.tree().get("f.txt").cloned() == file(b"first and second") }), "{:?}", drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_create_whose_answer_was_lost_lands_once() { // Drive made the file, but its answer was lost. The retry found the // name taken by a node holding the file's bytes, which was not empty // and so not adopted, and uploaded the file again as a conflict copy // (B127). let drive = FakeDrive::new(); let dir = scratch("lost-create-reply"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); daemon.client.lose_reply_to_next_create(); daemon .write_new(&daemon.mountpoint.join("f.txt"), b"once") .unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"once") }), "{:?}", drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_written_again_after_its_create_lost_its_answer_lands_once() { // Drive made the file, but its answer was lost, and the file was // written again before the retry. The retry found the name taken by a // node holding the bytes the first attempt sent, not the ones the op // held now, and uploaded the file as a conflict copy (B156). let drive = FakeDrive::new(); let dir = scratch("lost-create-reply-rewritten"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let held = daemon.client.hold_reply_to_next_create(); daemon.client.lose_reply_to_next_create(); daemon.write_new(&path, b"first").unwrap(); assert!(wait_until(Duration::from_secs(30), || held.reached())); std::fs::write(&path, b"second").unwrap(); drop(held); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"second") }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert_eq!(std::fs::read(&path).unwrap(), b"second"); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_deleted_after_its_create_lost_its_answer_leaves_drive() { // Drive made the file, but its answer was lost, and the file was // deleted before the retry. The delete dropped the queued create and // left the node Drive had made in place (B151). let drive = FakeDrive::new(); let dir = scratch("lost-create-reply-deleted"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); daemon.client.lose_reply_to_next_create(); daemon.write_new(&path, b"gone").unwrap(); assert!(wait_until(Duration::from_secs(30), || { drive.tree().contains_key("f.txt") })); std::fs::remove_file(&path).unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty() }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(!path.exists()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_deleted_after_a_restart_leaves_drive_with_what_its_create_sent() { // Drive made the file with the bytes its create sent, but the answer // was lost, and the file was written again. After a restart the file // was deleted. The trash knew only the bytes the file held last, which // were not the ones Drive had, so it left that file on Drive (B183). let drive = FakeDrive::new(); let dir = scratch("lost-create-reply-restart-deleted"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); let held = daemon.client.hold_reply_to_next_create(); daemon.client.lose_reply_to_next_create(); daemon.write_new(&path, b"first").unwrap(); assert!(wait_until(Duration::from_secs(30), || held.reached())); std::fs::write(&path, b"second").unwrap(); // The kernel sends the release after `close` returns. Attaching the // bytes resets the create's attempts, so a release that came after the // lost reply had it retried at once, and the retry landed before // anything here saw it fail. The bytes it replaced go only once they // are attached. let staged = dir.join("cache").join("staging"); assert!(wait_until(Duration::from_secs(30), || { let blobs: Vec> = std::fs::read_dir(&staged) .into_iter() .flatten() .filter_map(|e| e.ok()) .filter_map(|e| std::fs::read(e.path()).ok()) .collect(); blobs.iter().any(|b| b == b"second") && !blobs.iter().any(|b| b == b"first") })); drop(held); assert!(wait_until(Duration::from_secs(30), || { daemon .pending() .is_ok_and(|items| items.iter().any(|item| item.attempts > 0)) })); daemon.client.set_online(false); let daemon = daemon.restart().unwrap(); let path = daemon.mountpoint.join("f.txt"); std::fs::remove_file(&path).unwrap(); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty() }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(!path.exists()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_renamed_after_its_create_lost_its_answer_lands_once() { // Drive made the file, but the answer was lost, and the file was // renamed before the retry. The retry looked for the file Drive made // under the new name only, and made a second one there (B184). let drive = FakeDrive::new(); let dir = scratch("lost-create-reply-renamed"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); daemon.client.lose_reply_to_next_create(); daemon .write_new(&daemon.mountpoint.join("f.txt"), b"first") .unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon .pending() .is_ok_and(|items| items.iter().any(|item| item.attempts > 0)) })); daemon.client.set_online(false); std::fs::rename( daemon.mountpoint.join("f.txt"), daemon.mountpoint.join("g.txt"), ) .unwrap(); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("g.txt").cloned() == file(b"first") }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_renamed_and_deleted_after_its_create_lost_its_answer_leaves_nothing() { // As above, and the file was deleted after the rename. The trash // looked for the file Drive made under the last name only, and left // it on Drive under the first (B184). let drive = FakeDrive::new(); let dir = scratch("lost-create-reply-renamed-deleted"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); daemon.client.lose_reply_to_next_create(); daemon .write_new(&daemon.mountpoint.join("f.txt"), b"first") .unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon .pending() .is_ok_and(|items| items.iter().any(|item| item.attempts > 0)) })); daemon.client.set_online(false); std::fs::rename( daemon.mountpoint.join("f.txt"), daemon.mountpoint.join("g.txt"), ) .unwrap(); std::fs::remove_file(daemon.mountpoint.join("g.txt")).unwrap(); daemon.client.set_online(true); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().is_empty() }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_withdrawn_create_leaves_the_file_renamed_to_the_name_it_was_sent_under() { // A create was sent as d and refused, the file renamed to q, and an // empty file of the user's renamed to d. Deleting q left a trash that // looked for what the create may have made under d, took the user's // empty file for it and trashed it (B193). let drive = FakeDrive::new(); let dir = scratch("withdrawn-create-renamed-over"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mnt = daemon.mountpoint.clone(); let empty = Some(Entry::File(Arc::new(Vec::new()))); std::fs::write(mnt.join("e.md"), b"").unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("e.md").cloned() == empty }), "{:?} {:?}", daemon.pending(), drive.tree() ); drive.set_quota(0); std::fs::write(mnt.join("d"), b"refused").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon .pending() .is_ok_and(|items| items.iter().any(|item| item.attempts > 0)) })); std::fs::rename(mnt.join("d"), mnt.join("q")).unwrap(); std::fs::rename(mnt.join("e.md"), mnt.join("d")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { drive.tree().get("d").cloned() == empty })); std::fs::remove_file(mnt.join("q")).unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) }), "{:?}", daemon.pending() ); let tree = drive.tree(); assert_eq!(tree.get("d").cloned(), empty, "{tree:?}"); assert_eq!(tree.len(), 1, "{tree:?}"); assert_eq!(std::fs::read(mnt.join("d")).unwrap(), b""); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_made_under_the_name_of_a_create_on_the_wire_waits_for_it() { // A file renamed while its create was on the wire, and a new one made // under its old name. The new file's create found the name held by the // first and woke itself again after every try: it spent its waits in // milliseconds and landed under a conflict name, which the mount then // showed instead of the user's (B154). let drive = FakeDrive::new(); let dir = scratch("create-waits-for-create"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mount = &daemon.mountpoint; let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); let held = daemon.client.hold_reply_to_next_create(); daemon.write_new(&mount.join("c.bin"), b"first").unwrap(); assert!(wait_until(Duration::from_secs(30), || held.reached())); std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap(); std::fs::write(mount.join("c.bin"), b"second").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon .pending() .is_ok_and(|items| items.iter().any(|op| op.attempts > 0)) })); // Long enough for the waits to run out, were they not waits. std::thread::sleep(Duration::from_millis(300)); drop(held); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("d").cloned() == file(b"first") && drive.tree().get("c.bin").cloned() == file(b"second") }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_eq!(drive.tree().len(), 2, "{:?}", drive.tree()); assert_eq!(std::fs::read(mount.join("c.bin")).unwrap(), b"second"); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_write_whose_answer_was_lost_lands_once() { // Drive made the revision, but its answer was lost. The retry found // the file at a revision it did not know and kept the write as a // conflict copy of itself (B155). let drive = FakeDrive::new(); let dir = scratch("lost-revision-reply"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let path = daemon.mountpoint.join("f.txt"); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); std::fs::write(&path, b"first").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"first") })); daemon.client.lose_reply_to_next_revision(); std::fs::write(&path, b"second").unwrap(); assert!( wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.tree().get("f.txt").cloned() == file(b"second") }), "{:?} {:?}", daemon.pending(), drive.tree() ); assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes() { let drive = FakeDrive::new(); let dir = scratch("pdfs-copy"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mount = &daemon.mountpoint; std::fs::create_dir_all(mount.join("src/inner")).unwrap(); std::fs::create_dir_all(mount.join("dest")).unwrap(); std::fs::write(mount.join("src/a.txt"), b"alpha").unwrap(); std::fs::write(mount.join("src/inner/b.txt"), b"beta").unwrap(); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); assert!(wait_until(Duration::from_secs(30), || { drive.tree().get("src/inner/b.txt").cloned() == file(b"beta") && daemon.pending().is_ok_and(|ops| ops.is_empty()) })); let path = |rel: &str| mount.join(rel).to_string_lossy().into_owned(); let copy = |from: &str, to: &str| { daemon.request(&Request::Copy { path: path(from), new_parent: path(to), }) }; let uploads = drive.tree().len(); copy("src/a.txt", "dest").unwrap(); // A name already taken gets a suffix rather than an error. copy("src/a.txt", "dest").unwrap(); copy("src", "dest").unwrap(); assert_eq!(std::fs::read(mount.join("dest/a.txt")).unwrap(), b"alpha"); assert_eq!( std::fs::read(mount.join("dest/a (1).txt")).unwrap(), b"alpha" ); assert_eq!( std::fs::read(mount.join("dest/src/inner/b.txt")).unwrap(), b"beta" ); let tree = drive.tree(); assert_eq!(tree.get("dest/src/a.txt").cloned(), file(b"alpha")); assert_eq!( tree.len(), uploads + 6, "{:?}", tree.keys().collect::>() ); // Drive would copy what it has, not the bytes still queued. let paused = |paused| Request::SetSyncPaused { paused, until: None, }; daemon.request(&paused(true)).unwrap(); std::fs::write(mount.join("src/a.txt"), b"alpha, edited").unwrap(); match copy("src/a.txt", "dest") { Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"), Ok(answer) => panic!("the copy went ahead: {answer:?}"), } match copy("src", "dest") { Err(e) => assert!(e.to_string().contains("not reached Proton Drive"), "{e}"), Ok(answer) => panic!("the folder copy went ahead: {answer:?}"), } daemon.request(&paused(false)).unwrap(); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_pdfs_rename_after_a_queued_rename_is_not_undone() { // `pdfs rename` and `pdfs move` went straight to Drive, and the // mount's own rename, still queued, landed after them and put the old // name back (B149, the account run's B121 case). let drive = FakeDrive::new(); let dir = scratch("pdfs-rename-after-queued"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mount = &daemon.mountpoint; std::fs::create_dir_all(mount.join("b/inner")).unwrap(); std::fs::write(mount.join("b/first.txt"), b"moved").unwrap(); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); assert!(wait_until(Duration::from_secs(30), || { drive.tree().get("b/first.txt").cloned() == file(b"moved") })); let paused = |paused| Request::SetSyncPaused { paused, until: None, }; daemon.request(&paused(true)).unwrap(); std::fs::rename(mount.join("b/first.txt"), mount.join("b/back.txt")).unwrap(); let path = |rel: &str| mount.join(rel).to_string_lossy().into_owned(); daemon .request(&Request::Rename { path: path("b/back.txt"), new_name: "first.txt".into(), }) .unwrap(); daemon .request(&Request::Move { path: path("b/first.txt"), new_parent: path("b/inner"), }) .unwrap(); assert_eq!( std::fs::read(mount.join("b/inner/first.txt")).unwrap(), b"moved" ); daemon.request(&paused(false)).unwrap(); assert!( wait_until(Duration::from_secs(30), || { let tree = drive.tree(); tree.get("b/inner/first.txt").cloned() == file(b"moved") && daemon.pending().is_ok_and(|ops| ops.is_empty()) }), "{:?}", drive.tree().keys().collect::>() ); let tree = drive.tree(); assert!(!tree.contains_key("b/back.txt") && !tree.contains_key("b/inner/back.txt")); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_queued_write_whose_folder_was_trashed_lands_in_the_root() { // The conflict copy of a write to a file trashed elsewhere went into // the file's folder. With that folder gone too, the upload failed on // every retry, every two minutes (B150). let drive = FakeDrive::new(); let dir = scratch("conflict-copy-folder-gone"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mount = &daemon.mountpoint; std::fs::create_dir(mount.join("d")).unwrap(); std::fs::write(mount.join("d/f.txt"), b"first").unwrap(); assert!(wait_until(Duration::from_secs(30), || { drive.tree().contains_key("d/f.txt") && daemon.pending().is_ok_and(|ops| ops.is_empty()) })); let paused = |paused| Request::SetSyncPaused { paused, until: None, }; daemon.request(&paused(true)).unwrap(); std::fs::write(mount.join("d/f.txt"), b"second").unwrap(); drive.device().trash("d/f.txt"); drive.device().trash("d"); daemon.request(&paused(false)).unwrap(); let copy = |tree: &BTreeMap| { tree.iter() .find(|(path, _)| path.starts_with("f (sync-conflict ")) .map(|(_, entry)| entry.clone()) }; assert!( wait_until(Duration::from_secs(30), || { copy(&drive.tree()) == Some(Entry::File(Arc::new(b"second".to_vec()))) && daemon.pending().is_ok_and(|ops| ops.is_empty()) }), "{:?} {:?}", drive.tree().keys().collect::>(), daemon.pending() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } /// Make 100 folders of 10 empty files each through a mount on `faults`, and /// time the syscalls and then the drain until Drive holds all of it. fn time_a_thousand_files(faults: Faults) -> (Duration, Duration) { let drive = FakeDrive::new(); let dir = scratch("thousand"); let daemon = Daemon::start(&dir, drive.client(1, faults)).unwrap(); let start = Instant::now(); for folder in 0..100 { let folder = daemon.mountpoint.join(format!("d{folder:03}")); std::fs::create_dir(&folder).unwrap(); for file in 0..10 { std::fs::File::create(folder.join(format!("f{file}"))).unwrap(); } } let syscalls = start.elapsed(); let start = Instant::now(); assert!( wait_until(Duration::from_secs(1800), || { drive.tree().len() == 1100 && daemon.pending().is_ok_and(|ops| ops.is_empty()) }), "the drain did not finish: {} of 1100 on Drive", drive.tree().len() ); let drain = start.elapsed(); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); (syscalls, drain) } /// The milestone's speed target: a thousand files reach Drive as fast on /// Wi-Fi as on a LAN. It only measures for now, and takes minutes, so it /// runs when `PDFS_SIM_MEASURE` is set. #[test] #[ignore = "mounts FUSE: run with `PDFS_SIM_MEASURE=1 cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_thousand_files_drain_as_fast_on_wifi_as_on_lan() { if std::env::var_os("PDFS_SIM_MEASURE").is_none() { eprintln!("skipped: set PDFS_SIM_MEASURE to measure the drain"); return; } let (lan_syscalls, lan_drain) = time_a_thousand_files(Faults::lan()); let (wifi_syscalls, wifi_drain) = time_a_thousand_files(Faults::wifi()); eprintln!("lan: syscalls {lan_syscalls:?}, drain {lan_drain:?}"); eprintln!("wifi: syscalls {wifi_syscalls:?}, drain {wifi_drain:?}"); } /// `copy_file_range` inside the mount is answered from the bytes the daemon /// holds, and left to the kernel for bytes it would have to download. Every /// source kind has to come out byte for byte: cached, queued, open with /// unsaved writes, and on Drive only. #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_copy_inside_the_mount_has_the_source_bytes() { use std::os::fd::AsRawFd as _; use std::os::unix::fs::FileExt as _; fn copy_range(from: &std::fs::File, at: u64, to: &std::fs::File, to_at: u64, len: usize) { let (mut at, mut to_at) = (at as i64, to_at as i64); let mut left = len; while left > 0 { // SAFETY: both descriptors are open; the offsets are ours. let n = unsafe { libc::copy_file_range( from.as_raw_fd(), &mut at, to.as_raw_fd(), &mut to_at, left, 0, ) }; assert!(n > 0, "copy_file_range: {}", io::Error::last_os_error()); left -= n as usize; } } let pattern = |len: usize, seed: u8| -> Vec { (0..len).map(|i| (i % 253) as u8 ^ seed).collect() }; let drive = FakeDrive::new(); // On Drive before the mount knows it, so nothing of it is cached. let remote = pattern(9 << 20, 1); drive.device().write("remote.bin", &remote); let dir = scratch("copy-range"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mount = &daemon.mountpoint; // Downloaded through the kernel's own copy, then served from the cache. std::fs::copy(mount.join("remote.bin"), mount.join("remote-1.bin")).unwrap(); assert!(std::fs::read(mount.join("remote-1.bin")).unwrap() == remote); std::fs::copy(mount.join("remote.bin"), mount.join("remote-2.bin")).unwrap(); assert!(std::fs::read(mount.join("remote-2.bin")).unwrap() == remote); // Queued and not on Drive yet: the staged blob is the content. let paused = |paused| Request::SetSyncPaused { paused, until: None, }; daemon.request(&paused(true)).unwrap(); let queued = pattern(20 << 20, 2); std::fs::write(mount.join("queued.bin"), &queued).unwrap(); std::fs::copy(mount.join("queued.bin"), mount.join("queued-copy.bin")).unwrap(); assert!(std::fs::read(mount.join("queued-copy.bin")).unwrap() == queued); // Open with writes not saved yet: the copy sees them, as a read would. let open = std::fs::OpenOptions::new() .read(true) .write(true) .open(mount.join("queued.bin")) .unwrap(); open.write_all_at(b"UNSAVED", 100).unwrap(); let target = std::fs::File::create(mount.join("unsaved-copy.bin")).unwrap(); copy_range(&open, 96, &target, 0, 16); drop(target); drop(open); let mut expected = queued[96..112].to_vec(); expected[4..11].copy_from_slice(b"UNSAVED"); assert_eq!( std::fs::read(mount.join("unsaved-copy.bin")).unwrap(), expected ); // A range into the middle of a file keeps the bytes around it. std::fs::write(mount.join("middle.bin"), b"0123456789").unwrap(); let source = std::fs::File::open(mount.join("remote.bin")).unwrap(); let target = std::fs::OpenOptions::new() .write(true) .open(mount.join("middle.bin")) .unwrap(); copy_range(&source, 5, &target, 3, 4); drop(target); let mut expected = b"0123456789".to_vec(); expected[3..7].copy_from_slice(&remote[5..9]); assert_eq!(std::fs::read(mount.join("middle.bin")).unwrap(), expected); daemon.request(&paused(false)).unwrap(); let mut edited = queued.clone(); edited[100..107].copy_from_slice(b"UNSAVED"); let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec()))); assert!(wait_until(Duration::from_secs(120), || { let tree = drive.tree(); daemon.pending().is_ok_and(|ops| ops.is_empty()) && tree.get("queued.bin").cloned() == file(&edited) && tree.get("queued-copy.bin").cloned() == file(&queued) && tree.get("remote-2.bin").cloned() == file(&remote) && tree.get("middle.bin").cloned() == file(&expected) })); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } /// How long `cp` inside the mount takes for a file the daemon already /// holds, cold and from the page cache. It decides whether the daemon /// should answer `copy_file_range` itself rather than leave the kernel to /// copy through read and write. Runs when `PDFS_SIM_MEASURE` is set. #[test] #[ignore = "mounts FUSE: run with `PDFS_SIM_MEASURE=1 cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_copy_inside_the_mount_measures_its_speed() { if std::env::var_os("PDFS_SIM_MEASURE").is_none() { eprintln!("skipped: set PDFS_SIM_MEASURE to measure the copy"); return; } const SIZE: usize = 256 << 20; let drive = FakeDrive::new(); let dir = scratch("copy-speed"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let mount = &daemon.mountpoint; let bytes: Vec = (0..SIZE).map(|i| (i % 251) as u8).collect(); std::fs::write(mount.join("big.bin"), &bytes).unwrap(); assert!(wait_until(Duration::from_secs(120), || { daemon.pending().is_ok_and(|ops| ops.is_empty()) })); for (round, name) in ["copy-1.bin", "copy-2.bin", "copy-3.bin"] .into_iter() .enumerate() { let start = Instant::now(); let status = std::process::Command::new("cp") .arg(mount.join("big.bin")) .arg(mount.join(name)) .status() .unwrap(); let took = start.elapsed(); assert!(status.success()); let mib_per_s = (SIZE >> 20) as f64 / took.as_secs_f64(); eprintln!("cp round {round}: {took:?}, {mib_per_s:.0} MiB/s"); assert!(std::fs::read(mount.join(name)).unwrap() == bytes); } assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn half_of_a_wide_folder_unlinked_at_once_leaves_the_other_half() { // The acceptance suite's wide directory on Wi-Fi. A create that landed // while the folder's listing was on the wire was in neither Drive's // answer nor the queue read after it, and a file that was never // unlinked dropped out of the listing until the next one (B166). let drive = FakeDrive::new(); let dir = scratch("wide-unlink"); let daemon = Daemon::start(&dir, drive.client(1, Faults::wifi())).unwrap(); let root = daemon.mountpoint.join("wide"); std::fs::create_dir(&root).unwrap(); let names: Vec = (0..128).map(|i| format!("entry-{i:04}.txt")).collect(); let in_parallel = |op: &(dyn Fn(&str) + Sync), names: &[String]| { std::thread::scope(|scope| { for chunk in names.chunks(names.len().div_ceil(8)) { scope.spawn(move || chunk.iter().for_each(|name| op(name))); } }); }; in_parallel( &|name| { let mut file = std::fs::File::create(root.join(name)).unwrap(); std::io::Write::write_all(&mut file, name.as_bytes()).unwrap(); file.sync_all().unwrap(); }, &names, ); let listed = |root: &std::path::Path| { let mut names: Vec = std::fs::read_dir(root) .unwrap() .map(|e| e.unwrap().file_name().into_string().unwrap()) .collect(); names.sort(); names }; assert_eq!(listed(&root), names); in_parallel( &|name| std::fs::remove_file(root.join(name)).unwrap(), &names[..64], ); assert_eq!(listed(&root), names[64..]); let mut settled = None; while settled.is_none_or(|at: Instant| at.elapsed() < crate::POLL_INTERVAL * 2) { assert_eq!(listed(&root), names[64..]); if settled.is_none() && daemon.pending().is_ok_and(|ops| ops.is_empty()) { settled = Some(Instant::now()); } std::thread::sleep(Duration::from_millis(50)); } assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned() { let drive = FakeDrive::new(); let dir = scratch("pin-folder"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); std::fs::create_dir_all(daemon.mountpoint.join("Docs/sub")).unwrap(); std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap(); std::fs::write(daemon.mountpoint.join("Docs/sub/b.txt"), b"beta").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Docs/sub/b.txt").is_some() })); daemon.request(&Request::PurgeCache).unwrap(); let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir { path: folder.into(), }) { Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name), _ => None, }; assert!(entry("Docs", "a.txt").is_some_and(|e| !e.cached && !e.pinned)); daemon .request(&Request::Pin { path: "Docs".into(), }) .unwrap(); assert!( wait_until(Duration::from_secs(30), || { entry("Docs", "a.txt").is_some_and(|e| e.cached) && entry("Docs/sub", "b.txt").is_some_and(|e| e.cached) }), "the folder's files were downloaded in the background" ); let a = entry("Docs", "a.txt").unwrap(); assert!(a.pinned && a.pinned_by_folder); assert!(entry("", "Docs").is_some_and(|e| e.pinned && !e.pinned_by_folder)); assert!( daemon .request(&Request::Unpin { path: "Docs/a.txt".into(), }) .is_err(), "a file kept through its folder cannot be unpinned on its own" ); daemon .request(&Request::Unpin { path: "Docs".into(), }) .unwrap(); let a = entry("Docs", "a.txt").unwrap(); assert!(!a.pinned && !a.pinned_by_folder && !a.cached); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_folder_pinned_while_a_create_in_it_lands_keeps_that_file() { // The pin's download of a file started before the file's landing set // its modification time from Drive, and ended after it: the blob was // stored under the old time, and the file never read as cached (B208). let drive = FakeDrive::new(); let dir = scratch("pin-folder-landing"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); let entry = || match daemon.request(&Request::ListDir { path: "Docs".into(), }) { Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == "a.txt"), _ => None, }; // Drive stamps the file with the time it landed, a second after it // was written, so the landing moves its modification time. let paused = |paused| Request::SetSyncPaused { paused, until: None, }; daemon.request(&paused(true)).unwrap(); std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap(); std::thread::sleep(Duration::from_millis(1100)); let landing = daemon.client.hold_next_landing_of_any(); daemon.request(&paused(false)).unwrap(); assert!(wait_until(Duration::from_secs(30), || landing.reached())); let real = drive.lookup("Docs/a.txt").unwrap(); let read_back = daemon.client.hold_next_read(&real); drop(landing); assert!(wait_until(Duration::from_secs(30), || read_back.reached())); let written = entry().unwrap().modified; // Without the blob the landing kept, the pin downloads the file. daemon.request(&Request::PurgeCache).unwrap(); let download = daemon.client.hold_next_download(&real); take_logged(); daemon .request(&Request::Pin { path: "Docs".into(), }) .unwrap(); assert!(wait_until(Duration::from_secs(30), || download.reached())); drop(read_back); assert!(wait_until(Duration::from_secs(30), || { entry().is_some_and(|e| e.modified != written) })); drop(download); let mut logged = Vec::new(); assert!( wait_until(Duration::from_secs(30), || { logged.extend(take_logged()); logged .iter() .any(|line| line.starts_with("pin folder: done")) }), "{logged:?}" ); assert!( entry().is_some_and(|e| e.cached), "the file kept through its folder is available offline: {:?}", entry() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] fn attaching_my_files_preserves_local_files_and_syncs_both_ways() { use std::os::unix::fs::MetadataExt; let drive = FakeDrive::new(); let dir = scratch("attach-my-files"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some() })); drive.device().write("Work/cloud.txt", b"from cloud"); drive.device().write("Work/common.txt", b"remote version"); drive.device().write("Work/identical.txt", b"same bytes"); let local = dir.join("local"); std::fs::create_dir(&local).unwrap(); std::fs::write(local.join("local.txt"), b"only on this computer").unwrap(); std::fs::write(local.join("common.txt"), b"local version").unwrap(); std::fs::write(local.join("identical.txt"), b"same bytes").unwrap(); daemon .request(&Request::AttachSyncFolder { local_path: local.to_str().unwrap().into(), remote_path: "Work".into(), }) .unwrap(); let folder = match daemon.request(&Request::ListSyncFolders).unwrap() { Response::SyncFolders { items } => { items .into_iter() .find(|f| f.local_path == local.to_str().unwrap()) .unwrap() .id } other => panic!("unexpected reply: {other:?}"), }; assert!(wait_until(Duration::from_secs(30), || { drive.lookup("Work/local.txt").is_some() && std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud") && std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version") && !conflict_copies(&local, "common").is_empty() })); assert_ne!( std::fs::metadata(&local).unwrap().dev(), std::fs::metadata(&daemon.mountpoint).unwrap().dev() ); assert!( conflict_copies(&local, "common") .iter() .any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version")) ); assert!( daemon .request(&Request::AttachSyncFolder { local_path: local.to_str().unwrap().into(), remote_path: "Work".into() }) .is_err() ); let duplicate = dir.join("duplicate"); std::fs::create_dir(&duplicate).unwrap(); assert!( daemon .request(&Request::AttachSyncFolder { local_path: duplicate.to_str().unwrap().into(), remote_path: "Work".into() }) .is_err() ); assert!( daemon .request(&Request::AttachSyncFolder { local_path: duplicate.to_str().unwrap().into(), remote_path: "../Work".into() }) .is_err() ); // Parent/child mappings, the account root and files cannot be attached. std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work/Sub").is_some() })); for remote in ["Work/Sub", ".", "Work/cloud.txt"] { assert!( daemon .request(&Request::AttachSyncFolder { local_path: duplicate.to_str().unwrap().into(), remote_path: remote.into(), }) .is_err() ); } // Local overlap and symlink aliases into the actual cloud mount are refused. let alias = dir.join("cloud-alias"); std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap(); std::fs::create_dir_all(local.join("Sub")).unwrap(); for path in [ local.join("Sub"), dir.clone(), daemon.mountpoint.join("Work"), alias, ] { let error = daemon .request(&Request::AttachSyncFolder { local_path: path.to_string_lossy().into_owned(), remote_path: "Work/Sub".into(), }) .unwrap_err() .to_string(); assert!(error.contains("overlaps"), "{error}"); } assert!(conflict_copies(&local, "identical").is_empty()); assert_eq!( std::fs::read(local.join("identical.txt")).unwrap(), b"same bytes" ); // Simultaneous requests cannot register two local trees for one cloud root. std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some() })); let candidate_a = dir.join("candidate-a"); let candidate_b = dir.join("candidate-b"); std::fs::create_dir(&candidate_a).unwrap(); std::fs::create_dir(&candidate_b).unwrap(); let outcomes = std::thread::scope(|scope| { let a = scope.spawn(|| { daemon .request(&Request::AttachSyncFolder { local_path: candidate_a.to_str().unwrap().into(), remote_path: "Other".into(), }) .map_err(|e| e.to_string()) }); let b = scope.spawn(|| { daemon .request(&Request::AttachSyncFolder { local_path: candidate_b.to_str().unwrap().into(), remote_path: "Other".into(), }) .map_err(|e| e.to_string()) }); [a.join().unwrap(), b.join().unwrap()] }); assert_eq!( outcomes.iter().filter(|result| result.is_ok()).count(), 1, "exactly one mapping may own a remote root: {outcomes:?}" ); drive .device() .write("Work/cloud.txt", b"new cloud revision"); daemon .request(&Request::SyncNow { id: Some(folder) }) .unwrap(); assert!(wait_until(Duration::from_secs(30), || std::fs::read( local.join("cloud.txt") ) .is_ok_and(|b| b == b"new cloud revision"))); std::fs::write(local.join("local.txt"), b"new local revision").unwrap(); daemon .request(&Request::SyncNow { id: Some(folder) }) .unwrap(); assert!(wait_until(Duration::from_secs(30), || drive .tree() .get("Work/local.txt") == Some(&Entry::File(Arc::new(b"new local revision".to_vec()))))); assert!(wait_until(Duration::from_secs(30), || listed_folder( &daemon, folder ) .is_some_and(|f| f.state == "idle"))); daemon .request(&Request::SetSyncFolderPaused { id: folder, paused: true, }) .unwrap(); let before = drive.tree(); for entry in std::fs::read_dir(&local).unwrap() { let path = entry.unwrap().path(); if path.is_dir() { std::fs::remove_dir_all(path).unwrap(); } else { std::fs::remove_file(path).unwrap(); } } daemon .request(&Request::SetSyncFolderPaused { id: folder, paused: false, }) .unwrap(); daemon .request(&Request::SyncNow { id: Some(folder) }) .unwrap(); assert!(wait_until(Duration::from_secs(30), || listed_folder( &daemon, folder ) .is_some_and(|f| f.state == "error"))); assert_eq!( drive.tree(), before, "the wipe guard must preserve the remote tree" ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let db = Db::open(&dir.join("pdfs.db")).unwrap(); assert_eq!( db.sync_folder_get(folder).unwrap().unwrap().remote_share_id, "my-files-share" ); drop(db); let _ = std::fs::remove_dir_all(&dir); } fn attached_my_files(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 { std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.drained() && drive.lookup("Work").is_some() })); std::fs::create_dir(local).unwrap(); daemon .request(&Request::AttachSyncFolder { local_path: local.to_string_lossy().into_owned(), remote_path: "Work".into(), }) .unwrap(); match daemon.request(&Request::ListSyncFolders).unwrap() { Response::SyncFolders { items } => { items .into_iter() .find(|f| f.local_path == local.to_string_lossy()) .unwrap() .id } other => panic!("unexpected reply: {other:?}"), } } #[test] #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] fn attached_my_files_views_preserve_concurrent_edits() { let drive = FakeDrive::new(); let dir = scratch("attach-views"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let local = dir.join("local"); let id = attached_my_files(&daemon, &drive, &local); let mounted = daemon.mountpoint.join("Work"); // Prime the mount listing before a mirror upload, to catch stale listings. assert_eq!(std::fs::read_dir(&mounted).unwrap().count(), 0); std::fs::write(local.join("mirror.txt"), b"mirror bytes").unwrap(); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); assert!( wait_until(Duration::from_secs(30), || { std::fs::read_dir(&mounted) .unwrap() .any(|e| e.unwrap().file_name() == "mirror.txt") && std::fs::read(mounted.join("mirror.txt")).is_ok_and(|b| b == b"mirror bytes") }), "mirror upload must reach the mounted view without a restart" ); std::fs::write(mounted.join("mount.txt"), b"mounted bytes").unwrap(); assert!(wait_until(Duration::from_secs(30), || daemon.drained())); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); assert!( wait_until(Duration::from_secs(30), || { std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mounted bytes") && listed_folder(&daemon, id).is_some_and(|f| f.state == "idle") }), "mounted writes must reach the mirror" ); daemon .request(&Request::SetSyncFolderPaused { id, paused: true }) .unwrap(); assert!(wait_until(Duration::from_secs(30), || { listed_folder(&daemon, id).is_some_and(|f| f.state != "syncing") })); std::fs::write(mounted.join("mount.txt"), b"mount changed").unwrap(); assert!(wait_until(Duration::from_secs(30), || daemon.drained())); std::fs::write(local.join("mount.txt"), b"local changed").unwrap(); daemon .request(&Request::SetSyncFolderPaused { id, paused: false }) .unwrap(); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); assert!( wait_until(Duration::from_secs(30), || { std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mount changed") && conflict_copies(&local, "mount") .iter() .any(|p| std::fs::read(p).is_ok_and(|b| b == b"local changed")) && drive.tree().iter().any(|(path, entry)| { path.starts_with("Work/mount (sync-conflict") && entry == &Entry::File(Arc::new(b"local changed".to_vec())) }) }), "both edits must survive the next pass" ); let error = daemon .request(&Request::SetSyncFolderMode { id, mode: "ondemand".into(), }) .unwrap_err() .to_string(); assert!(error.contains("only support mirror"), "{error}"); let error = daemon .request(&Request::RemoveSyncFolder { id, delete_remote: true, }) .unwrap_err() .to_string(); assert!(error.contains("cannot be deleted"), "{error}"); let folder = listed_folder(&daemon, id).unwrap(); assert_eq!(folder.kind, "attached"); assert_eq!(folder.mode, "mirror"); assert!(folder.pending_mode.is_none()); // Restore must not reclassify a My files root as a device backup, // which would make the device-only destructive operations available. let restore_path = dir.join("forged-device-restore"); daemon .request(&Request::RestoreSyncFolders { items: vec![control::RestoreItem { remote_uid: folder.remote_uid.clone(), local_path: restore_path.to_string_lossy().into_owned(), mode: "ondemand".into(), }], }) .unwrap(); assert!(wait_until(Duration::from_secs(30), || { matches!(daemon.request(&Request::ListActivity { limit: 100 }), Ok(Response::Activity { items }) if items.iter().any(|item| { !item.ok && item.detail.contains("not a folder under this device") })) })); assert!(!restore_path.exists()); match daemon.request(&Request::ListLocations).unwrap() { Response::Locations { items } => assert!(items.iter().any(|m| { matches!(m.kind, pdfs_core::mounts::MountKind::Attached { sync_folder_id } if sync_folder_id == id) })), other => panic!("unexpected reply: {other:?}"), } daemon .request(&Request::RemoveSyncFolder { id, delete_remote: false, }) .unwrap(); assert!(listed_folder(&daemon, id).is_none()); assert!(drive.lookup("Work/mount.txt").is_some()); assert_eq!( std::fs::read(local.join("mount.txt")).unwrap(), b"mount changed" ); daemon.stop().unwrap(); let _ = std::fs::remove_dir_all(dir); } #[test] #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] fn attached_my_files_trashed_root_keeps_local_files() { attached_root_loss_keeps_local_files(false); } #[test] #[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"] fn attached_my_files_root_moved_outside_my_files_keeps_local_files() { attached_root_loss_keeps_local_files(true); } fn attached_root_loss_keeps_local_files(move_outside: bool) { let drive = FakeDrive::new(); let dir = scratch("attach-root-gone"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let local = dir.join("local"); let id = attached_my_files(&daemon, &drive, &local); std::fs::write(local.join("keep.txt"), b"keep my bytes").unwrap(); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); assert!(wait_until(Duration::from_secs(30), || { drive.lookup("Work/keep.txt").is_some() && listed_folder(&daemon, id).is_some_and(|f| f.state == "idle") })); daemon .request(&Request::SetSyncFolderPaused { id, paused: true }) .unwrap(); if move_outside { use crate::drive::DriveApi; let rt = daemon.rt.as_ref().unwrap(); let device = rt .block_on( daemon .client .create_device("other computer", proton_drive_rs::DeviceType::Linux), ) .unwrap(); let root = drive.lookup("Work").unwrap(); rt.block_on(daemon.client.move_node(&root, &device.root_folder_uid)) .unwrap(); } else { drive.device().trash("Work"); } daemon .request(&Request::SetSyncFolderPaused { id, paused: false }) .unwrap(); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); assert!(wait_until(Duration::from_secs(30), || { listed_folder(&daemon, id).is_some_and(|f| f.state == "error") })); assert_eq!( std::fs::read(local.join("keep.txt")).unwrap(), b"keep my bytes" ); assert_eq!(std::fs::read_dir(&local).unwrap().count(), 1); let daemon = daemon.restart().unwrap(); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); assert!(wait_until(Duration::from_secs(30), || { listed_folder(&daemon, id).is_some_and(|f| f.state == "error") })); assert_eq!( std::fs::read(local.join("keep.txt")).unwrap(), b"keep my bytes" ); daemon.stop().unwrap(); let _ = std::fs::remove_dir_all(dir); } /// Register `local` as a synced folder in `mode` and wait until the daemon /// lists it that way, returning its id. fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 { std::fs::create_dir_all(local).unwrap(); let path = local.canonicalize().unwrap().to_string_lossy().into_owned(); daemon .request(&Request::AddSyncFolder { local_path: path.clone(), }) .unwrap(); let listed = |mode: &str| match daemon.request(&Request::ListSyncFolders) { Ok(Response::SyncFolders { items }) => items .into_iter() .find(|f| { f.local_path == path && f.mode == mode && f.pending_mode.is_none() && f.last_sync > 0 }) .map(|f| f.id), _ => None, }; assert!( wait_until(Duration::from_secs(30), || listed("mirror").is_some()), "{} was registered: {:?}", local.display(), daemon.request(&Request::ListSyncFolders) ); let id = listed("mirror").unwrap(); // Reads lag behind creates, so the remote folder the first pass made // is not readable at once; switching modes reads it. std::thread::sleep(Duration::from_millis(200)); if mode != "mirror" { daemon .request(&Request::SetSyncFolderMode { id, mode: mode.into(), }) .unwrap(); assert!( wait_until(Duration::from_secs(30), || listed(mode).is_some() && is_mounted(local)), "{} switched to {mode}: {:?}", local.display(), daemon.request(&Request::ListSyncFolders) ); } id } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing() { // The account run's "move refuses a file still being written" case let // a move from an on-demand synced folder into a mirror folder go ahead // while the file was open, so the write landed after the move. let drive = FakeDrive::new(); let dir = scratch("move-open-out-of-ondemand"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let ondemand = dir.join("ondemand"); let mirror = dir.join("mirror"); sync_folder(&daemon, &ondemand, "ondemand"); sync_folder(&daemon, &mirror, "mirror"); let source = ondemand.join("open"); std::fs::create_dir(&source).unwrap(); daemon.request(&Request::SyncNow { id: None }).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); // The account run opened the file a moment after its create landed: // the queue was already empty, and the tree still had the // placeholder uid it gave the file. let landing = daemon.client.hold_next_landing_of_any(); { let mut file = std::fs::File::create(source.join("open.txt")).unwrap(); std::io::Write::write_all(&mut file, b"before").unwrap(); file.sync_all().unwrap(); } assert!(wait_until(Duration::from_secs(30), || landing.reached())); let mut file = std::fs::OpenOptions::new() .append(true) .open(source.join("open.txt")) .unwrap(); std::io::Write::write_all(&mut file, b" and after").unwrap(); let moved = daemon.request(&Request::Move { path: source.to_string_lossy().into_owned(), new_parent: mirror .canonicalize() .unwrap() .to_string_lossy() .into_owned(), }); drop(file); drop(landing); match moved { Err(e) => assert!( e.to_string().contains("not uploaded yet"), "refused for another reason: {e}" ), Ok(answer) => panic!("the move went ahead: {answer:?}"), } assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() { // Keeping a file offline took only paths under the primary mount, so // the copy in an on-demand synced folder could not be kept, and a // program reading it there downloaded it every time (B281). let drive = FakeDrive::new(); let dir = scratch("pin-in-ondemand"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let ondemand = dir.join("ondemand"); sync_folder(&daemon, &ondemand, "ondemand"); let content = b"not a real png, but kept all the same".repeat(64); let file = ondemand.join("photo.png"); std::fs::write(&file, &content).unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) })); daemon.request(&Request::PurgeCache).unwrap(); let path = file.canonicalize().unwrap().to_string_lossy().into_owned(); let pinned = daemon .request(&Request::Pin { path: path.clone() }) .unwrap(); assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}"); let pins = || match daemon.request(&Request::ListPins) { Ok(Response::Pins { pins }) => pins, other => panic!("pins: {other:?}"), }; let listed = pins(); assert!( listed.iter().any(|pin| pin.path == path && pin.cached), "the pin opens by the path it was made through: {listed:?}" ); // With the link gone, only a kept copy can answer the read. daemon.client.set_online(false); assert_eq!(std::fs::read(&file).unwrap(), content); daemon.client.set_online(true); let unpinned = daemon .request(&Request::Unpin { path: path.clone() }) .unwrap(); assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}"); assert!(pins().is_empty()); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() { // A change from another device deleted a pinned file's copy and // nothing downloaded it again, and a file added to a pinned folder // elsewhere was never downloaded: both were listed as kept offline // and read only with a network (B282). let drive = FakeDrive::new(); let dir = scratch("pin-keeper"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap(); std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap(); std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap(); assert!(wait_until(Duration::from_secs(30), || { daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Docs/a.txt").is_some() && drive.lookup("solo.txt").is_some() })); daemon.request(&Request::PurgeCache).unwrap(); let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir { path: folder.into(), }) { Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name), _ => None, }; for path in ["Docs", "solo.txt"] { let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap(); assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}"); } assert!(wait_until(Duration::from_secs(30), || { entry("Docs", "a.txt").is_some_and(|e| e.cached) && entry("", "solo.txt").is_some_and(|e| e.cached) })); // Another device edits both files and adds one to the kept folder. drive.device().write("Docs/a.txt", b"second, and longer"); drive.device().write("solo.txt", b"alone, edited"); drive.device().write("Docs/new.txt", b"made elsewhere"); assert!( wait_until(Duration::from_secs(60), || { entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18) && entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13) && entry("Docs", "new.txt").is_some_and(|e| e.cached) }), "the keeper downloaded the new content: {:?} {:?} {:?}", entry("Docs", "a.txt"), entry("", "solo.txt"), entry("Docs", "new.txt"), ); // With the link gone, only the kept copies can answer. daemon.client.set_online(false); let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap(); assert_eq!(read("Docs/a.txt"), b"second, and longer"); assert_eq!(read("solo.txt"), b"alone, edited"); assert_eq!(read("Docs/new.txt"), b"made elsewhere"); daemon.client.set_online(true); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } /// The synced folder `id` as the daemon lists it. fn listed_folder(daemon: &Daemon, id: i64) -> Option { match daemon.request(&Request::ListSyncFolders) { Ok(Response::SyncFolders { items }) => items.into_iter().find(|f| f.id == id), _ => None, } } /// A mirror folder holding `a.txt` and `sub/b.txt`, synced to Drive, whose /// local copy is then deleted in full while the folder is paused, so no /// pass sees half of it. Waits for the pass that refuses to run (#46). fn folder_whose_local_copy_is_gone(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 { let id = sync_folder(daemon, local, "mirror"); std::fs::write(local.join("a.txt"), b"alpha").unwrap(); std::fs::create_dir(local.join("sub")).unwrap(); std::fs::write(local.join("sub/b.txt"), b"beta").unwrap(); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); let on_drive = |name: &str, content: &[u8]| { drive.tree().iter().any(|(path, entry)| { path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec())) }) }; assert!( wait_until(Duration::from_secs(30), || on_drive("a.txt", b"alpha") && on_drive("sub/b.txt", b"beta") && listed_folder(daemon, id).is_some_and(|f| f.state == "idle")), "the folder synced: {:?} {:?}", listed_folder(daemon, id), drive.tree().keys().collect::>() ); daemon .request(&Request::SetSyncFolderPaused { id, paused: true }) .unwrap(); std::fs::remove_file(local.join("a.txt")).unwrap(); std::fs::remove_dir_all(local.join("sub")).unwrap(); daemon .request(&Request::SetSyncFolderPaused { id, paused: false }) .unwrap(); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); assert!( wait_until(Duration::from_secs(30), || listed_folder(daemon, id) .is_some_and(|f| f.state == "error")), "the pass refused to trash everything: {:?}", listed_folder(daemon, id) ); assert!( on_drive("a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"), "nothing was trashed on Drive: {:?}", drive.tree().keys().collect::>() ); id } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_folder_whose_local_copy_is_gone_downloads_again_after_a_reset() { let drive = FakeDrive::new(); let dir = scratch("reset-gone-local-copy"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let local = dir.join("docs"); let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local); daemon.request(&Request::ResetSyncFolder { id }).unwrap(); let has = |rel: &str, content: &[u8]| { std::fs::read(local.join(rel)).is_ok_and(|bytes| bytes == content) }; assert!( wait_until(Duration::from_secs(30), || has("a.txt", b"alpha") && has("sub/b.txt", b"beta") && listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")), "the reset downloaded the folder again: {:?}", listed_folder(&daemon, id) ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_folder_whose_local_copy_is_gone_still_goes_on_demand() { let drive = FakeDrive::new(); let dir = scratch("ondemand-gone-local-copy"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let local = dir.join("docs"); let id = folder_whose_local_copy_is_gone(&daemon, &drive, &local); daemon .request(&Request::SetSyncFolderMode { id, mode: "ondemand".into(), }) .unwrap(); assert!( wait_until(Duration::from_secs(30), || listed_folder(&daemon, id) .is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none()) && is_mounted(&local)), "the queued switch landed: {:?}", listed_folder(&daemon, id) ); assert_eq!(std::fs::read(local.join("a.txt")).unwrap(), b"alpha"); assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta"); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } /// Emptied before any pass saw it, the folder is still `idle`, and the /// switch was refused for good: every path read as "deleted here but not on /// Proton Drive yet" (B255, found by the account run). #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_folder_emptied_before_any_pass_still_goes_on_demand() { let drive = FakeDrive::new(); let dir = scratch("ondemand-emptied-idle"); let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap(); let local = dir.join("docs"); let id = sync_folder(&daemon, &local, "mirror"); std::fs::create_dir(local.join("sub")).unwrap(); std::fs::write(local.join("sub/a.txt"), b"alpha").unwrap(); std::fs::write(local.join("sub/b.txt"), b"beta").unwrap(); daemon.request(&Request::SyncNow { id: Some(id) }).unwrap(); let on_drive = |name: &str, content: &[u8]| { drive.tree().iter().any(|(path, entry)| { path.ends_with(name) && *entry == Entry::File(Arc::new(content.to_vec())) }) }; assert!( wait_until(Duration::from_secs(30), || on_drive("sub/a.txt", b"alpha") && on_drive("sub/b.txt", b"beta") && listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")), "the folder synced: {:?}", listed_folder(&daemon, id) ); // In one rename, so no pass sees half of it, and asked at once, well // inside the watcher's debounce, so no pass sees it empty either. std::fs::rename(local.join("sub"), dir.join("moved-away")).unwrap(); let asked = daemon.request(&Request::SetSyncFolderMode { id, mode: "ondemand".into(), }); assert!( matches!(asked, Ok(Response::Ok { .. })), "the switch was refused: {asked:?}" ); assert!( listed_folder(&daemon, id) .is_some_and(|f| f.pending_mode.as_deref() == Some("ondemand")), "the switch waits for a pass: {:?}", listed_folder(&daemon, id) ); assert!( wait_until(Duration::from_secs(30), || listed_folder(&daemon, id) .is_some_and(|f| f.mode == "ondemand" && f.pending_mode.is_none()) && is_mounted(&local)), "the queued switch landed: {:?}", listed_folder(&daemon, id) ); assert_eq!(std::fs::read(local.join("sub/a.txt")).unwrap(), b"alpha"); assert_eq!(std::fs::read(local.join("sub/b.txt")).unwrap(), b"beta"); assert!( on_drive("sub/a.txt", b"alpha") && on_drive("sub/b.txt", b"beta"), "nothing was trashed on Drive: {:?}", drive.tree().keys().collect::>() ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } /// A video in My files gets a thumbnail from a frame of its own, but a /// recording can be gigabytes: only its start, and an MP4's index, are /// downloaded for it. A video with a thumbnail from its uploader is not /// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail. #[test] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] fn a_video_thumbnail_downloads_a_small_part_of_the_video() { if std::process::Command::new("ffmpeg") .arg("-version") .output() .is_err() { eprintln!("skipped: needs ffmpeg"); return; } let dir = scratch("video-thumbs"); std::fs::create_dir_all(&dir).unwrap(); // Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole. // An MP4 written this way has its index at the end. let encode = |name: &str| { let path = dir.join(name); let status = std::process::Command::new("ffmpeg") .args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"]) .args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"]) .args(["-c:v", "libx264", "-preset", "ultrafast"]) .args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"]) .arg(&path) .status() .unwrap(); assert!(status.success()); let bytes = std::fs::read(&path).unwrap(); std::fs::remove_file(&path).unwrap(); assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len()); bytes }; let (recording, clip) = (encode("recording.ts"), encode("clip.mp4")); let source = b"export const answer = 42;\n".repeat(64 * 1024); let drive = FakeDrive::new(); drive.device().write("recording.ts", &recording); drive.device().write("clip.mp4", &clip); drive.device().write("app.ts", &source); let phone = drive.device().write("phone.mp4", &clip); let mut frame = Vec::new(); image::codecs::jpeg::JpegEncoder::new(&mut frame) .encode_image(&image::RgbImage::new(320, 180)) .unwrap(); let client = drive.client(1, Faults::lan()); client.set_thumbnail(&phone, frame); let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap(); let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir { path: String::new(), }) else { panic!("listing"); }; let items: Vec = entries .iter() .filter(|entry| !entry.is_dir) .map(|entry| control::FileThumbRequest { uid: entry.uid.clone(), modified: entry.modified, name: entry.name.clone(), }) .collect(); let Ok(Response::FileThumbGeneration { generation }) = daemon.request(&Request::ReserveFileThumbGeneration) else { panic!("generation"); }; let read_before = client.content_read(); let mut paths = std::collections::HashMap::new(); let settled = wait_until(Duration::from_secs(120), || { let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs { items: items.clone(), generation, }) else { return false; }; paths = replies .iter() .filter_map(|reply| { let name = &items.iter().find(|item| item.uid == reply.uid)?.name; Some((name.clone(), (reply.path.clone(), reply.pending))) }) .collect(); paths.values().all(|(_, pending)| !pending) && paths.len() == 4 }); assert!(settled, "{paths:?}"); let read = client.content_read() - read_before; for name in ["recording.ts", "clip.mp4", "phone.mp4"] { assert!(paths[name].0.is_some(), "{name} has a thumbnail: {paths:?}"); } assert_eq!(paths["app.ts"].0, None, "TypeScript is no video"); // Two 16 MiB heads, one MP4 index and the TypeScript file, and nothing // of the video that came with a thumbnail. assert!( read < 34 << 20, "downloaded {} MiB for three thumbnails", read >> 20 ); assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown))); let _ = std::fs::remove_dir_all(&dir); } }