test(fuse): let the simulation sync local folders

A simulated daemon could not add a synced folder, so nothing that moves
between a mount, an on-demand folder and a mirror folder could be tested
without a real account.

Listing and registering devices now go through DriveApi, and the fake
Drive keeps the account's computers, each with a root folder outside My
files. The simulated daemons also read their config.json from a scratch
XDG directory instead of the developer's own: an adopted device of the
real account made every sync folder fail to register against the fake
Drive.
This commit is contained in:
nvrl committed 2026-10-12 00:57:04 +02:00
1 parent c2bf87596f
commit aac53e3139
4 files changed
+115 -10

No files matched your search

+5 -5
View File
@@ -157,7 +157,7 @@ impl Core {
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
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(),
+20 -3
View File
@@ -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<Quota>;
/// Every computer that backs up to the account.
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
/// Register a computer, with a root folder of its own named `name`.
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
/// 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<Quota> {
ProtonDriveClient::quota(self).await
}
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
ProtonDriveClient::enumerate_devices(self).await
}
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
ProtonDriveClient::create_device(self, name, device_type).await
}
}
+28
View File
@@ -46,6 +46,7 @@ impl Daemon {
/// cache and the database the last run left.
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
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 {
+62 -2
View File
@@ -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<u64>,
/// 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<Vec<Computer>> {
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<Computer> {
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<Vec<NodeUid>> {
self.request(false, |server| {
Ok(server