diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 476b4e0..3f660c0 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -157,7 +157,7 @@ impl Core { pub(crate) fn list_devices(&self) -> CoreResult> { let devices = self .rt - .block_on(self.client.enumerate_devices()) + .block_on(self.drive.enumerate_devices()) .map_err(|e| CoreError::from_api(&e, "list devices"))?; // No cached device row yet means this machine syncs nothing, so none of // the listed devices is ours. @@ -214,7 +214,7 @@ impl Core { }; let remote = self .rt - .block_on(self.client.enumerate_devices()) + .block_on(self.drive.enumerate_devices()) .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; let dev = remote .into_iter() @@ -257,7 +257,7 @@ impl Core { }; let remote = self .rt - .block_on(self.client.enumerate_devices()) + .block_on(self.drive.enumerate_devices()) .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; let d = remote .into_iter() @@ -395,7 +395,7 @@ impl Core { // any cached record, and recover an existing device by name. let remote = self .rt - .block_on(self.client.enumerate_devices()) + .block_on(self.drive.enumerate_devices()) .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; // An adopted uid is an explicit instruction, so it outranks everything @@ -454,7 +454,7 @@ impl Core { None => { let d = self .rt - .block_on(self.client.create_device(&name, DeviceType::Linux)) + .block_on(self.drive.create_device(&name, DeviceType::Linux)) .map_err(|e| CoreError::from_api(&e, "create device"))?; StoredDevice { uid: d.uid.to_string(), diff --git a/crates/pdfs-fuse/src/drive.rs b/crates/pdfs-fuse/src/drive.rs index 1bb7a68..53686f4 100644 --- a/crates/pdfs-fuse/src/drive.rs +++ b/crates/pdfs-fuse/src/drive.rs @@ -8,8 +8,10 @@ //! real client implements it by forwarding, and the simulation tests implement //! it with an in-memory Drive that injects Drive's known faults. //! -//! Sharing, Photos, devices, invitations and public links stay on the concrete -//! client (`Core::client`) until a test needs them faked. +//! Sharing, Photos, invitations, public links and the rest of the device calls +//! stay on the concrete client (`Core::client`) until a test needs them faked. +//! Listing and registering devices are here, so a simulated daemon can sync a +//! local folder. //! //! The method names match the client's own, so a call site reads the same //! whichever it goes through. The client's generic methods (a reader or writer @@ -26,7 +28,8 @@ use proton_drive_rs::proton_sdk::account::Quota; use proton_drive_rs::proton_sdk::error::Result; use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid}; use proton_drive_rs::{ - DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail, + Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, + RevisionReader, Thumbnail, }; /// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`]. @@ -154,6 +157,12 @@ pub(crate) trait DriveApi: Send + Sync { /// The account's storage, across every Proton product. async fn quota(&self) -> Result; + /// Every computer that backs up to the account. + async fn enumerate_devices(&self) -> Result>; + + /// Register a computer, with a root folder of its own named `name`. + async fn create_device(&self, name: &str, device_type: DeviceType) -> Result; + /// Called by the drain once it has let go of the blob of an upload to /// `uid` that landed, or retired the create that made `uid`, before the /// tree has the node, so a simulation can hold it there. Blocks the drain @@ -470,4 +479,12 @@ impl DriveApi for ProtonDriveClient { async fn quota(&self) -> Result { ProtonDriveClient::quota(self).await } + + async fn enumerate_devices(&self) -> Result> { + ProtonDriveClient::enumerate_devices(self).await + } + + async fn create_device(&self, name: &str, device_type: DeviceType) -> Result { + ProtonDriveClient::create_device(self, name, device_type).await + } } diff --git a/crates/pdfs-fuse/src/sim/daemon.rs b/crates/pdfs-fuse/src/sim/daemon.rs index d0be5fa..d71f5f4 100644 --- a/crates/pdfs-fuse/src/sim/daemon.rs +++ b/crates/pdfs-fuse/src/sim/daemon.rs @@ -46,6 +46,7 @@ impl Daemon { /// 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)?; @@ -224,6 +225,33 @@ impl tracing::field::Visit for Message { } } +/// 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 { diff --git a/crates/pdfs-fuse/src/sim/fake_drive.rs b/crates/pdfs-fuse/src/sim/fake_drive.rs index df05d8c..8c14eab 100644 --- a/crates/pdfs-fuse/src/sim/fake_drive.rs +++ b/crates/pdfs-fuse/src/sim/fake_drive.rs @@ -31,6 +31,9 @@ //! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with //! `InsufficientQuota`. //! +//! It also holds the account's computers, each with a root folder outside My +//! files, so a simulated daemon can register its own and sync a local folder. +//! //! A test can also hold the next read of one node on its way to Drive //! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits //! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]), @@ -54,9 +57,12 @@ use parking_lot::Mutex; use proton_drive_rs::proton_sdk::account::Quota; use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result}; -use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; +use proton_drive_rs::proton_sdk::ids::{ + DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId, +}; use proton_drive_rs::{ - DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem, RevisionState, Thumbnail, + Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem, + RevisionState, Thumbnail, }; use sha1::{Digest as _, Sha1}; @@ -111,6 +117,9 @@ struct Server { block_pattern: Vec, /// The account's storage limit in bytes. max_space: i64, + /// The account's computers, oldest first, as `(name, type, root folder)`. + /// [`Computer`] is not `Clone`, so each answer builds them afresh. + devices: Vec<(String, DeviceType, NodeUid)>, } impl Server { @@ -162,6 +171,20 @@ impl Server { false } + /// The `n`th computer of the account, as Drive lists it. + fn computer(&self, n: usize) -> Computer { + let (name, device_type, root) = &self.devices[n]; + Computer { + uid: DeviceUid::new(format!("device{}", n + 1)), + device_type: *device_type, + name: Ok(name.clone()), + root_folder_uid: root.clone(), + creation_time: self.current(root).map_or(0, |remote| remote.created), + last_sync_time: None, + share_id: ShareId::new(format!("device-share{}", n + 1)), + } + } + fn mint_uid(&mut self) -> NodeUid { self.next_link += 1; uid(&format!("L{}", self.next_link)) @@ -305,6 +328,7 @@ impl FakeDrive { events: Vec::new(), block_pattern: vec![pdfs_core::cache::BLOCK_SIZE], max_space: 1 << 40, + devices: Vec::new(), }; let now = now_secs(); server.nodes.insert( @@ -1506,6 +1530,42 @@ impl DriveApi for FakeClient { .await } + async fn enumerate_devices(&self) -> Result> { + self.request(false, |server| { + Ok((0..server.devices.len()) + .map(|n| server.computer(n)) + .collect()) + }) + .await + } + + async fn create_device(&self, name: &str, device_type: DeviceType) -> Result { + self.request(true, |server| { + // A device root has no parent: like on Drive, it is not in My files. + // It is listed at once, as the My files root is: the listing lag is + // about children appearing, and the daemon lists the new root next. + let root = server.mint_uid(); + let now = now_secs(); + server.nodes.insert( + root.clone(), + vec![( + Instant::now() - HISTORY, + Some(Remote { + parent: None, + name: name.to_owned(), + trashed: false, + created: now, + modified: now, + file: None, + }), + )], + ); + server.devices.push((name.to_owned(), device_type, root)); + Ok(server.computer(server.devices.len() - 1)) + }) + .await + } + async fn enumerate_trash_node_uids(&self) -> Result> { self.request(false, |server| { Ok(server